diff --git a/packages/kbn-check-mappings-update-cli/current_fields.json b/packages/kbn-check-mappings-update-cli/current_fields.json index b902407b92e7a..5f3ec0c8552b1 100644 --- a/packages/kbn-check-mappings-update-cli/current_fields.json +++ b/packages/kbn-check-mappings-update-cli/current_fields.json @@ -169,6 +169,9 @@ "external_service.pushed_by.full_name", "external_service.pushed_by.profile_uid", "external_service.pushed_by.username", + "observables", + "observables.typeKey", + "observables.value", "owner", "settings", "settings.syncAlerts", diff --git a/packages/kbn-check-mappings-update-cli/current_mappings.json b/packages/kbn-check-mappings-update-cli/current_mappings.json index 352e753162a36..64dc6150ab4b0 100644 --- a/packages/kbn-check-mappings-update-cli/current_mappings.json +++ b/packages/kbn-check-mappings-update-cli/current_mappings.json @@ -585,6 +585,17 @@ } } }, + "observables": { + "properties": { + "typeKey": { + "type": "keyword" + }, + "value": { + "type": "keyword" + } + }, + "type": "nested" + }, "owner": { "type": "keyword" }, diff --git a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts index 5aca6f1c04446..f2bbcd85834ff 100644 --- a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts +++ b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts @@ -73,7 +73,7 @@ describe('checking migration metadata changes on all registered SO types', () => "canvas-element": "cdedc2123eb8a1506b87a56b0bcce60f4ec08bc8", "canvas-workpad": "9d82aafb19586b119e5c9382f938abe28c26ca5c", "canvas-workpad-template": "c077b0087346776bb3542b51e1385d172cb24179", - "cases": "5433a9f1277f8f17bbc4fd20d33b1fc6d997931e", + "cases": "91771732e2e488e4c1b1ac468057925d1c6b32b5", "cases-comments": "5cb0a421588831c2a950e50f486048b8aabbae25", "cases-configure": "44ed7b8e0f44df39516b8870589b89e32224d2bf", "cases-connector-mappings": "f9d1ac57e484e69506c36a8051e4d61f4a8cfd25", diff --git a/x-pack/plugins/cases/common/api/helpers.ts b/x-pack/plugins/cases/common/api/helpers.ts index 230fe8128855e..fcdf2be6b6159 100644 --- a/x-pack/plugins/cases/common/api/helpers.ts +++ b/x-pack/plugins/cases/common/api/helpers.ts @@ -22,6 +22,10 @@ import { INTERNAL_DELETE_FILE_ATTACHMENTS_URL, CASE_FIND_ATTACHMENTS_URL, INTERNAL_PUT_CUSTOM_FIELDS_URL, + INTERNAL_CASE_OBSERVABLES_URL, + INTERNAL_CASE_OBSERVABLES_PATCH_URL, + INTERNAL_CASE_SIMILAR_CASES_URL, + INTERNAL_CASE_OBSERVABLES_DELETE_URL, } from '../constants'; export const getCaseDetailsUrl = (id: string): string => { @@ -90,3 +94,25 @@ export const getCustomFieldReplaceUrl = (caseId: string, customFieldId: string): customFieldId ); }; + +export const getCaseCreateObservableUrl = (id: string): string => { + return INTERNAL_CASE_OBSERVABLES_URL.replace('{case_id}', id); +}; + +export const getCaseUpdateObservableUrl = (id: string, observableId: string): string => { + return INTERNAL_CASE_OBSERVABLES_PATCH_URL.replace('{case_id}', id).replace( + '{observable_id}', + observableId + ); +}; + +export const getCaseDeleteObservableUrl = (id: string, observableId: string): string => { + return INTERNAL_CASE_OBSERVABLES_DELETE_URL.replace('{case_id}', id).replace( + '{observable_id}', + observableId + ); +}; + +export const getCaseSimilarCasesUrl = (caseId: string) => { + return INTERNAL_CASE_SIMILAR_CASES_URL.replace('{case_id}', caseId); +}; diff --git a/x-pack/plugins/cases/common/constants/index.ts b/x-pack/plugins/cases/common/constants/index.ts index 1fee73f8608c8..70a7f73bd4526 100644 --- a/x-pack/plugins/cases/common/constants/index.ts +++ b/x-pack/plugins/cases/common/constants/index.ts @@ -85,7 +85,14 @@ export const INTERNAL_DELETE_FILE_ATTACHMENTS_URL = export const INTERNAL_GET_CASE_CATEGORIES_URL = `${CASES_INTERNAL_URL}/categories` as const; export const INTERNAL_CASE_METRICS_URL = `${CASES_INTERNAL_URL}/metrics` as const; export const INTERNAL_CASE_METRICS_DETAILS_URL = `${CASES_INTERNAL_URL}/metrics/{case_id}` as const; +export const INTERNAL_CASE_SIMILAR_CASES_URL = `${CASES_INTERNAL_URL}/{case_id}/_similar` as const; export const INTERNAL_PUT_CUSTOM_FIELDS_URL = `${CASES_INTERNAL_URL}/{case_id}/custom_fields/{custom_field_id}`; +export const INTERNAL_CASE_OBSERVABLES_URL = `${CASES_INTERNAL_URL}/{case_id}/observables` as const; +export const INTERNAL_CASE_OBSERVABLES_PATCH_URL = + `${INTERNAL_CASE_OBSERVABLES_URL}/{observable_id}` as const; +export const INTERNAL_CASE_OBSERVABLES_DELETE_URL = + `${INTERNAL_CASE_OBSERVABLES_URL}/{observable_id}` as const; + /** * Action routes */ @@ -142,6 +149,7 @@ export const MAX_TEMPLATES_LENGTH = 10 as const; export const MAX_TEMPLATE_TAG_LENGTH = 50 as const; export const MAX_TAGS_PER_TEMPLATE = 10 as const; export const MAX_FILENAME_LENGTH = 160 as const; +export const MAX_CUSTOM_OBSERVABLE_TYPES_LABEL_LENGTH = 50 as const; /** * Cases features @@ -204,6 +212,7 @@ export const DEFAULT_USER_SIZE = 10; export const MAX_ASSIGNEES_PER_CASE = 10; export const NO_ASSIGNEES_FILTERING_KEYWORD = 'none'; export const KIBANA_SYSTEM_USERNAME = 'elastic/kibana'; +export const MAX_OBSERVABLES_PER_CASE = 50; /** * Delays @@ -262,3 +271,63 @@ export const CASES_CONNECTOR_TIME_WINDOW_REGEX = '^[1-9][0-9]*[d,w]$'; * operation continues, otherwise we throw a 403. */ export const OWNER_FIELD = 'owner'; + +export const MAX_OBSERVABLE_TYPE_KEY_LENGTH = 36; + +export const MAX_OBSERVABLE_TYPE_LABEL_LENGTH = 50; + +export const MAX_CUSTOM_OBSERVABLE_TYPES = 10; + +export const OBSERVABLE_TYPE_EMAIL = { + label: 'Email', + key: 'observable-type-email', +} as const; + +export const OBSERVABLE_TYPE_DOMAIN = { + label: 'Domain', + key: 'observable-type-domain', +} as const; + +export const OBSERVABLE_TYPE_IPV4 = { + label: 'IPv4', + key: 'observable-type-ipv4', +} as const; + +export const OBSERVABLE_TYPE_IPV6 = { + label: 'IPv6', + key: 'observable-type-ipv6', +} as const; + +export const OBSERVABLE_TYPE_URL = { + label: 'URL', + key: 'observable-type-url', +} as const; + +/** + * Exporting an array of built-in observable types for use in the application + */ +export const OBSERVABLE_TYPES_BUILTIN = [ + OBSERVABLE_TYPE_IPV4, + OBSERVABLE_TYPE_IPV6, + OBSERVABLE_TYPE_URL, + { + label: 'Hostname', + key: 'observable-type-hostname', + }, + { + label: 'File hash', + key: 'observable-type-file-hash', + }, + { + label: 'File path', + key: 'observable-type-file-path', + }, + { + ...OBSERVABLE_TYPE_EMAIL, + }, + { + ...OBSERVABLE_TYPE_DOMAIN, + }, +]; + +export const OBSERVABLE_TYPES_BUILTIN_KEYS = OBSERVABLE_TYPES_BUILTIN.map(({ key }) => key); diff --git a/x-pack/plugins/cases/common/types.ts b/x-pack/plugins/cases/common/types.ts index 32d6b34b11c16..bb57a712033ae 100644 --- a/x-pack/plugins/cases/common/types.ts +++ b/x-pack/plugins/cases/common/types.ts @@ -25,4 +25,6 @@ export enum CASE_VIEW_PAGE_TABS { ALERTS = 'alerts', ACTIVITY = 'activity', FILES = 'files', + OBSERVABLES = 'observables', + SIMILAR_CASES = 'similar_cases', } diff --git a/x-pack/plugins/cases/common/types/api/case/v1.test.ts b/x-pack/plugins/cases/common/types/api/case/v1.test.ts index baf9626d3562e..fc8737c6bbfb1 100644 --- a/x-pack/plugins/cases/common/types/api/case/v1.test.ts +++ b/x-pack/plugins/cases/common/types/api/case/v1.test.ts @@ -124,6 +124,16 @@ const basicCase: Case = { value: 3, }, ], + observables: [ + { + value: 'test', + typeKey: '9b557398-0289-4e00-b696-5b277608789c', + id: 'df927ab8-54ed-47d6-be07-9948c255c097', + createdAt: '2024-11-14', + updatedAt: '2024-11-14', + description: null, + }, + ], }; describe('CasePostRequestRt', () => { diff --git a/x-pack/plugins/cases/common/types/api/case/v1.ts b/x-pack/plugins/cases/common/types/api/case/v1.ts index f66df68169e5b..0e1b9ae9894ac 100644 --- a/x-pack/plugins/cases/common/types/api/case/v1.ts +++ b/x-pack/plugins/cases/common/types/api/case/v1.ts @@ -41,6 +41,7 @@ import { CasesRt, CaseStatusRt, RelatedCaseRt, + SimilarCaseRt, } from '../../domain/case/v1'; import { CaseConnectorRt } from '../../domain/connector/v1'; import { CaseUserProfileRt, UserRt } from '../../domain/user/v1'; @@ -394,6 +395,13 @@ export const CasesFindResponseRt = rt.intersection([ CasesStatusResponseRt, ]); +export const CasesSimilarResponseRt = rt.strict({ + cases: rt.array(SimilarCaseRt), + page: rt.number, + per_page: rt.number, + total: rt.number, +}); + /** * Delete cases */ @@ -452,7 +460,10 @@ export const CasePatchRequestRt = rt.intersection([ /** * The saved object ID and version */ - rt.strict({ id: rt.string, version: rt.string }), + rt.strict({ + id: rt.string, + version: rt.string, + }), ]); export const CasesPatchRequestRt = rt.strict({ @@ -519,6 +530,8 @@ export const CasesByAlertIDRequestRt = rt.exact( export const GetRelatedCasesByAlertResponseRt = rt.array(RelatedCaseRt); +export const SimilarCasesSearchRequestRt = paginationSchema({ maxPerPage: MAX_CASES_PER_PAGE }); + export type CasePostRequest = rt.TypeOf; export type CaseResolveResponse = rt.TypeOf; export type CasesDeleteRequest = rt.TypeOf; @@ -542,3 +555,5 @@ export type CaseRequestCustomFields = rt.TypeOf; export type BulkCreateCasesRequest = rt.TypeOf; export type BulkCreateCasesResponse = rt.TypeOf; +export type SimilarCasesSearchRequest = rt.TypeOf; +export type CasesSimilarResponse = rt.TypeOf; diff --git a/x-pack/plugins/cases/common/types/api/configure/v1.test.ts b/x-pack/plugins/cases/common/types/api/configure/v1.test.ts index 64baf7b2e46f4..2952246b1759a 100644 --- a/x-pack/plugins/cases/common/types/api/configure/v1.test.ts +++ b/x-pack/plugins/cases/common/types/api/configure/v1.test.ts @@ -14,8 +14,11 @@ import { MAX_CUSTOM_FIELD_KEY_LENGTH, MAX_CUSTOM_FIELD_LABEL_LENGTH, MAX_CUSTOM_FIELD_TEXT_VALUE_LENGTH, + MAX_CUSTOM_OBSERVABLE_TYPES, MAX_DESCRIPTION_LENGTH, MAX_LENGTH_PER_TAG, + MAX_OBSERVABLE_TYPE_KEY_LENGTH, + MAX_OBSERVABLE_TYPE_LABEL_LENGTH, MAX_TAGS_PER_CASE, MAX_TAGS_PER_TEMPLATE, MAX_TEMPLATES_LENGTH, @@ -38,6 +41,7 @@ import { ToggleCustomFieldConfigurationRt, NumberCustomFieldConfigurationRt, TemplateConfigurationRt, + ObservableTypesConfigurationRt, } from './v1'; describe('configure', () => { @@ -96,6 +100,24 @@ describe('configure', () => { }); }); + it('has expected attributes in request with observableTypes', () => { + const request = { + ...defaultRequest, + observableTypes: [ + { + key: '371357ae-77ce-44bd-88b7-fbba9c80501f', + label: 'Example Label', + }, + ], + }; + const query = ConfigurationRequestRt.decode(request); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: request, + }); + }); + it(`limits customFields to ${MAX_CUSTOM_FIELDS_PER_CASE}`, () => { const customFields = new Array(MAX_CUSTOM_FIELDS_PER_CASE + 1).fill({ key: 'text_custom_field', @@ -270,6 +292,24 @@ describe('configure', () => { ).toContain(`The length of the field templates is too long. Array must be of length <= 10.`); }); + it('has expected attributes in request with observableTypes', () => { + const request = { + ...defaultRequest, + observableTypes: [ + { + key: '371357ae-77ce-44bd-88b7-fbba9c80501f', + label: 'Example Label', + }, + ], + }; + const query = ConfigurationPatchRequestRt.decode(request); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: request, + }); + }); + it('removes foo:bar attributes from request', () => { const query = ConfigurationPatchRequestRt.decode({ ...defaultRequest, foo: 'bar' }); @@ -926,4 +966,85 @@ describe('configure', () => { }); }); }); + + describe('ObservableTypesConfigurationRt', () => { + it('should validate a correct observable types configuration', () => { + const validData = [ + { key: 'observable_key_1', label: 'Observable Label 1' }, + { key: 'observable_key_2', label: 'Observable Label 2' }, + ]; + + const result = ObservableTypesConfigurationRt.decode(validData); + expect(PathReporter.report(result).join()).toContain('No errors!'); + }); + + it('should invalidate an observable types configuration with an invalid key', () => { + const invalidData = [{ key: 'Invalid Key!', label: 'Observable Label 1' }]; + + const result = ObservableTypesConfigurationRt.decode(invalidData); + expect(PathReporter.report(result).join()).not.toContain('No errors!'); + }); + + it('should invalidate an observable types configuration with a missing label', () => { + const invalidData = [{ key: 'observable_key_1' }]; + + const result = ObservableTypesConfigurationRt.decode(invalidData); + expect(PathReporter.report(result).join()).not.toContain('No errors!'); + }); + + it('should accept an observable types configuration with an empty array', () => { + const invalidData: unknown[] = []; + + const result = ObservableTypesConfigurationRt.decode(invalidData); + expect(PathReporter.report(result).join()).toContain('No errors!'); + }); + + it('should invalidate an observable types configuration with a label exceeding max length', () => { + const invalidData = [ + { key: 'observable_key_1', label: 'a'.repeat(MAX_OBSERVABLE_TYPE_LABEL_LENGTH + 1) }, + ]; + + const result = ObservableTypesConfigurationRt.decode(invalidData); + expect(PathReporter.report(result).join()).not.toContain('No errors!'); + }); + + it('should invalidate an observable types configuration with a key exceeding max length', () => { + const invalidData = [{ key: 'a'.repeat(MAX_OBSERVABLE_TYPE_KEY_LENGTH + 1), label: 'label' }]; + + const result = ObservableTypesConfigurationRt.decode(invalidData); + expect(PathReporter.report(result).join()).not.toContain('No errors!'); + }); + + it('should invalidate an observable types configuration with observableTypes count exceeding max', () => { + const invalidData = new Array(MAX_CUSTOM_OBSERVABLE_TYPES + 1).fill({ + key: 'foo', + label: 'label', + }); + + const result = ObservableTypesConfigurationRt.decode(invalidData); + expect(PathReporter.report(result).join()).not.toContain('No errors!'); + }); + + it('accepts a uuid as an key', () => { + const key = uuidv4(); + + const query = ObservableTypesConfigurationRt.decode([{ key, label: 'Observable Label 1' }]); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: [{ key, label: 'Observable Label 1' }], + }); + }); + + it('accepts a slug as an key', () => { + const key = 'abc_key-1'; + + const query = ObservableTypesConfigurationRt.decode([{ key, label: 'Observable Label 1' }]); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: [{ key, label: 'Observable Label 1' }], + }); + }); + }); }); diff --git a/x-pack/plugins/cases/common/types/api/configure/v1.ts b/x-pack/plugins/cases/common/types/api/configure/v1.ts index 52843da1ac1ad..e5682d314f726 100644 --- a/x-pack/plugins/cases/common/types/api/configure/v1.ts +++ b/x-pack/plugins/cases/common/types/api/configure/v1.ts @@ -10,6 +10,9 @@ import { MAX_CUSTOM_FIELDS_PER_CASE, MAX_CUSTOM_FIELD_KEY_LENGTH, MAX_CUSTOM_FIELD_LABEL_LENGTH, + MAX_CUSTOM_OBSERVABLE_TYPES, + MAX_OBSERVABLE_TYPE_KEY_LENGTH, + MAX_OBSERVABLE_TYPE_LABEL_LENGTH, MAX_TAGS_PER_TEMPLATE, MAX_TEMPLATES_LENGTH, MAX_TEMPLATE_DESCRIPTION_LENGTH, @@ -95,6 +98,24 @@ export const CustomFieldsConfigurationRt = limitedArraySchema({ fieldName: 'customFields', }); +export const ObservableTypesConfigurationRt = limitedArraySchema({ + min: 0, + max: MAX_CUSTOM_OBSERVABLE_TYPES, + fieldName: 'observableTypes', + codec: rt.strict({ + key: regexStringRt({ + codec: limitedStringSchema({ fieldName: 'key', min: 1, max: MAX_OBSERVABLE_TYPE_KEY_LENGTH }), + pattern: '^[a-z0-9_-]+$', + message: `Key must be lower case, a-z, 0-9, '_', and '-' are allowed`, + }), + label: limitedStringSchema({ + fieldName: 'label', + min: 1, + max: MAX_OBSERVABLE_TYPE_LABEL_LENGTH, + }), + }), +}); + export const TemplateConfigurationRt = rt.intersection([ rt.strict({ /** @@ -167,6 +188,7 @@ export const ConfigurationRequestRt = rt.intersection([ rt.partial({ customFields: CustomFieldsConfigurationRt, templates: TemplatesConfigurationRt, + observableTypes: ObservableTypesConfigurationRt, }) ), ]); @@ -192,6 +214,7 @@ export const ConfigurationPatchRequestRt = rt.intersection([ connector: ConfigurationBasicWithoutOwnerRt.type.props.connector, customFields: CustomFieldsConfigurationRt, templates: TemplatesConfigurationRt, + observableTypes: ObservableTypesConfigurationRt, }) ), rt.strict({ version: rt.string }), diff --git a/x-pack/plugins/cases/common/types/api/index.ts b/x-pack/plugins/cases/common/types/api/index.ts index 9e8459dd6894b..ddd8392fd0950 100644 --- a/x-pack/plugins/cases/common/types/api/index.ts +++ b/x-pack/plugins/cases/common/types/api/index.ts @@ -17,6 +17,7 @@ export * from './connector/latest'; export * from './attachment/latest'; export * from './metrics/latest'; export * from './custom_field/latest'; +export * from './observable/latest'; // V1 export * as configureApiV1 from './configure/v1'; @@ -30,3 +31,4 @@ export * as connectorApiV1 from './connector/v1'; export * as attachmentApiV1 from './attachment/v1'; export * as metricsApiV1 from './metrics/v1'; export * as customFieldsApiV1 from './custom_field/v1'; +export * as observableApiV1 from './observable/v1'; diff --git a/x-pack/plugins/cases/common/types/api/observable/latest.ts b/x-pack/plugins/cases/common/types/api/observable/latest.ts new file mode 100644 index 0000000000000..25300c97a6d2e --- /dev/null +++ b/x-pack/plugins/cases/common/types/api/observable/latest.ts @@ -0,0 +1,8 @@ +/* + * 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. + */ + +export * from './v1'; diff --git a/x-pack/plugins/cases/common/types/api/observable/v1.test.ts b/x-pack/plugins/cases/common/types/api/observable/v1.test.ts new file mode 100644 index 0000000000000..c13d24dfcab31 --- /dev/null +++ b/x-pack/plugins/cases/common/types/api/observable/v1.test.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AddObservableRequestRt, UpdateObservableRequestRt } from './v1'; + +describe('AddObservableRequestRT', () => { + it('has expected attributes in request', () => { + const defaultRequest = { + observable: { + description: null, + typeKey: 'ef528526-2af9-4345-9b78-046512c5bbd6', + value: 'email@example.com', + }, + }; + + const query = AddObservableRequestRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); +}); + +describe('UpdateObservableRequestRT', () => { + it('has expected attributes in request', () => { + const defaultRequest = { + observable: { + description: null, + value: 'email@example.com', + }, + }; + + const query = UpdateObservableRequestRt.decode(defaultRequest); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: defaultRequest, + }); + }); +}); diff --git a/x-pack/plugins/cases/common/types/api/observable/v1.ts b/x-pack/plugins/cases/common/types/api/observable/v1.ts new file mode 100644 index 0000000000000..c665184a3d20c --- /dev/null +++ b/x-pack/plugins/cases/common/types/api/observable/v1.ts @@ -0,0 +1,33 @@ +/* + * 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 { CaseObservableBaseRt } from '../../domain/observable/v1'; + +/** + * Observables + */ +export const ObservablePostRt = CaseObservableBaseRt; + +export const ObservablePatchRt = rt.strict({ + value: rt.string, + description: rt.union([rt.string, rt.null]), +}); + +export type ObservablePatch = rt.TypeOf; +export type ObservablePost = rt.TypeOf; + +export const AddObservableRequestRt = rt.strict({ + observable: ObservablePostRt, +}); + +export const UpdateObservableRequestRt = rt.strict({ + observable: ObservablePatchRt, +}); + +export type AddObservableRequest = rt.TypeOf; +export type UpdateObservableRequest = rt.TypeOf; diff --git a/x-pack/plugins/cases/common/types/domain/case/v1.test.ts b/x-pack/plugins/cases/common/types/domain/case/v1.test.ts index b0a6f96bcacd0..b4af10013513a 100644 --- a/x-pack/plugins/cases/common/types/domain/case/v1.test.ts +++ b/x-pack/plugins/cases/common/types/domain/case/v1.test.ts @@ -91,6 +91,7 @@ const basicCase = { value: 0, }, ], + observables: [], }; describe('RelatedCaseRt', () => { @@ -204,6 +205,7 @@ describe('CaseAttributesRt', () => { value: 0, }, ], + observables: [], }; it('has expected attributes in request', () => { diff --git a/x-pack/plugins/cases/common/types/domain/case/v1.ts b/x-pack/plugins/cases/common/types/domain/case/v1.ts index 83d48df363bd2..14051228452ed 100644 --- a/x-pack/plugins/cases/common/types/domain/case/v1.ts +++ b/x-pack/plugins/cases/common/types/domain/case/v1.ts @@ -12,6 +12,7 @@ import { CaseAssigneesRt, UserRt } from '../user/v1'; import { CaseConnectorRt } from '../connector/v1'; import { AttachmentRt } from '../attachment/v1'; import { CaseCustomFieldsRt } from '../custom_field/v1'; +import { CaseObservableRt } from '../observable/v1'; export { CaseStatuses }; @@ -90,6 +91,10 @@ const CaseBaseFields = { * The alert sync settings */ settings: CaseSettingsRt, + /** + * Observables + */ + observables: rt.array(CaseObservableRt), }; export const CaseBaseOptionalFieldsRt = rt.exact( @@ -155,6 +160,16 @@ export const RelatedCaseRt = rt.strict({ totals: AttachmentTotalsRt, }); +export const SimilarityRt = rt.strict({ + typeKey: rt.string, + value: rt.string, +}); + +export const SimilarCaseRt = rt.intersection([ + CaseRt, + rt.strict({ similarities: rt.strict({ observables: rt.array(SimilarityRt) }) }), +]); + export type Case = rt.TypeOf; export type Cases = rt.TypeOf; export type CaseAttributes = rt.TypeOf; @@ -162,3 +177,5 @@ export type CaseSettings = rt.TypeOf; export type RelatedCase = rt.TypeOf; export type AttachmentTotals = rt.TypeOf; export type CaseBaseOptionalFields = rt.TypeOf; +export type SimilarCase = rt.TypeOf; +export type SimilarCases = SimilarCase[]; diff --git a/x-pack/plugins/cases/common/types/domain/configure/v1.test.ts b/x-pack/plugins/cases/common/types/domain/configure/v1.test.ts index 59682de1e7c7a..179439d65697d 100644 --- a/x-pack/plugins/cases/common/types/domain/configure/v1.test.ts +++ b/x-pack/plugins/cases/common/types/domain/configure/v1.test.ts @@ -121,6 +121,12 @@ describe('configure', () => { username: 'lknope', email: 'leslie.knope@elastic.co', }, + observableTypes: [ + { + key: '8498cd52-e311-4467-9073-c6056960e2ca', + label: 'Email', + }, + ], }; it('has expected attributes in request', () => { @@ -188,6 +194,12 @@ describe('configure', () => { version: 'WzQ3LDFd', id: 'case-id', error: null, + observableTypes: [ + { + key: '8498cd52-e311-4467-9073-c6056960e2ca', + label: 'Email', + }, + ], }; it('has expected attributes in request', () => { diff --git a/x-pack/plugins/cases/common/types/domain/configure/v1.ts b/x-pack/plugins/cases/common/types/domain/configure/v1.ts index 17760922d2cda..b7d9a09791590 100644 --- a/x-pack/plugins/cases/common/types/domain/configure/v1.ts +++ b/x-pack/plugins/cases/common/types/domain/configure/v1.ts @@ -14,6 +14,7 @@ import { CustomFieldNumberTypeRt, } from '../custom_field/v1'; import { CaseBaseOptionalFieldsRt } from '../case/v1'; +import { CaseObservableTypeRt } from '../observable/v1'; export const ClosureTypeRt = rt.union([ rt.literal('close-by-user'), @@ -73,6 +74,8 @@ export const CustomFieldConfigurationRt = rt.union([ export const CustomFieldsConfigurationRt = rt.array(CustomFieldConfigurationRt); +export const ObservableTypesConfigurationRt = rt.array(CaseObservableTypeRt); + export const TemplateConfigurationRt = rt.intersection([ rt.strict({ /** @@ -121,6 +124,10 @@ export const ConfigurationBasicWithoutOwnerRt = rt.strict({ * Templates configured for the case */ templates: TemplatesConfigurationRt, + /** + * Observable types configured for the case + */ + observableTypes: ObservableTypesConfigurationRt, }); export const CasesConfigureBasicRt = rt.intersection([ @@ -166,3 +173,5 @@ export type ClosureType = rt.TypeOf; export type ConfigurationAttributes = rt.TypeOf; export type Configuration = rt.TypeOf; export type Configurations = rt.TypeOf; +export type ObservableTypesConfiguration = rt.TypeOf; +export type ObservableTypeConfiguration = rt.TypeOf; diff --git a/x-pack/plugins/cases/common/types/domain/index.ts b/x-pack/plugins/cases/common/types/domain/index.ts index ef317908b4627..b6d3cbb8dd76c 100644 --- a/x-pack/plugins/cases/common/types/domain/index.ts +++ b/x-pack/plugins/cases/common/types/domain/index.ts @@ -14,6 +14,7 @@ export * from './case/latest'; export * from './user/latest'; export * from './connector/latest'; export * from './attachment/latest'; +export * from './observable/latest'; // V1 export * as configureDomainV1 from './configure/v1'; @@ -24,3 +25,4 @@ export * as caseDomainV1 from './case/v1'; export * as userDomainV1 from './user/v1'; export * as connectorDomainV1 from './connector/v1'; export * as attachmentDomainV1 from './attachment/v1'; +export * as observableDomainV1 from './observable/v1'; diff --git a/x-pack/plugins/cases/common/types/domain/observable/latest.ts b/x-pack/plugins/cases/common/types/domain/observable/latest.ts new file mode 100644 index 0000000000000..25300c97a6d2e --- /dev/null +++ b/x-pack/plugins/cases/common/types/domain/observable/latest.ts @@ -0,0 +1,8 @@ +/* + * 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. + */ + +export * from './v1'; diff --git a/x-pack/plugins/cases/common/types/domain/observable/v1.test.ts b/x-pack/plugins/cases/common/types/domain/observable/v1.test.ts new file mode 100644 index 0000000000000..a0ed481a2d322 --- /dev/null +++ b/x-pack/plugins/cases/common/types/domain/observable/v1.test.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CaseObservableRt } from './v1'; + +describe('CaseObservableRt', () => { + it('has expected attributes in request', () => { + const observable = { + description: null, + id: '274fcbfc-87b8-47d0-9f17-bfe98e5453e9', + typeKey: 'ef528526-2af9-4345-9b78-046512c5bbd6', + value: 'email@example.com', + createdAt: '2024-10-01', + updatedAt: '2024-10-01', + }; + + const query = CaseObservableRt.decode(observable); + + expect(query).toStrictEqual({ + _tag: 'Right', + right: observable, + }); + }); +}); diff --git a/x-pack/plugins/cases/common/types/domain/observable/v1.ts b/x-pack/plugins/cases/common/types/domain/observable/v1.ts new file mode 100644 index 0000000000000..7fff862acac68 --- /dev/null +++ b/x-pack/plugins/cases/common/types/domain/observable/v1.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as rt from 'io-ts'; + +export const CaseObservableBaseRt = rt.strict({ + typeKey: rt.string, + value: rt.string, + description: rt.union([rt.string, rt.null]), +}); + +export const CaseObservableRt = rt.intersection([ + rt.strict({ + id: rt.string, + createdAt: rt.string, + updatedAt: rt.union([rt.string, rt.null]), + }), + CaseObservableBaseRt, +]); + +export const CaseObservableTypeRt = rt.strict({ + key: rt.string, + label: rt.string, +}); + +export type Observable = rt.TypeOf; +export type ObservableType = rt.TypeOf; diff --git a/x-pack/plugins/cases/common/ui/types.ts b/x-pack/plugins/cases/common/ui/types.ts index a03f38979ceac..2bc12101d65b9 100644 --- a/x-pack/plugins/cases/common/ui/types.ts +++ b/x-pack/plugins/cases/common/ui/types.ts @@ -45,6 +45,7 @@ import type { CaseMetricsFeature, CasesMetricsResponse, SingleCaseMetricsResponse, + CasesSimilarResponse, } from '../types/api'; type DeepRequired = { [K in keyof T]: DeepRequired } & Required; @@ -100,6 +101,8 @@ export type CaseUserActionsStats = SnakeToCamelCase export type CaseUI = Omit, 'comments'> & { comments: AttachmentUI[]; }; +export type ObservableUI = CaseUI['observables'][0]; + export type CasesUI = CaseUI[]; export type CasesFindResponseUI = Omit, 'cases'> & { cases: CasesUI; @@ -109,6 +112,9 @@ export type CaseUpdateRequest = SnakeToCamelCase; export type CaseConnectors = SnakeToCamelCase; export type CaseUsers = GetCaseUsersResponse; export type CaseUICustomField = CaseUI['customFields'][number]; +export type CasesSimilarResponseUI = SnakeToCamelCase; +export type SimilarCaseUI = Omit, 'comments'>; +export type SimilarCasesUI = SimilarCaseUI[]; export interface ResolvedCase { case: CaseUI; @@ -127,6 +133,7 @@ export type CasesConfigurationUI = Pick< | 'id' | 'version' | 'owner' + | 'observableTypes' >; export type CasesConfigurationUICustomField = CasesConfigurationUI['customFields'][number]; @@ -191,6 +198,12 @@ export interface FetchCasesProps extends ApiProps { filterOptions?: FilterOptions; } +export interface SimilarCasesProps extends ApiProps { + caseId: string; + perPage: number; + page: number; +} + export interface ApiProps { signal?: AbortSignal; } diff --git a/x-pack/plugins/cases/public/api/decoders.ts b/x-pack/plugins/cases/public/api/decoders.ts index c2f9f466ec69d..3bd6ff84af710 100644 --- a/x-pack/plugins/cases/public/api/decoders.ts +++ b/x-pack/plugins/cases/public/api/decoders.ts @@ -13,11 +13,13 @@ import type { CasesFindResponse, CasesBulkGetResponse, CasesMetricsResponse, + CasesSimilarResponse, } from '../../common/types/api'; import { CasesFindResponseRt, CasesBulkGetResponseRt, CasesMetricsResponseRt, + CasesSimilarResponseRt, } from '../../common/types/api'; import { createToasterPlainError } from '../containers/utils'; import { throwErrors } from '../../common'; @@ -35,3 +37,9 @@ export const decodeCasesBulkGetResponse = (res: CasesBulkGetResponse) => { return res; }; + +export const decodeCasesSimilarResponse = (respCases?: CasesSimilarResponse) => + pipe( + CasesSimilarResponseRt.decode(respCases), + fold(throwErrors(createToasterPlainError), identity) + ); diff --git a/x-pack/plugins/cases/public/api/utils.test.ts b/x-pack/plugins/cases/public/api/utils.test.ts index e7dd99938e3cf..c909b2303aa85 100644 --- a/x-pack/plugins/cases/public/api/utils.test.ts +++ b/x-pack/plugins/cases/public/api/utils.test.ts @@ -16,6 +16,8 @@ import { persistableStateAttachment, caseUserActionsWithRegisteredAttachments, caseUserActionsWithRegisteredAttachmentsSnake, + similarCasesSnake, + similarCases, } from '../containers/mock'; import { convertAllCasesToCamel, @@ -27,6 +29,7 @@ import { convertAttachmentsToCamelCase, convertAttachmentToCamelCase, convertUserActionsToCamelCase, + convertSimilarCasesToCamel, } from './utils'; describe('utils', () => { @@ -120,4 +123,10 @@ describe('utils', () => { ); }); }); + + describe('convertSimilarCasesToCamel', () => { + it('convert similar cases to camel case', () => { + expect(convertSimilarCasesToCamel(similarCasesSnake)).toEqual(similarCases); + }); + }); }); diff --git a/x-pack/plugins/cases/public/api/utils.ts b/x-pack/plugins/cases/public/api/utils.ts index 99e6ceb6f312c..12d820fdfce77 100644 --- a/x-pack/plugins/cases/public/api/utils.ts +++ b/x-pack/plugins/cases/public/api/utils.ts @@ -11,8 +11,16 @@ import type { AttachmentRequest, CaseResolveResponse, CasesFindResponse, + CasesSimilarResponse, } from '../../common/types/api'; -import type { Attachment, Case, Cases, UserActions } from '../../common/types/domain'; +import type { + Attachment, + Case, + Cases, + SimilarCase, + SimilarCases, + UserActions, +} from '../../common/types/domain'; import { isCommentRequestTypeExternalReference, isCommentRequestTypePersistableState, @@ -24,6 +32,9 @@ import type { CaseUI, AttachmentUI, ResolvedCase, + CasesSimilarResponseUI, + SimilarCasesUI, + SimilarCaseUI, } from '../containers/types'; export const convertArrayToCamelCase = (arrayOfSnakes: unknown[]): unknown[] => @@ -60,6 +71,16 @@ export const convertCaseToCamelCase = (theCase: Case): CaseUI => { export const convertCasesToCamelCase = (cases: Cases): CasesUI => cases.map(convertCaseToCamelCase); +export const convertSimilarCaseToCamelCase = (theCase: SimilarCase): SimilarCaseUI => { + const { comments, ...restCase } = theCase; + return { + ...convertToCamelCase(restCase), + }; +}; + +export const convertSimilarCasesToCamelCase = (cases: SimilarCases): SimilarCasesUI => + cases.map(convertSimilarCaseToCamelCase); + export const convertCaseResolveToCamelCase = (res: CaseResolveResponse): ResolvedCase => { const { case: theCase, ...rest } = res; return { @@ -125,3 +146,12 @@ export const convertAllCasesToCamel = (snakeCases: CasesFindResponse): CasesFind perPage: snakeCases.per_page, total: snakeCases.total, }); + +export const convertSimilarCasesToCamel = ( + snakeCases: CasesSimilarResponse +): CasesSimilarResponseUI => ({ + cases: convertSimilarCasesToCamelCase(snakeCases.cases), + page: snakeCases.page, + perPage: snakeCases.per_page, + total: snakeCases.total, +}); diff --git a/x-pack/plugins/cases/public/common/use_cases_features.test.tsx b/x-pack/plugins/cases/public/common/use_cases_features.test.tsx index c4c54af0b1c42..887ece71aef2c 100644 --- a/x-pack/plugins/cases/public/common/use_cases_features.test.tsx +++ b/x-pack/plugins/cases/public/common/use_cases_features.test.tsx @@ -46,6 +46,7 @@ describe('useCasesFeatures', () => { metricsFeatures: [], caseAssignmentAuthorized: false, pushToServiceAuthorized: false, + observablesAuthorized: false, }); } ); @@ -65,6 +66,7 @@ describe('useCasesFeatures', () => { metricsFeatures: [CaseMetricsFeature.CONNECTORS], caseAssignmentAuthorized: false, pushToServiceAuthorized: false, + observablesAuthorized: false, }); }); @@ -92,6 +94,7 @@ describe('useCasesFeatures', () => { metricsFeatures: [], caseAssignmentAuthorized: expectedResult, pushToServiceAuthorized: expectedResult, + observablesAuthorized: expectedResult, }); } ); diff --git a/x-pack/plugins/cases/public/common/use_cases_features.tsx b/x-pack/plugins/cases/public/common/use_cases_features.tsx index 2f064df9a97a9..b9910c366fb11 100644 --- a/x-pack/plugins/cases/public/common/use_cases_features.tsx +++ b/x-pack/plugins/cases/public/common/use_cases_features.tsx @@ -13,6 +13,7 @@ import { useLicense } from './use_license'; export interface UseCasesFeatures { isAlertsEnabled: boolean; isSyncAlertsEnabled: boolean; + observablesAuthorized: boolean; caseAssignmentAuthorized: boolean; pushToServiceAuthorized: boolean; metricsFeatures: SingleCaseMetricsFeature[]; @@ -38,6 +39,7 @@ export const useCasesFeatures = (): UseCasesFeatures => { metricsFeatures: features.metrics, caseAssignmentAuthorized: hasLicenseGreaterThanPlatinum, pushToServiceAuthorized: hasLicenseGreaterThanPlatinum, + observablesAuthorized: hasLicenseGreaterThanPlatinum, }), [features.alerts.enabled, features.alerts.sync, features.metrics, hasLicenseGreaterThanPlatinum] ); diff --git a/x-pack/plugins/cases/public/components/app/routes.tsx b/x-pack/plugins/cases/public/components/app/routes.tsx index ee2c82777dbf6..aee24f946eade 100644 --- a/x-pack/plugins/cases/public/components/app/routes.tsx +++ b/x-pack/plugins/cases/public/components/app/routes.tsx @@ -6,7 +6,7 @@ */ import React, { lazy, Suspense, useCallback } from 'react'; -import { Redirect } from 'react-router-dom'; +import { Redirect, useLocation } from 'react-router-dom'; import { Routes, Route } from '@kbn/shared-ux-router'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; @@ -45,6 +45,7 @@ const CasesRoutesComponent: React.FC = ({ const { navigateToAllCases } = useAllCasesNavigation(); const { navigateToCaseView } = useCaseViewNavigation(); useReadonlyHeader(); + const location = useLocation(); const onCreateCaseSuccess: CreateCaseFormProps['onSuccess'] = useCallback( async ({ id }) => navigateToCaseView({ detailName: id }), @@ -79,7 +80,12 @@ const CasesRoutesComponent: React.FC = ({ )} - + {/* NOTE: current case view implementation retains some local state between renders, eg. when going from one case directly to another one. as a short term fix, we are forcing the component remount. */} + }> ({ .mockReturnValue(
{'Case view files'}
), })); +jest.mock('./components/case_view_observables', () => ({ + CaseViewObservables: jest + .fn() + .mockReturnValue( +
{'Case view observables'}
+ ), +})); + +jest.mock('./components/case_view_similar_cases', () => ({ + CaseViewSimilarCases: jest + .fn() + .mockReturnValue( +
{'Case view similar cases'}
+ ), +})); + const useUrlParamsMock = useUrlParams as jest.Mock; const useCasesTitleBreadcrumbsMock = useCasesTitleBreadcrumbs as jest.Mock; diff --git a/x-pack/plugins/cases/public/components/case_view/case_view_page.tsx b/x-pack/plugins/cases/public/components/case_view/case_view_page.tsx index add85202d0e55..51bc2a8eb29fb 100644 --- a/x-pack/plugins/cases/public/components/case_view/case_view_page.tsx +++ b/x-pack/plugins/cases/public/components/case_view/case_view_page.tsx @@ -17,10 +17,12 @@ import { useCasesTitleBreadcrumbs } from '../use_breadcrumbs'; import { CaseViewActivity } from './components/case_view_activity'; import { CaseViewAlerts } from './components/case_view_alerts'; import { CaseViewFiles } from './components/case_view_files'; +import { CaseViewObservables } from './components/case_view_observables'; import { CaseViewMetrics } from './metrics'; import type { CaseViewPageProps } from './types'; import { useRefreshCaseViewPage } from './use_on_refresh_case_view_page'; import { useOnUpdateField } from './use_on_update_field'; +import { CaseViewSimilarCases } from './components/case_view_similar_cases'; const getActiveTabId = (tabId?: string) => { if (tabId && Object.values(CASE_VIEW_PAGE_TABS).includes(tabId as CASE_VIEW_PAGE_TABS)) { @@ -122,6 +124,12 @@ export const CaseViewPage = React.memo( )} {activeTabId === CASE_VIEW_PAGE_TABS.FILES && } + {activeTabId === CASE_VIEW_PAGE_TABS.OBSERVABLES && ( + + )} + {activeTabId === CASE_VIEW_PAGE_TABS.SIMILAR_CASES && ( + + )} ); diff --git a/x-pack/plugins/cases/public/components/case_view/case_view_tabs.test.tsx b/x-pack/plugins/cases/public/components/case_view/case_view_tabs.test.tsx index 14e6e94f91194..0c4de8ca2ece0 100644 --- a/x-pack/plugins/cases/public/components/case_view/case_view_tabs.test.tsx +++ b/x-pack/plugins/cases/public/components/case_view/case_view_tabs.test.tsx @@ -8,6 +8,7 @@ import React from 'react'; import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { licensingMock } from '@kbn/licensing-plugin/public/mocks'; import type { AppMockRenderer } from '../../common/mock'; import type { UseGetCase } from '../../containers/use_get_case'; @@ -20,15 +21,18 @@ import { useGetCase } from '../../containers/use_get_case'; import { CaseViewTabs } from './case_view_tabs'; import { caseData, defaultGetCase } from './mocks'; import { useGetCaseFileStats } from '../../containers/use_get_case_file_stats'; +import { useCaseObservables } from './use_case_observables'; jest.mock('../../containers/use_get_case'); jest.mock('../../common/navigation/hooks'); jest.mock('../../common/hooks'); jest.mock('../../containers/use_get_case_file_stats'); +jest.mock('./use_case_observables'); const useFetchCaseMock = useGetCase as jest.Mock; const useCaseViewNavigationMock = useCaseViewNavigation as jest.Mock; const useGetCaseFileStatsMock = useGetCaseFileStats as jest.Mock; +const useGetCaseObservablesMock = useCaseObservables as jest.Mock; const mockGetCase = (props: Partial = {}) => { const data = { @@ -57,10 +61,15 @@ describe('CaseViewTabs', () => { const data = { total: 3 }; beforeEach(() => { + useGetCaseObservablesMock.mockReturnValue({ isLoading: false, observables: [] }); useGetCaseFileStatsMock.mockReturnValue({ data }); mockGetCase(); - appMockRenderer = createAppMockRenderer(); + const license = licensingMock.createLicense({ + license: { type: 'basic' }, + }); + + appMockRenderer = createAppMockRenderer({ license }); }); afterEach(() => { @@ -230,4 +239,74 @@ describe('CaseViewTabs', () => { await screen.findByTestId('case-view-alerts-table-experimental-badge') ).toBeInTheDocument(); }); + + it('should not show observable tabs in non-platinum tiers', async () => { + appMockRenderer = createAppMockRenderer(); + + appMockRenderer.render( + + ); + + expect(screen.queryByTestId('case-view-tab-title-observables')).not.toBeInTheDocument(); + expect(screen.queryByTestId('case-view-tab-title-similar_cases')).not.toBeInTheDocument(); + }); + + describe('show observable tabs in platinum tier or higher', () => { + beforeEach(() => { + const license = licensingMock.createLicense({ + license: { type: 'platinum' }, + }); + appMockRenderer = createAppMockRenderer({ license }); + }); + + it('should show the observables tab', async () => { + appMockRenderer.render( + + ); + + expect(await screen.findByTestId('case-view-tab-title-observables')).toBeInTheDocument(); + }); + + it('should show the similar cases tab', async () => { + appMockRenderer.render( + + ); + + expect(await screen.findByTestId('case-view-tab-title-similar_cases')).toBeInTheDocument(); + }); + + it('navigates to the similar cases tab when the similar cases tab is clicked', async () => { + const navigateToCaseViewMock = useCaseViewNavigationMock().navigateToCaseView; + appMockRenderer.render(); + + await userEvent.click(await screen.findByTestId('case-view-tab-title-similar_cases')); + + await waitFor(() => { + expect(navigateToCaseViewMock).toHaveBeenCalledWith({ + detailName: caseData.id, + tabId: CASE_VIEW_PAGE_TABS.SIMILAR_CASES, + }); + }); + }); + + it('shows the observables tab with the correct count', async () => { + appMockRenderer.render( + + ); + + const badge = await screen.findByTestId('case-view-observables-stats-badge'); + + expect(badge).toHaveTextContent('0'); + }); + + it('do not show count on the observables tab if the call isLoading', async () => { + useGetCaseObservablesMock.mockReturnValue({ isLoading: true, observables: [] }); + + appMockRenderer.render( + + ); + + expect(screen.queryByTestId('case-view-observables-stats-badge')).not.toBeInTheDocument(); + }); + }); }); diff --git a/x-pack/plugins/cases/public/components/case_view/case_view_tabs.tsx b/x-pack/plugins/cases/public/components/case_view/case_view_tabs.tsx index 1dbfbad2c2630..62fe227d5cdb5 100644 --- a/x-pack/plugins/cases/public/components/case_view/case_view_tabs.tsx +++ b/x-pack/plugins/cases/public/components/case_view/case_view_tabs.tsx @@ -7,7 +7,6 @@ import type { EuiThemeComputed } from '@elastic/eui'; import { - EuiBetaBadge, EuiNotificationBadge, EuiSpacer, EuiTab, @@ -20,10 +19,19 @@ import { css } from '@emotion/react'; import { CASE_VIEW_PAGE_TABS } from '../../../common/types'; import { useCaseViewNavigation } from '../../common/navigation'; import { useCasesContext } from '../cases_context/use_cases_context'; -import { EXPERIMENTAL_DESC, EXPERIMENTAL_LABEL } from '../header_page/translations'; -import { ACTIVITY_TAB, ALERTS_TAB, FILES_TAB } from './translations'; +import { + ACTIVITY_TAB, + ALERTS_TAB, + FILES_TAB, + OBSERVABLES_TAB, + SIMILAR_CASES_TAB, +} from './translations'; import type { CaseUI } from '../../../common'; import { useGetCaseFileStats } from '../../containers/use_get_case_file_stats'; +import { useCaseObservables } from './use_case_observables'; +import { ExperimentalBadge } from '../experimental_badge/experimental_badge'; +import { useGetSimilarCases } from '../../containers/use_get_similar_cases'; +import { useCasesFeatures } from '../../common/use_cases_features'; const TabTitle = ({ title }: { title: string }) => ( @@ -61,6 +69,60 @@ const FilesBadge = ({ FilesBadge.displayName = 'FilesBadge'; +const ObservablesBadge = ({ + activeTab, + isLoading, + euiTheme, + count, +}: { + activeTab: string; + count: number; + isLoading: boolean; + euiTheme: EuiThemeComputed<{}>; +}) => ( + <> + {!isLoading && ( + + {count} + + )} + +); + +ObservablesBadge.displayName = 'ObservablesBadge'; + +const SimilarCasesBadge = ({ + activeTab, + count, + euiTheme, +}: { + activeTab: string; + count?: number; + euiTheme: EuiThemeComputed<{}>; +}) => ( + <> + { + + {count ?? 0} + + } + +); + +SimilarCasesBadge.displayName = 'SimilarCasesBadge'; + const AlertsBadge = ({ activeTab, totalAlerts, @@ -83,17 +145,7 @@ const AlertsBadge = ({ {totalAlerts || 0} {isExperimental && ( - + )} ); @@ -109,9 +161,17 @@ export const CaseViewTabs = React.memo(({ caseData, activeTab const { features } = useCasesContext(); const { navigateToCaseView } = useCaseViewNavigation(); const { euiTheme } = useEuiTheme(); - const { data: fileStatsData, isLoading } = useGetCaseFileStats({ + const { data: fileStatsData, isLoading: isLoadingFiles } = useGetCaseFileStats({ + caseId: caseData.id, + }); + const { observables, isLoading: isLoadingObservables } = useCaseObservables(caseData); + + const { data: similarCasesData } = useGetSimilarCases({ caseId: caseData.id, + perPage: 0, + page: 0, }); + const { observablesAuthorized: canShowObservableTabs } = useCasesFeatures(); const tabs = useMemo( () => [ @@ -140,22 +200,53 @@ export const CaseViewTabs = React.memo(({ caseData, activeTab name: FILES_TAB, badge: ( ), }, + ...(canShowObservableTabs + ? [ + { + id: CASE_VIEW_PAGE_TABS.OBSERVABLES, + name: OBSERVABLES_TAB, + badge: ( + + ), + }, + { + id: CASE_VIEW_PAGE_TABS.SIMILAR_CASES, + name: SIMILAR_CASES_TAB, + badge: ( + + ), + }, + ] + : []), ], [ features.alerts.enabled, features.alerts.isExperimental, caseData.totalAlerts, activeTab, - isLoading, - fileStatsData, euiTheme, + isLoadingFiles, + fileStatsData, + canShowObservableTabs, + isLoadingObservables, + observables.length, + similarCasesData?.total, ] ); diff --git a/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.test.tsx b/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.test.tsx index 606a364896ecf..fafc67fd1a5a2 100644 --- a/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.test.tsx +++ b/x-pack/plugins/cases/public/components/case_view/components/case_view_activity.test.tsx @@ -67,7 +67,7 @@ jest.mock('../../../containers/user_profiles/use_get_current_user_profile'); (useGetTags as jest.Mock).mockReturnValue({ data: ['coke', 'pepsi'], refetch: jest.fn() }); (useGetCategories as jest.Mock).mockReturnValue({ data: ['foo', 'bar'], refetch: jest.fn() }); -(useGetCaseConfiguration as jest.Mock).mockReturnValue({ data: {} }); +(useGetCaseConfiguration as jest.Mock).mockReturnValue({ data: { observableTypes: [] } }); (useGetCurrentUserProfile as jest.Mock).mockReturnValue({ data: {}, isFetching: false }); const caseData: CaseUI = { @@ -364,6 +364,7 @@ describe('Case View Page activity tab', () => { (useGetCaseConfiguration as jest.Mock).mockReturnValue({ data: { customFields: [customFieldsConfigurationMock[1]], + observableTypes: [], }, }); appMockRender.render( diff --git a/x-pack/plugins/cases/public/components/case_view/components/case_view_observables.test.tsx b/x-pack/plugins/cases/public/components/case_view/components/case_view_observables.test.tsx new file mode 100644 index 0000000000000..7e030febfca6e --- /dev/null +++ b/x-pack/plugins/cases/public/components/case_view/components/case_view_observables.test.tsx @@ -0,0 +1,36 @@ +/* + * 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 React from 'react'; +import { screen } from '@testing-library/react'; + +import type { AppMockRenderer } from '../../../common/mock'; + +import { createAppMockRenderer } from '../../../common/mock'; +import { basicCase } from '../../../containers/mock'; +import { CaseViewObservables } from './case_view_observables'; + +describe('Case View Page observables tab', () => { + let appMockRender: AppMockRenderer; + + beforeEach(() => { + appMockRender = createAppMockRenderer(); + jest.clearAllMocks(); + }); + + it('should render the utility bar for the observables table', async () => { + appMockRender.render(); + + expect((await screen.findAllByTestId('cases-observables-add')).length).toBe(2); + }); + + it('should render the observable table', async () => { + appMockRender.render(); + + expect(await screen.findByTestId('cases-observables-table')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/case_view/components/case_view_observables.tsx b/x-pack/plugins/cases/public/components/case_view/components/case_view_observables.tsx new file mode 100644 index 0000000000000..65fa3d634207a --- /dev/null +++ b/x-pack/plugins/cases/public/components/case_view/components/case_view_observables.tsx @@ -0,0 +1,52 @@ +/* + * 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 React, { useMemo } from 'react'; + +import { EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; + +import type { CaseUI } from '../../../../common/ui/types'; + +import { CASE_VIEW_PAGE_TABS } from '../../../../common/types'; +import { CaseViewTabs } from '../case_view_tabs'; +import { ObservablesTable } from '../../observables/observables_table'; +import { ObservablesUtilityBar } from '../../observables/observables_utility_bar'; +import { useCaseObservables } from '../use_case_observables'; + +interface CaseViewObservablesProps { + caseData: CaseUI; + isLoading: boolean; +} + +export const CaseViewObservables = ({ caseData, isLoading }: CaseViewObservablesProps) => { + const { observables, isLoading: isLoadingObservables } = useCaseObservables(caseData); + + const caseDataWithFilteredObservables: CaseUI = useMemo(() => { + return { + ...caseData, + observables, + }; + }, [caseData, observables]); + + return ( + + + + + + + + + + + + ); +}; + +CaseViewObservables.displayName = 'CaseViewObservables'; diff --git a/x-pack/plugins/cases/public/components/case_view/components/case_view_similar_cases.test.tsx b/x-pack/plugins/cases/public/components/case_view/components/case_view_similar_cases.test.tsx new file mode 100644 index 0000000000000..a7b4631b1ac77 --- /dev/null +++ b/x-pack/plugins/cases/public/components/case_view/components/case_view_similar_cases.test.tsx @@ -0,0 +1,36 @@ +/* + * 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 React from 'react'; +import { screen } from '@testing-library/react'; + +import type { CaseUI } from '../../../../common'; +import type { AppMockRenderer } from '../../../common/mock'; + +import { createAppMockRenderer } from '../../../common/mock'; +import { alertCommentWithIndices, basicCase } from '../../../containers/mock'; +import { CaseViewSimilarCases } from './case_view_similar_cases'; + +const caseData: CaseUI = { + ...basicCase, + comments: [...basicCase.comments, alertCommentWithIndices], +}; + +describe('Case View Page similar cases tab', () => { + let appMockRender: AppMockRenderer; + + beforeEach(() => { + appMockRender = createAppMockRenderer(); + jest.clearAllMocks(); + }); + + it('should render the similar cases table', async () => { + appMockRender.render(); + + expect(await screen.findByTestId('similar-cases-table')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/case_view/components/case_view_similar_cases.tsx b/x-pack/plugins/cases/public/components/case_view/components/case_view_similar_cases.tsx new file mode 100644 index 0000000000000..cb72af1fa0e1f --- /dev/null +++ b/x-pack/plugins/cases/public/components/case_view/components/case_view_similar_cases.tsx @@ -0,0 +1,67 @@ +/* + * 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 React, { useCallback, useMemo, useState } from 'react'; + +import { EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; + +import { useGetSimilarCases, initialData } from '../../../containers/use_get_similar_cases'; +import type { CaseUI } from '../../../../common/ui/types'; + +import { CASE_VIEW_PAGE_TABS } from '../../../../common/types'; +import { CaseViewTabs } from '../case_view_tabs'; +import { CASES_TABLE_PER_PAGE_VALUES, type EuiBasicTableOnChange } from '../../all_cases/types'; +import { SimilarCasesTable } from '../../similar_cases/table'; + +interface CaseViewSimilarCasesProps { + caseData: CaseUI; +} + +export const CaseViewSimilarCases = ({ caseData }: CaseViewSimilarCasesProps) => { + const [pageIndex, setPageIndex] = useState(0); + const [pageSize, setPageSize] = useState(CASES_TABLE_PER_PAGE_VALUES[0]); + + const { data = initialData, isFetching: isLoadingCases } = useGetSimilarCases({ + caseId: caseData.id, + page: pageIndex + 1, + perPage: pageSize, + }); + + const tableOnChangeCallback = useCallback(({ page, sort }: EuiBasicTableOnChange) => { + setPageIndex(page.index); + setPageSize(page.size); + }, []); + + const pagination = useMemo( + () => ({ + pageIndex, + pageSize, + totalItemCount: data.total ?? 0, + pageSizeOptions: CASES_TABLE_PER_PAGE_VALUES, + }), + [data.total, pageIndex, pageSize] + ); + + return ( + + + + + + + + + + + ); +}; + +CaseViewSimilarCases.displayName = 'CaseViewObservables'; diff --git a/x-pack/plugins/cases/public/components/case_view/translations.ts b/x-pack/plugins/cases/public/components/case_view/translations.ts index b876e94d760ec..341b7db784029 100644 --- a/x-pack/plugins/cases/public/components/case_view/translations.ts +++ b/x-pack/plugins/cases/public/components/case_view/translations.ts @@ -187,6 +187,14 @@ export const FILES_TAB = i18n.translate('xpack.cases.caseView.tabs.files', { defaultMessage: 'Files', }); +export const OBSERVABLES_TAB = i18n.translate('xpack.cases.caseView.tabs.observables', { + defaultMessage: 'Observables', +}); + +export const SIMILAR_CASES_TAB = i18n.translate('xpack.cases.caseView.tabs.similar', { + defaultMessage: 'Similar cases', +}); + export const ALERTS_EMPTY_DESCRIPTION = i18n.translate( 'xpack.cases.caseView.tabs.alerts.emptyDescription', { diff --git a/x-pack/plugins/cases/public/components/case_view/use_case_observables.test.ts b/x-pack/plugins/cases/public/components/case_view/use_case_observables.test.ts new file mode 100644 index 0000000000000..183619786deea --- /dev/null +++ b/x-pack/plugins/cases/public/components/case_view/use_case_observables.test.ts @@ -0,0 +1,94 @@ +/* + * 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 { renderHook } from '@testing-library/react-hooks'; +import { useCaseObservables } from './use_case_observables'; +import { useGetCaseConfiguration } from '../../containers/configure/use_get_case_configuration'; +import { OBSERVABLE_TYPES_BUILTIN_KEYS } from '../../../common/constants'; +import { caseData } from './mocks'; + +const mockCaseData = { + ...caseData, + observables: [ + { + typeKey: 'type1', + value: '127.0.0.1', + description: null, + id: '6d44e478-3b35-4c48-929a-b22e98bfe178', + createdAt: '2024-12-02', + updatedAt: '2024-12-02', + }, + { + typeKey: 'unknown-type', + value: '127.0.0.1', + description: null, + id: '6d44e478-3b35-4c48-929a-b22e98bfe178', + createdAt: '2024-12-02', + updatedAt: '2024-12-02', + }, + ], +}; + +jest.mock('../../containers/configure/use_get_case_configuration'); + +describe('useCaseObservables', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns loading state when configuration is loading', () => { + (useGetCaseConfiguration as jest.Mock).mockReturnValue({ + data: { observableTypes: [] }, + isLoading: true, + }); + + const { result } = renderHook(() => useCaseObservables(mockCaseData)); + + expect(result.current).toEqual({ + observables: [], + isLoading: true, + }); + }); + + it('filters observables based on available types', () => { + (useGetCaseConfiguration as jest.Mock).mockReturnValue({ + data: { observableTypes: [{ key: 'type1' }] }, + isLoading: false, + }); + + const { result } = renderHook(() => useCaseObservables(mockCaseData)); + + expect(result.current).toEqual({ + observables: [ + { + typeKey: 'type1', + value: '127.0.0.1', + description: null, + id: '6d44e478-3b35-4c48-929a-b22e98bfe178', + createdAt: '2024-12-02', + updatedAt: '2024-12-02', + }, + ], + isLoading: false, + }); + }); + + it('includes built-in observable types', () => { + (useGetCaseConfiguration as jest.Mock).mockReturnValue({ + data: { observableTypes: [] }, + isLoading: false, + }); + + const { result } = renderHook(() => useCaseObservables(mockCaseData)); + + expect(result.current.observables).toEqual( + mockCaseData.observables.filter(({ typeKey }) => + OBSERVABLE_TYPES_BUILTIN_KEYS.includes(typeKey) + ) + ); + }); +}); diff --git a/x-pack/plugins/cases/public/components/case_view/use_case_observables.ts b/x-pack/plugins/cases/public/components/case_view/use_case_observables.ts new file mode 100644 index 0000000000000..6853295ce75d3 --- /dev/null +++ b/x-pack/plugins/cases/public/components/case_view/use_case_observables.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; +import { OBSERVABLE_TYPES_BUILTIN_KEYS } from '../../../common/constants'; +import type { CaseUI } from '../../../common'; +import { useGetCaseConfiguration } from '../../containers/configure/use_get_case_configuration'; + +export const useCaseObservables = (caseData: CaseUI) => { + const { data: currentConfiguration, isLoading: loadingCaseConfigure } = useGetCaseConfiguration(); + + return useMemo(() => { + if (loadingCaseConfigure) { + return { + observables: [], + isLoading: true, + }; + } + + const availableTypesSet = new Set([ + ...OBSERVABLE_TYPES_BUILTIN_KEYS, + ...currentConfiguration.observableTypes.map(({ key }) => key), + ]); + + return { + observables: caseData.observables.filter(({ typeKey }) => availableTypesSet.has(typeKey)), + isLoading: loadingCaseConfigure, + }; + }, [caseData.observables, currentConfiguration.observableTypes, loadingCaseConfigure]); +}; diff --git a/x-pack/plugins/cases/public/components/configure_cases/__mock__/index.tsx b/x-pack/plugins/cases/public/components/configure_cases/__mock__/index.tsx index bf1ace60ced91..c7781bc9fcad9 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/__mock__/index.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/__mock__/index.tsx @@ -32,6 +32,7 @@ const mockConfigurationData = { version: '', id: '', owner: mockedTestProvidersOwner[0], + observableTypes: [], }; export const useCaseConfigureResponse = { diff --git a/x-pack/plugins/cases/public/components/configure_cases/flyout.test.tsx b/x-pack/plugins/cases/public/components/configure_cases/flyout.test.tsx index 8b42dd7df6f0d..0920950ed65b2 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/flyout.test.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/flyout.test.tsx @@ -365,6 +365,7 @@ describe('CommonFlyout ', () => { version: '', id: '', owner: mockedTestProvidersOwner[0], + observableTypes: [], }; const renderBody = ({ onChange }: FlyOutBodyProps) => ( diff --git a/x-pack/plugins/cases/public/components/configure_cases/index.test.tsx b/x-pack/plugins/cases/public/components/configure_cases/index.test.tsx index c309509d563a3..d0ee4fcebab9f 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/index.test.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/index.test.tsx @@ -12,8 +12,12 @@ import { waitFor, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { ConfigureCases } from '.'; -import { noCasesSettingsPermission, TestProviders, createAppMockRenderer } from '../../common/mock'; -import { customFieldsConfigurationMock, templatesConfigurationMock } from '../../containers/mock'; +import { + observableTypesMock, + customFieldsConfigurationMock, + templatesConfigurationMock, +} from '../../containers/mock'; +import { TestProviders, createAppMockRenderer, noCasesSettingsPermission } from '../../common/mock'; import type { AppMockRenderer } from '../../common/mock'; import { Connectors } from './connectors'; import { ClosureOptions } from './closure_options'; @@ -71,7 +75,7 @@ describe('ConfigureCases', () => { beforeEach(() => { useGetActionTypesMock.mockImplementation(() => useActionTypesResponse); - useLicenseMock.mockReturnValue({ isAtLeastGold: () => true }); + useLicenseMock.mockReturnValue({ isAtLeastGold: () => true, isAtLeastPlatinum: () => false }); }); describe('rendering', () => { @@ -1257,6 +1261,160 @@ describe('ConfigureCases', () => { }); }); + describe('observable types', () => { + let appMockRender: AppMockRenderer; + const persistCaseConfigure = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + usePersistConfigurationMock.mockImplementation(() => ({ + ...usePersistConfigurationMockResponse, + mutate: persistCaseConfigure, + })); + useLicenseMock.mockReturnValue({ isAtLeastPlatinum: () => true, isAtLeastGold: () => true }); + }); + + it('should render observable types section', async () => { + appMockRender.render(); + + expect(await screen.findByTestId('observable-types-form-group')).toBeInTheDocument(); + expect(await screen.findByTestId('add-observable-type')).toBeInTheDocument(); + }); + + it('opens fly out for when click on add observable type', async () => { + appMockRender.render(); + + await userEvent.click(screen.getByTestId('add-observable-type')); + + expect(await screen.findByTestId('common-flyout')).toBeInTheDocument(); + }); + + it('closes fly out for when click on cancel', async () => { + appMockRender.render(); + + await userEvent.click(screen.getByTestId('add-observable-type')); + + expect(await screen.findByTestId('common-flyout')).toBeInTheDocument(); + + await userEvent.click(screen.getByTestId('common-flyout-cancel')); + + expect(await screen.findByTestId('observable-types-form-group')).toBeInTheDocument(); + expect(screen.queryByTestId('common-flyout')).not.toBeInTheDocument(); + }); + + it('closes fly out and updates the data when click on save', async () => { + appMockRender.render(); + + await userEvent.click(screen.getByTestId('add-observable-type')); + + expect(await screen.findByTestId('common-flyout')).toBeInTheDocument(); + + await userEvent.click(screen.getByTestId('observable-type-label-input')); + await userEvent.paste('added'); + + await userEvent.click(screen.getByTestId('common-flyout-save')); + + await waitFor(() => { + expect(persistCaseConfigure).toHaveBeenCalledWith( + expect.objectContaining({ + observableTypes: [expect.objectContaining({ key: expect.any(String), label: 'added' })], + }) + ); + }); + + expect(await screen.findByTestId('observable-types-form-group')).toBeInTheDocument(); + expect(screen.queryByTestId('common-flyout')).not.toBeInTheDocument(); + }); + + it('updates observable type correctly', async () => { + useGetCaseConfigurationMock.mockImplementation(() => ({ + ...useCaseConfigureResponse, + data: { + ...useCaseConfigureResponse.data, + observableTypes: observableTypesMock, + }, + })); + + appMockRender.render(); + + const list = screen.getByTestId('observable-types-list'); + + await userEvent.click( + within(list).getByTestId(`${observableTypesMock[0].key}-observable-type-edit`) + ); + + expect(await screen.findByTestId('common-flyout')).toBeInTheDocument(); + + expect(await screen.findByTestId('common-flyout-header')).toHaveTextContent( + i18n.EDIT_OBSERVABLE_TYPE + ); + + await userEvent.click(screen.getByTestId('observable-type-label-input')); + await userEvent.paste('updated'); + await userEvent.click(screen.getByTestId('common-flyout-save')); + + const updatedObservableTypes = structuredClone(observableTypesMock); + updatedObservableTypes[0].label = 'test_observable_type_1updated'; + + await waitFor(() => { + expect(persistCaseConfigure).toHaveBeenCalledWith({ + connector: { + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, + closureType: 'close-by-user', + customFields: [], + templates: [], + observableTypes: updatedObservableTypes, + id: '', + version: '', + }); + }); + }); + + it('deletes observable types correctly', async () => { + useGetCaseConfigurationMock.mockImplementation(() => ({ + ...useCaseConfigureResponse, + data: { + ...useCaseConfigureResponse.data, + observableTypes: observableTypesMock, + }, + })); + + appMockRender.render(); + + const list = screen.getByTestId('observable-types-list'); + + await userEvent.click( + within(list).getByTestId(`${observableTypesMock[0].key}-observable-type-delete`) + ); + + expect(await screen.findByTestId('confirm-delete-modal')).toBeInTheDocument(); + + await userEvent.click(screen.getByText('Delete')); + + await waitFor(() => { + expect(persistCaseConfigure).toHaveBeenCalledWith({ + connector: { + id: 'none', + name: 'none', + type: ConnectorTypes.none, + fields: null, + }, + customFields: [], + closureType: 'close-by-user', + observableTypes: [observableTypesMock[1]], + templates: [], + id: '', + version: '', + }); + }); + }); + }); + describe('rendering with license limitations', () => { let appMockRender: AppMockRenderer; let persistCaseConfigure: jest.Mock; @@ -1273,7 +1431,10 @@ describe('ConfigureCases', () => { useGetCaseConfigurationMock.mockImplementation(() => useCaseConfigureResponse); // Updated - useLicenseMock.mockReturnValue({ isAtLeastGold: () => false }); + useLicenseMock.mockReturnValue({ + isAtLeastGold: () => false, + isAtLeastPlatinum: () => false, + }); }); it('should not render connectors and closure options', () => { @@ -1287,6 +1448,13 @@ describe('ConfigureCases', () => { expect(screen.getByTestId('custom-fields-form-group')).toBeInTheDocument(); }); + it('should not render observable types section', async () => { + appMockRender.render(); + + expect(screen.queryByTestId('observable-types-form-group')).not.toBeInTheDocument(); + expect(screen.queryByTestId('add-observable-type')).not.toBeInTheDocument(); + }); + describe('when the previously selected connector doesnt appear due to license downgrade or because it was deleted', () => { beforeEach(() => { useGetCaseConfigurationMock.mockImplementation(() => ({ diff --git a/x-pack/plugins/cases/public/components/configure_cases/index.tsx b/x-pack/plugins/cases/public/components/configure_cases/index.tsx index 071a4c5cfac4e..371369ee105a4 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/index.tsx +++ b/x-pack/plugins/cases/public/components/configure_cases/index.tsx @@ -29,6 +29,7 @@ import type { TemplateConfiguration, CustomFieldTypes, ActionConnector, + ObservableTypeConfiguration, } from '../../../common/types/domain'; import { useKibana } from '../../common/lib/kibana'; import { useGetActionTypes } from '../../containers/configure/use_action_types'; @@ -55,6 +56,8 @@ import { CustomFieldsForm } from '../custom_fields/form'; import { TemplateForm } from '../templates/form'; import type { CasesConfigurationUI, CaseUI } from '../../containers/types'; import { builderMap as customFieldsBuilderMap } from '../custom_fields/builder'; +import { ObservableTypes } from '../observable_types'; +import { ObservableTypesForm } from '../observable_types/form'; const sectionWrapperCss = css` box-sizing: content-box; @@ -71,7 +74,7 @@ const getFormWrapperCss = (euiTheme: EuiThemeComputed<{}>) => css` `; interface Flyout { - type: 'addConnector' | 'editConnector' | 'customField' | 'template'; + type: 'addConnector' | 'editConnector' | 'customField' | 'template' | 'observableTypes'; visible: boolean; } @@ -115,6 +118,7 @@ export const ConfigureCases: React.FC = React.memo(() => { useCasesBreadcrumbs(CasesDeepLinkId.casesConfigure); const license = useLicense(); const hasMinimumLicensePermissions = license.isAtLeastGold(); + const hasMinimumLicensePermissionsForObservables = license.isAtLeastPlatinum(); const [connectorIsValid, setConnectorIsValid] = useState(true); const [flyOutVisibility, setFlyOutVisibility] = useState(null); @@ -123,6 +127,8 @@ export const ConfigureCases: React.FC = React.memo(() => { ); const [customFieldToEdit, setCustomFieldToEdit] = useState(null); const [templateToEdit, setTemplateToEdit] = useState(null); + const [observableTypeToEdit, setObservableTypeToEdit] = + useState(null); const { euiTheme } = useEuiTheme(); const { @@ -139,6 +145,7 @@ export const ConfigureCases: React.FC = React.memo(() => { mappings, customFields, templates, + observableTypes, } = currentConfiguration; const { @@ -375,6 +382,87 @@ export const ConfigureCases: React.FC = React.memo(() => { setCustomFieldToEdit(null); }, [setFlyOutVisibility, setCustomFieldToEdit]); + const onEditObservableType = useCallback( + (key: string) => { + const selectedObservableType = observableTypes.find((item) => item.key === key); + + if (selectedObservableType) { + setObservableTypeToEdit(selectedObservableType); + } + setFlyOutVisibility({ type: 'observableTypes', visible: true }); + }, + [setFlyOutVisibility, observableTypes] + ); + + const onDeleteObservableType = useCallback( + (key: string) => { + const remainingObservableTypes = observableTypes.filter((field) => field.key !== key); + + persistCaseConfigure({ + connector, + observableTypes: remainingObservableTypes, + id: configurationId, + version: configurationVersion, + closureType, + customFields, + templates, + }); + }, + [ + closureType, + configurationId, + configurationVersion, + connector, + observableTypes, + persistCaseConfigure, + customFields, + templates, + ] + ); + + const onCloseObservableTypesFlyout = useCallback(() => { + setFlyOutVisibility({ type: 'observableTypes', visible: false }); + setObservableTypeToEdit(null); + }, [setFlyOutVisibility]); + + const onObservableTypeSave = useCallback( + (data: ObservableTypeConfiguration) => { + const existingObservableIndex = observableTypes.findIndex((item) => item.key === data.key); + + let updatedObservableTypes = []; + + if (existingObservableIndex === -1) { + updatedObservableTypes = [...structuredClone(observableTypes), data]; + } else { + updatedObservableTypes = structuredClone(observableTypes); + updatedObservableTypes[existingObservableIndex] = data; + } + + persistCaseConfigure({ + connector, + id: configurationId, + version: configurationVersion, + closureType, + observableTypes: updatedObservableTypes, + customFields, + templates, + }); + + onCloseObservableTypesFlyout(); + }, + [ + observableTypes, + persistCaseConfigure, + connector, + configurationId, + configurationVersion, + closureType, + customFields, + templates, + onCloseObservableTypesFlyout, + ] + ); + const onCustomFieldSave = useCallback( (data: CustomFieldConfiguration) => { const updatedCustomFields = addOrReplaceField(customFields, data); @@ -516,6 +604,23 @@ export const ConfigureCases: React.FC = React.memo(() => { ) : null; + const AddOrEditObservableTypeFlyout = + flyOutVisibility?.type === 'observableTypes' && flyOutVisibility?.visible ? ( + + isLoading={loadingCaseConfigure || isPersistingConfiguration} + disabled={!permissions.settings || loadingCaseConfigure || isPersistingConfiguration} + onCloseFlyout={onCloseObservableTypesFlyout} + onSaveField={onObservableTypeSave} + renderHeader={() => ( + {observableTypeToEdit ? i18n.EDIT_OBSERVABLE_TYPE : i18n.ADD_OBSERVABLE_TYPE} + )} + > + {({ onChange }) => ( + + )} + + ) : null; + return ( { /> + + {hasMinimumLicensePermissionsForObservables && ( + <> + + +
+ + + setFlyOutVisibility({ type: 'observableTypes', visible: true }) + } + handleDeleteObservableType={onDeleteObservableType} + handleEditObservableType={onEditObservableType} + /> + +
+ + )} + + {ConnectorAddFlyout} {ConnectorEditFlyout} {AddOrEditCustomFieldFlyout} {AddOrEditTemplateFlyout} + {AddOrEditObservableTypeFlyout}
diff --git a/x-pack/plugins/cases/public/components/configure_cases/translations.ts b/x-pack/plugins/cases/public/components/configure_cases/translations.ts index 4fe462655dcc1..174bb5fafecd7 100644 --- a/x-pack/plugins/cases/public/components/configure_cases/translations.ts +++ b/x-pack/plugins/cases/public/components/configure_cases/translations.ts @@ -186,3 +186,17 @@ export const CREATE_TEMPLATE = i18n.translate('xpack.cases.configureCases.templa export const EDIT_TEMPLATE = i18n.translate('xpack.cases.configureCases.templates.editTemplate', { defaultMessage: 'Edit template', }); + +export const ADD_OBSERVABLE_TYPE = i18n.translate( + 'xpack.cases.configureCases.observableTypes.addObservableType', + { + defaultMessage: 'Add observable type', + } +); + +export const EDIT_OBSERVABLE_TYPE = i18n.translate( + 'xpack.cases.configureCases.observableTypes.editObservableType', + { + defaultMessage: 'Edit observable type', + } +); diff --git a/x-pack/plugins/cases/public/components/experimental_badge/experimental_badge.tsx b/x-pack/plugins/cases/public/components/experimental_badge/experimental_badge.tsx index ef3f4a8584141..73ae8590f1376 100644 --- a/x-pack/plugins/cases/public/components/experimental_badge/experimental_badge.tsx +++ b/x-pack/plugins/cases/public/components/experimental_badge/experimental_badge.tsx @@ -15,12 +15,14 @@ interface Props { icon?: boolean; size?: EuiBetaBadgeProps['size']; compact?: boolean; + 'data-test-subj'?: string; } const ExperimentalBadgeComponent: React.FC = ({ icon = false, size = 's', compact = false, + 'data-test-subj': testSubj = 'case-experimental-badge', }) => { const props: EuiBetaBadgeProps = { label: compact ? null : EXPERIMENTAL_LABEL, @@ -28,7 +30,7 @@ const ExperimentalBadgeComponent: React.FC = ({ ...((icon || compact) && { iconType: 'beaker' }), tooltipContent: EXPERIMENTAL_DESC, tooltipPosition: 'bottom' as const, - 'data-test-subj': 'case-experimental-badge', + 'data-test-subj': testSubj, }; const { euiTheme } = useEuiTheme(); diff --git a/x-pack/plugins/cases/public/components/observable_types/delete_confirmation_modal.test.tsx b/x-pack/plugins/cases/public/components/observable_types/delete_confirmation_modal.test.tsx new file mode 100644 index 0000000000000..caa622c2c32cd --- /dev/null +++ b/x-pack/plugins/cases/public/components/observable_types/delete_confirmation_modal.test.tsx @@ -0,0 +1,52 @@ +/* + * 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 userEvent from '@testing-library/user-event'; +import React from 'react'; +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; +import { DeleteConfirmationModal } from './delete_confirmation_modal'; + +describe('DeleteConfirmationModal', () => { + let appMock: AppMockRenderer; + const props = { + label: 'Delete observable', + onCancel: jest.fn(), + onConfirm: jest.fn(), + }; + + beforeEach(() => { + appMock = createAppMockRenderer(); + jest.clearAllMocks(); + }); + + it('renders correctly', async () => { + const result = appMock.render(); + + expect(result.getByTestId('confirm-delete-observable-modal')).toBeInTheDocument(); + expect(result.getByText('Delete')).toBeInTheDocument(); + expect(result.getByText('Cancel')).toBeInTheDocument(); + }); + + it('calls onConfirm', async () => { + const result = appMock.render(); + + expect(result.getByText('Delete')).toBeInTheDocument(); + await userEvent.click(result.getByText('Delete')); + + expect(props.onConfirm).toHaveBeenCalled(); + }); + + it('calls onCancel', async () => { + const result = appMock.render(); + + expect(result.getByText('Cancel')).toBeInTheDocument(); + await userEvent.click(result.getByText('Cancel')); + + expect(props.onCancel).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/observable_types/delete_confirmation_modal.tsx b/x-pack/plugins/cases/public/components/observable_types/delete_confirmation_modal.tsx new file mode 100644 index 0000000000000..7b3d7bd0a48b9 --- /dev/null +++ b/x-pack/plugins/cases/public/components/observable_types/delete_confirmation_modal.tsx @@ -0,0 +1,40 @@ +/* + * 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 React from 'react'; +import { EuiConfirmModal } from '@elastic/eui'; +import * as i18n from './translations'; + +interface ConfirmDeleteCaseModalProps { + label: string; + onCancel: () => void; + onConfirm: () => void; +} + +const DeleteConfirmationModalComponent: React.FC = ({ + label, + onCancel, + onConfirm, +}) => { + return ( + + {i18n.DELETE_OBSERVABLE_TYPE_DESCRIPTION} + + ); +}; +DeleteConfirmationModalComponent.displayName = 'DeleteConfirmationModal'; + +export const DeleteConfirmationModal = React.memo(DeleteConfirmationModalComponent); diff --git a/x-pack/plugins/cases/public/components/observable_types/form.test.tsx b/x-pack/plugins/cases/public/components/observable_types/form.test.tsx new file mode 100644 index 0000000000000..a4feb8fa0b467 --- /dev/null +++ b/x-pack/plugins/cases/public/components/observable_types/form.test.tsx @@ -0,0 +1,127 @@ +/* + * 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 React from 'react'; +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; +import { ObservableTypesForm, type ObservableTypesFormProps } from './form'; +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import type { FormState } from '../configure_cases/flyout'; +import type { ObservableTypeConfiguration } from '../../../common/types/domain/configure/v1'; +import { MAX_CUSTOM_OBSERVABLE_TYPES_LABEL_LENGTH } from '../../../common/constants'; + +describe('ObservableTypesForm ', () => { + let appMock: AppMockRenderer; + + const props: ObservableTypesFormProps = { + onChange: jest.fn(), + initialValue: null, + }; + + beforeEach(() => { + appMock = createAppMockRenderer(); + jest.clearAllMocks(); + }); + + it('renders correctly', async () => { + appMock.render(); + expect(await screen.findByTestId('observable-types-form')).toBeInTheDocument(); + }); + + describe('when initial value is set', () => { + let formState: FormState; + const onChangeState = (state: FormState) => (formState = state); + + it('should pass initial key to onChange handler', async () => { + appMock.render( + + ); + + await waitFor(() => { + expect(formState).not.toBeUndefined(); + }); + + const labelInput = await screen.findByTestId('observable-type-label-input'); + + expect(labelInput).toBeInTheDocument(); + + fireEvent.change(labelInput, { + target: { value: 'changed label' }, + }); + + const { data, isValid } = await formState!.submit(); + + expect(isValid).toEqual(true); + expect(data.key).toEqual('initial-key'); + expect(data.label).toEqual('changed label'); + }); + + it('should not allow invalid labels', async () => { + appMock.render( + + ); + + await waitFor(() => { + expect(formState).not.toBeUndefined(); + }); + + const labelInput = await screen.findByTestId('observable-type-label-input'); + + expect(labelInput).toBeInTheDocument(); + + fireEvent.change(labelInput, { + target: { value: '' }, + }); + + const { isValid } = await formState!.submit(); + + expect(isValid).toEqual(false); + + fireEvent.change(labelInput, { + target: { value: 'a'.repeat(MAX_CUSTOM_OBSERVABLE_TYPES_LABEL_LENGTH + 1) }, + }); + + const { isValid: isValidWithTooLongLabel } = await formState!.submit(); + + expect(isValidWithTooLongLabel).toEqual(false); + }); + }); + + describe('when initial value is missing', () => { + it('should pass generated key to onChange handler', async () => { + let formState: FormState; + + const onChangeState = (state: FormState) => (formState = state); + + appMock.render(); + + await waitFor(() => { + expect(formState).not.toBeUndefined(); + }); + + const labelInput = await screen.findByTestId('observable-type-label-input'); + + expect(labelInput).toBeInTheDocument(); + + fireEvent.change(labelInput, { + target: { value: 'changed label' }, + }); + + const { data, isValid } = await formState!.submit(); + + expect(isValid).toEqual(true); + expect(data.key).toEqual(expect.any(String)); + expect(data.label).toEqual('changed label'); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/observable_types/form.tsx b/x-pack/plugins/cases/public/components/observable_types/form.tsx new file mode 100644 index 0000000000000..ce51953a1cbeb --- /dev/null +++ b/x-pack/plugins/cases/public/components/observable_types/form.tsx @@ -0,0 +1,48 @@ +/* + * 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 { Form, useForm } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import React, { useEffect, useMemo } from 'react'; +import { v4 as uuidv4 } from 'uuid'; + +import { schema } from './schema'; +import { FormFields } from './form_fields'; +import type { ObservableTypeConfiguration } from '../../../common/types/domain'; +import type { FormState } from '../configure_cases/flyout'; + +export interface ObservableTypesFormProps { + onChange: (state: FormState) => void; + initialValue: ObservableTypeConfiguration | null; +} + +const FormComponent: React.FC = ({ onChange, initialValue }) => { + const defaultValue = useMemo(() => ({ key: uuidv4(), label: '' }), []); + + const { form } = useForm({ + defaultValue: initialValue || defaultValue, + options: { stripEmptyFields: false }, + schema, + }); + + const { submit, isValid, isSubmitting } = form; + + useEffect(() => { + if (onChange) { + onChange({ isValid, submit }); + } + }, [onChange, isValid, submit]); + + return ( +
+ + + ); +}; + +FormComponent.displayName = 'ObservableTypesForm '; + +export const ObservableTypesForm = React.memo(FormComponent); diff --git a/x-pack/plugins/cases/public/components/observable_types/form_fields.test.tsx b/x-pack/plugins/cases/public/components/observable_types/form_fields.test.tsx new file mode 100644 index 0000000000000..74dd03bb0959e --- /dev/null +++ b/x-pack/plugins/cases/public/components/observable_types/form_fields.test.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen } from '@testing-library/react'; + +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; +import { FormTestComponent } from '../../common/test_utils'; +import { FormFields } from './form_fields'; + +describe('FormFields ', () => { + let appMockRender: AppMockRenderer; + const onSubmit = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + }); + + it('renders correctly', async () => { + appMockRender.render( + + + + ); + + expect(await screen.findByTestId('observable-type-label-input')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/observable_types/form_fields.tsx b/x-pack/plugins/cases/public/components/observable_types/form_fields.tsx new file mode 100644 index 0000000000000..ea4b21dbd8acb --- /dev/null +++ b/x-pack/plugins/cases/public/components/observable_types/form_fields.tsx @@ -0,0 +1,39 @@ +/* + * 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 React, { memo, useMemo } from 'react'; +import { UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { TextField, HiddenField } from '@kbn/es-ui-shared-plugin/static/forms/components'; + +interface FormFieldsProps { + isSubmitting?: boolean; +} + +const FormFieldsComponent: React.FC = ({ isSubmitting }) => { + const labelFieldProps = useMemo( + () => ({ + euiFieldProps: { + 'data-test-subj': 'observable-type-label-input', + fullWidth: true, + autoFocus: true, + isLoading: isSubmitting, + }, + }), + [isSubmitting] + ); + + return ( + <> + + + + ); +}; + +FormFieldsComponent.displayName = 'FormFields'; + +export const FormFields = memo(FormFieldsComponent); diff --git a/x-pack/plugins/cases/public/components/observable_types/index.test.tsx b/x-pack/plugins/cases/public/components/observable_types/index.test.tsx new file mode 100644 index 0000000000000..6709e6f5f811d --- /dev/null +++ b/x-pack/plugins/cases/public/components/observable_types/index.test.tsx @@ -0,0 +1,79 @@ +/* + * 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 React from 'react'; +import { screen } from '@testing-library/react'; + +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer, noCasesPermissions } from '../../common/mock'; +import type { ObservableTypesProps } from '.'; +import { ObservableTypes } from '.'; +import { observableTypesMock } from '../../containers/mock'; +import * as i18n from './translations'; +import { MAX_CUSTOM_OBSERVABLE_TYPES } from '../../../common/constants'; + +describe('ObservableTypes', () => { + let appMock: AppMockRenderer; + + const props: ObservableTypesProps = { + disabled: false, + isLoading: false, + observableTypes: [], + handleAddObservableType: jest.fn(), + handleEditObservableType: jest.fn(), + handleDeleteObservableType: jest.fn(), + }; + + describe('with sufficient permissions', () => { + beforeEach(() => { + appMock = createAppMockRenderer(); + jest.clearAllMocks(); + }); + + it('renders correctly when there are no observable types', async () => { + appMock.render(); + expect(await screen.findByTestId('observable-types-form-group')).toBeInTheDocument(); + expect(screen.queryByTestId('observable-types-list')).not.toBeInTheDocument(); + }); + + it('renders correctly when there are observable types', async () => { + appMock.render(); + expect(await screen.findByTestId('observable-types-form-group')).toBeInTheDocument(); + expect(await screen.findByTestId('observable-types-list')).toBeInTheDocument(); + }); + + it('shows error when custom fields reaches the limit', async () => { + const generatedMockCustomFields = []; + + for (let i = 0; i < 11; i++) { + generatedMockCustomFields.push({ + key: `field_key_${i + 1}`, + label: `My custom label ${i + 1}`, + }); + } + + const observableTypes = [...generatedMockCustomFields]; + + appMock.render(); + + expect(await screen.findByText(i18n.MAX_OBSERVABLE_TYPES_LIMIT(MAX_CUSTOM_OBSERVABLE_TYPES))); + expect(screen.queryByTestId('add-observable-type')).not.toBeInTheDocument(); + }); + }); + + describe('with insufficient permissions', () => { + beforeEach(() => { + appMock = createAppMockRenderer({ permissions: noCasesPermissions() }); + jest.clearAllMocks(); + }); + + it('renders correctly when there are no observable types', async () => { + appMock.render(); + expect(screen.queryByTestId('observable-types-form-group')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/observable_types/index.tsx b/x-pack/plugins/cases/public/components/observable_types/index.tsx new file mode 100644 index 0000000000000..c1106ef692132 --- /dev/null +++ b/x-pack/plugins/cases/public/components/observable_types/index.tsx @@ -0,0 +1,120 @@ +/* + * 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 React, { useCallback } from 'react'; +import { + EuiButtonEmpty, + EuiPanel, + EuiDescribedFormGroup, + EuiSpacer, + EuiFlexGroup, + EuiFlexItem, + EuiText, +} from '@elastic/eui'; + +import { MAX_CUSTOM_OBSERVABLE_TYPES } from '../../../common/constants'; +import * as i18n from './translations'; +import { useCasesContext } from '../cases_context/use_cases_context'; +import type { ObservableTypesConfiguration } from '../../../common/types/domain'; +import { ObservableTypesList } from './observable_types_list'; + +export interface ObservableTypesProps { + observableTypes: ObservableTypesConfiguration; + disabled: boolean; + isLoading: boolean; + handleAddObservableType: () => void; + handleDeleteObservableType: (key: string) => void; + handleEditObservableType: (key: string) => void; +} +const ObservableTypesComponent: React.FC = ({ + disabled, + isLoading, + handleAddObservableType, + handleDeleteObservableType, + handleEditObservableType, + observableTypes, +}) => { + const { permissions } = useCasesContext(); + const canModifyObservableTypes = !disabled && permissions.settings; + + const onAddObservableType = useCallback(() => { + handleAddObservableType(); + }, [handleAddObservableType]); + + const onEditObservableType = useCallback( + (key: string) => { + handleEditObservableType(key); + }, + [handleEditObservableType] + ); + + if (!permissions.settings) { + return null; + } + + return ( + + {i18n.TITLE} + + } + description={

{i18n.DESCRIPTION}

} + data-test-subj="observable-types-form-group" + > + + {observableTypes.length ? ( + <> + + + ) : null} + + {!observableTypes.length ? ( + + + {i18n.NO_OBSERVABLE_TYPES} + + + + ) : null} + + + {observableTypes.length < MAX_CUSTOM_OBSERVABLE_TYPES ? ( + + {i18n.ADD_OBSERVABLE_TYPE} + + ) : ( + + + {i18n.MAX_OBSERVABLE_TYPES_LIMIT(MAX_CUSTOM_OBSERVABLE_TYPES)} + + + )} + + + + + +
+ ); +}; +ObservableTypesComponent.displayName = 'CustomFields'; + +export const ObservableTypes = React.memo(ObservableTypesComponent); diff --git a/x-pack/plugins/cases/public/components/observable_types/observable_types_list/index.test.tsx b/x-pack/plugins/cases/public/components/observable_types/observable_types_list/index.test.tsx new file mode 100644 index 0000000000000..db2d6adb3234c --- /dev/null +++ b/x-pack/plugins/cases/public/components/observable_types/observable_types_list/index.test.tsx @@ -0,0 +1,131 @@ +/* + * 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 React from 'react'; +import { screen, within, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import type { AppMockRenderer } from '../../../common/mock'; +import { createAppMockRenderer } from '../../../common/mock'; +import { ObservableTypesList, type ObservableTypesListProps } from '.'; + +const observableTypes = [ + { label: 'Test Observable Type', key: 'deb68304-da86-483c-b5ed-ff5b3420e340' }, + { label: 'Test Observable Type vol 2', key: '532433db-045f-4ccc-b73c-db9441f0eefa' }, +]; + +describe('ObservableTypesList', () => { + let appMockRender: AppMockRenderer; + + const props: ObservableTypesListProps = { + disabled: false, + observableTypes, + onDeleteObservableType: jest.fn(), + onEditObservableType: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + }); + + it('renders correctly', () => { + appMockRender.render(); + + expect(screen.getByTestId('observable-types-list')).toBeInTheDocument(); + }); + + it('shows ObservableTypesList correctly', async () => { + appMockRender.render(); + + expect(await screen.findByTestId('observable-types-list')).toBeInTheDocument(); + + expect( + await screen.findByTestId(`observable-type-${observableTypes[0].key}`) + ).toBeInTheDocument(); + expect(await screen.findByText('Test Observable Type')).toBeInTheDocument(); + expect( + await screen.findByTestId(`observable-type-${observableTypes[1].key}`) + ).toBeInTheDocument(); + }); + + describe('Delete', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('shows confirmation modal when deleting a field ', async () => { + appMockRender.render(); + + const list = await screen.findByTestId('observable-types-list'); + + await userEvent.click( + await within(list).findByTestId(`${observableTypes[0].key}-observable-type-delete`) + ); + + expect(await screen.findByTestId('confirm-delete-modal')).toBeInTheDocument(); + }); + + it('calls onDeleteObservableType when confirm', async () => { + appMockRender.render(); + + const list = await screen.findByTestId('observable-types-list'); + + await userEvent.click( + await within(list).findByTestId(`${observableTypes[0].key}-observable-type-delete`) + ); + + expect(await screen.findByTestId('confirm-delete-modal')).toBeInTheDocument(); + + await userEvent.click(await screen.findByText('Delete')); + + await waitFor(() => { + expect(screen.queryByTestId('confirm-delete-modal')).not.toBeInTheDocument(); + expect(props.onDeleteObservableType).toHaveBeenCalledWith(observableTypes[0].key); + }); + }); + + it('does not call onDeleteObservableType when cancel', async () => { + appMockRender.render(); + + const list = await screen.findByTestId('observable-types-list'); + + await userEvent.click( + await within(list).findByTestId(`${observableTypes[0].key}-observable-type-delete`) + ); + + expect(await screen.findByTestId('confirm-delete-modal')).toBeInTheDocument(); + + await userEvent.click(await screen.findByText('Cancel')); + + await waitFor(() => { + expect(screen.queryByTestId('confirm-delete-modal')).not.toBeInTheDocument(); + expect(props.onDeleteObservableType).not.toHaveBeenCalledWith(); + }); + }); + }); + + describe('Edit', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('calls onEditObservableType correctly', async () => { + appMockRender.render(); + + const list = await screen.findByTestId('observable-types-list'); + + await userEvent.click( + await within(list).findByTestId(`${observableTypes[0].key}-observable-type-edit`) + ); + + await waitFor(() => { + expect(props.onEditObservableType).toHaveBeenCalledWith(observableTypes[0].key); + }); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/observable_types/observable_types_list/index.tsx b/x-pack/plugins/cases/public/components/observable_types/observable_types_list/index.tsx new file mode 100644 index 0000000000000..bdb898c718e8b --- /dev/null +++ b/x-pack/plugins/cases/public/components/observable_types/observable_types_list/index.tsx @@ -0,0 +1,116 @@ +/* + * 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 React, { useCallback, useState } from 'react'; +import { + EuiPanel, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiText, + EuiButtonIcon, +} from '@elastic/eui'; +import * as i18n from '../translations'; + +import type { ObservableTypesConfiguration } from '../../../../common/types/domain'; +import { DeleteConfirmationModal } from '../../configure_cases/delete_confirmation_modal'; + +export interface ObservableTypesListProps { + disabled: boolean; + observableTypes: ObservableTypesConfiguration; + onDeleteObservableType: (key: string) => void; + onEditObservableType: (key: string) => void; +} + +const ObservableTypesListComponent: React.FC = (props) => { + const { observableTypes, onDeleteObservableType, onEditObservableType } = props; + const [selectedItem, setSelectedItem] = useState( + null + ); + + const onConfirm = useCallback(() => { + if (selectedItem) { + onDeleteObservableType(selectedItem.key); + } + + setSelectedItem(null); + }, [onDeleteObservableType, setSelectedItem, selectedItem]); + + const onCancel = useCallback(() => { + setSelectedItem(null); + }, []); + + const showModal = Boolean(selectedItem); + + return observableTypes.length ? ( + <> + + + + {observableTypes.map((observableType) => ( + + + + + + + +

{observableType.label}

+
+
+
+
+ + + + onEditObservableType(observableType.key)} + /> + + + setSelectedItem(observableType)} + /> + + + +
+
+ +
+ ))} +
+ {showModal && selectedItem ? ( + + ) : null} +
+ + ) : null; +}; + +ObservableTypesListComponent.displayName = 'ObservableTypesListComponent'; + +export const ObservableTypesList = React.memo(ObservableTypesListComponent); diff --git a/x-pack/plugins/cases/public/components/observable_types/schema.tsx b/x-pack/plugins/cases/public/components/observable_types/schema.tsx new file mode 100644 index 0000000000000..53a54cc5bddd9 --- /dev/null +++ b/x-pack/plugins/cases/public/components/observable_types/schema.tsx @@ -0,0 +1,39 @@ +/* + * 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 { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers'; +import * as i18n from './translations'; +import { MAX_CUSTOM_OBSERVABLE_TYPES_LABEL_LENGTH } from '../../../common/constants'; + +const { emptyField, maxLengthField } = fieldValidators; + +export const schema = { + key: { + validations: [ + { + validator: emptyField('key'), + }, + ], + }, + label: { + label: i18n.OBSERVABLE_TYPE_LABEL, + validations: [ + { + validator: emptyField(i18n.REQUIRED_FIELD(i18n.OBSERVABLE_TYPE_LABEL.toLocaleLowerCase())), + }, + { + validator: maxLengthField({ + length: MAX_CUSTOM_OBSERVABLE_TYPES_LABEL_LENGTH, + message: i18n.MAX_LENGTH_ERROR( + i18n.OBSERVABLE_TYPE_LABEL.toLocaleLowerCase(), + MAX_CUSTOM_OBSERVABLE_TYPES_LABEL_LENGTH + ), + }), + }, + ], + }, +}; diff --git a/x-pack/plugins/cases/public/components/observable_types/translations.ts b/x-pack/plugins/cases/public/components/observable_types/translations.ts new file mode 100644 index 0000000000000..61218e536ae90 --- /dev/null +++ b/x-pack/plugins/cases/public/components/observable_types/translations.ts @@ -0,0 +1,59 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +export * from '../../common/translations'; + +export const TITLE = i18n.translate('xpack.cases.observableTypes.title', { + defaultMessage: 'Observable types', +}); + +export const DESCRIPTION = i18n.translate('xpack.cases.observableTypes.description', { + defaultMessage: 'Add observable types for customized case collaboration.', +}); + +export const NO_OBSERVABLE_TYPES = i18n.translate('xpack.cases.observableTypes.noObservableTypes', { + defaultMessage: 'You do not have any observable types yet', +}); + +export const ADD_OBSERVABLE_TYPE = i18n.translate('xpack.cases.observableTypes.addObservableType', { + defaultMessage: 'Add observable type', +}); + +export const OBSERVABLE_TYPE_LABEL = i18n.translate('xpack.cases.observableTypes.fieldLabel', { + defaultMessage: 'Observable type label', +}); + +export const REQUIRED_FIELD = (fieldName: string): string => + i18n.translate('xpack.cases.observableTypes.requiredField', { + values: { fieldName }, + defaultMessage: '{fieldName} is required.', + }); + +export const DELETE_OBSERVABLE_TYPE_TITLE = (fieldName: string) => + i18n.translate('xpack.cases.observableTypes.deleteField', { + values: { fieldName }, + defaultMessage: 'Delete observable type "{fieldName}"?', + }); + +export const DELETE_OBSERVABLE_TYPE_DESCRIPTION = i18n.translate( + 'xpack.cases.observableTypes.deleteObservableTypeDescription', + { + defaultMessage: 'The observable type will be removed from all cases and data will be lost.', + } +); + +export const DELETE = i18n.translate('xpack.cases.observableTypes.options.Delete', { + defaultMessage: 'Delete', +}); + +export const MAX_OBSERVABLE_TYPES_LIMIT = (maxObservableTypesLimit: number) => + i18n.translate('xpack.cases.observableTypes.maxObservableTypesLimit', { + values: { maxObservableTypesLimit }, + defaultMessage: 'Maximum number of {maxObservableTypesLimit} observable types reached.', + }); diff --git a/x-pack/plugins/cases/public/components/observables/add_observable.test.tsx b/x-pack/plugins/cases/public/components/observables/add_observable.test.tsx new file mode 100644 index 0000000000000..7e88b78db49d3 --- /dev/null +++ b/x-pack/plugins/cases/public/components/observables/add_observable.test.tsx @@ -0,0 +1,102 @@ +/* + * 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 React from 'react'; +import { createAppMockRenderer, noCasesPermissions } from '../../common/mock'; +import type { AddObservableProps } from './add_observable'; +import { AddObservable } from './add_observable'; +import { mockCase } from '../../containers/mock'; +import { licensingMock } from '@kbn/licensing-plugin/public/mocks'; +import userEvent from '@testing-library/user-event'; +import { screen } from '@testing-library/react'; +import { OBSERVABLE_TYPE_IPV4 } from '../../../common/constants'; +import { postObservable } from '../../containers/api'; + +jest.mock('../../containers/api'); + +const platinumLicense = licensingMock.createLicense({ + license: { type: 'platinum' }, +}); + +const basicLicense = licensingMock.createLicense({ + license: { type: 'basic' }, +}); + +describe('AddObservable', () => { + const props: AddObservableProps = { + caseData: mockCase, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the button as enabled when subscribed to platinum', async () => { + const appMock = createAppMockRenderer({ license: platinumLicense }); + const result = appMock.render(); + + const addButton = result.getByTestId('cases-observables-add'); + + expect(addButton).toBeInTheDocument(); + expect(addButton).toBeEnabled(); + }); + + it('opens the modal when clicked', async () => { + const appMock = createAppMockRenderer({ license: platinumLicense }); + const result = appMock.render(); + + const addButton = result.getByTestId('cases-observables-add'); + + expect(addButton).toBeInTheDocument(); + expect(addButton).toBeEnabled(); + + await userEvent.click(addButton); + + expect(await screen.findByTestId('cases-observables-add-modal')).toBeInTheDocument(); + }); + + it('submits the data on save', async () => { + const appMock = createAppMockRenderer({ license: platinumLicense }); + const result = appMock.render(); + + await userEvent.click(result.getByTestId('cases-observables-add')); + + await userEvent.selectOptions( + result.getByTestId('observable-type-select'), + OBSERVABLE_TYPE_IPV4.key + ); + + await userEvent.click(screen.getByTestId('observable-value-field')); + await userEvent.paste('127.0.0.1'); + + await userEvent.click(result.getByTestId('save-observable')); + + expect(screen.queryByTestId('cases-observables-add-modal')).not.toBeInTheDocument(); + + expect(jest.mocked(postObservable)).toHaveBeenCalledWith( + { observable: { description: '', typeKey: 'observable-type-ipv4', value: '127.0.0.1' } }, + 'mock-id' + ); + }); + + it('renders the button as disabled when license is too low', async () => { + const appMock = createAppMockRenderer({ license: basicLicense }); + const result = appMock.render(); + + const addButton = result.getByTestId('cases-observables-add'); + + expect(addButton).toBeInTheDocument(); + expect(addButton).toBeDisabled(); + }); + + it('does not render the button with insufficient permissions', async () => { + const appMock = createAppMockRenderer({ permissions: noCasesPermissions() }); + const result = appMock.render(); + + expect(result.queryByTestId('cases-observables-add')).not.toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/observables/add_observable.tsx b/x-pack/plugins/cases/public/components/observables/add_observable.tsx new file mode 100644 index 0000000000000..d8241b46e18f8 --- /dev/null +++ b/x-pack/plugins/cases/public/components/observables/add_observable.tsx @@ -0,0 +1,81 @@ +/* + * 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 { + EuiButton, + EuiFlexItem, + EuiModal, + EuiModalBody, + EuiModalHeader, + EuiModalHeaderTitle, +} from '@elastic/eui'; +import React, { useState, useCallback } from 'react'; + +import type { ObservablePost } from '../../../common/types/api/observable/v1'; +import type { CaseUI } from '../../../common'; +import { useCasesContext } from '../cases_context/use_cases_context'; +import * as i18n from './translations'; +import { usePostObservable } from '../../containers/use_post_observables'; +import { ObservableForm, type ObservableFormProps } from './observable_form'; +import { useCasesFeatures } from '../../common/use_cases_features'; + +export interface AddObservableProps { + caseData: CaseUI; +} + +const AddObservableComponent: React.FC = ({ caseData }) => { + const { permissions } = useCasesContext(); + const [isModalVisible, setIsModalVisible] = useState(false); + const { isLoading, mutateAsync: postObservables } = usePostObservable(caseData.id); + const { observablesAuthorized: isObservablesEnabled } = useCasesFeatures(); + + const closeModal = () => setIsModalVisible(false); + const showModal = () => setIsModalVisible(true); + + const handleCreateObservable = useCallback( + async (observable: ObservablePost) => { + await postObservables({ + observable, + }); + + closeModal(); + }, + [postObservables] + ); + + return permissions.create && permissions.update ? ( + + + {i18n.ADD_OBSERVABLE} + + {isModalVisible && ( + + + {i18n.ADD_OBSERVABLE} + + + + + + )} + + ) : null; +}; + +AddObservableComponent.displayName = 'AddObservable'; + +export const AddObservable = React.memo(AddObservableComponent); diff --git a/x-pack/plugins/cases/public/components/observables/builder.tsx b/x-pack/plugins/cases/public/components/observables/builder.tsx new file mode 100644 index 0000000000000..9c715f03d854f --- /dev/null +++ b/x-pack/plugins/cases/public/components/observables/builder.tsx @@ -0,0 +1,57 @@ +/* + * 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. + */ + +/* eslint-disable react/display-name */ + +import React, { type ComponentType } from 'react'; +import { UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { TextField } from '@kbn/es-ui-shared-plugin/static/forms/components'; +import { + OBSERVABLE_TYPE_DOMAIN, + OBSERVABLE_TYPE_EMAIL, + OBSERVABLE_TYPE_IPV4, + OBSERVABLE_TYPE_IPV6, + OBSERVABLE_TYPE_URL, +} from '../../../common/constants'; +import { fieldsConfig } from './fields_config'; +import * as i18n from './translations'; + +const sharedProps = { + path: 'value', + componentProps: { + placeholder: i18n.VALUE_PLACEHOLDER, + euiFieldProps: { + 'data-test-subj': 'observable-value-field', + }, + }, + component: TextField, +} as const; + +const cachedComponents = Object.freeze({ + generic: () => , + [OBSERVABLE_TYPE_EMAIL.key]: () => ( + + ), + [OBSERVABLE_TYPE_URL.key]: () => ( + + ), + [OBSERVABLE_TYPE_IPV4.key]: () => ( + + ), + [OBSERVABLE_TYPE_IPV6.key]: () => ( + + ), + [OBSERVABLE_TYPE_DOMAIN.key]: () => ( + + ), +} as const) as Record; + +/* + * Returns value component with validation config matching the type (or generic value component if the specialized field is not found). + */ +export const getDynamicValueField = (observableType: string) => + cachedComponents[observableType] ?? cachedComponents.generic; diff --git a/x-pack/plugins/cases/public/components/observables/edit_observable_modal.test.tsx b/x-pack/plugins/cases/public/components/observables/edit_observable_modal.test.tsx new file mode 100644 index 0000000000000..68dc7e8a74b5d --- /dev/null +++ b/x-pack/plugins/cases/public/components/observables/edit_observable_modal.test.tsx @@ -0,0 +1,62 @@ +/* + * 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 userEvent from '@testing-library/user-event'; +import React from 'react'; +import type { AppMockRenderer } from '../../common/mock'; +import { createAppMockRenderer } from '../../common/mock'; +import { EditObservableModal, type EditObservableModalProps } from './edit_observable_modal'; +import { mockCase } from '../../containers/mock'; +import { patchObservable } from '../../containers/api'; + +jest.mock('../../containers/api'); + +describe('EditObservableModal', () => { + let appMock: AppMockRenderer; + const props: EditObservableModalProps = { + onCloseModal: jest.fn(), + caseData: mockCase, + observable: { + value: 'test', + typeKey: '67ac7899-2cc0-4ce5-80d3-0f4a2d2af33e', + id: '84279197-3746-47fb-ba4d-c7946a7feb88', + createdAt: '2024-10-01', + updatedAt: '2024-10-01', + description: '', + }, + }; + + beforeEach(() => { + appMock = createAppMockRenderer(); + jest.clearAllMocks(); + }); + + it('renders correctly', async () => { + const result = appMock.render(); + + expect(result.getByTestId('case-observables-edit-modal')).toBeInTheDocument(); + expect(result.getByText('Save observable')).toBeInTheDocument(); + }); + + it('calls handleUpdateObservable', async () => { + const result = appMock.render(); + + expect(result.getByText('Save observable')).toBeInTheDocument(); + await userEvent.click(result.getByText('Save observable')); + + expect(patchObservable).toHaveBeenCalled(); + }); + + it('calls onCancel', async () => { + const result = appMock.render(); + + expect(result.getByText('Cancel')).toBeInTheDocument(); + await userEvent.click(result.getByText('Cancel')); + + expect(props.onCloseModal).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/observables/edit_observable_modal.tsx b/x-pack/plugins/cases/public/components/observables/edit_observable_modal.tsx new file mode 100644 index 0000000000000..e76fe417eae00 --- /dev/null +++ b/x-pack/plugins/cases/public/components/observables/edit_observable_modal.tsx @@ -0,0 +1,56 @@ +/* + * 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 { EuiModal, EuiModalHeader, EuiModalHeaderTitle, EuiModalBody } from '@elastic/eui'; +import React, { type FC } from 'react'; +import type { ObservablePatch } from '../../../common/types/api/observable/v1'; +import type { Observable } from '../../../common/types/domain/observable/v1'; +import { ObservableForm } from './observable_form'; +import * as i18n from './translations'; +import { usePatchObservable } from '../../containers/use_patch_observables'; +import { type CaseUI } from '../../containers/types'; + +export interface EditObservableModalProps { + onCloseModal: VoidFunction; + observable: Observable; + caseData: CaseUI; +} + +export const EditObservableModal: FC = ({ + onCloseModal: closeModal, + observable, + caseData, +}) => { + const { isLoading, mutateAsync: patchObservable } = usePatchObservable( + caseData.id, + observable.id + ); + const handleUpdateObservable = async (updatedObservable: ObservablePatch) => { + patchObservable({ + observable: updatedObservable, + }); + closeModal(); + }; + + return ( + + + {i18n.EDIT_OBSERVABLE} + + + + + + ); +}; + +EditObservableModal.displayName = 'EditObservableModal'; diff --git a/x-pack/plugins/cases/public/components/observables/fields_config.test.ts b/x-pack/plugins/cases/public/components/observables/fields_config.test.ts new file mode 100644 index 0000000000000..348b5b1ca27b5 --- /dev/null +++ b/x-pack/plugins/cases/public/components/observables/fields_config.test.ts @@ -0,0 +1,146 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ValidationFunc } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib/types'; +import { domainValidator, emailValidator, genericValidator, ipv4Validator } from './fields_config'; + +describe('emailValidator', () => { + it('should return an error if the value is not a string', () => { + const result = emailValidator({ + value: undefined, + path: 'email', + } as Parameters[0]); + + expect(result).toEqual({ + code: 'ERR_NOT_STRING', + message: 'Value should be a string', + path: 'email', + }); + }); + + it('should return an error if the value is not a valid email', () => { + const result = emailValidator({ + value: 'invalid-email', + path: 'email', + } as Parameters[0]); + expect(result).toEqual({ + code: 'ERR_NOT_EMAIL', + message: 'Value should be a valid email', + path: 'email', + }); + }); + + it('should return undefined if the value is a valid email', () => { + const result = emailValidator({ + value: 'test@example.com', + path: 'email', + } as Parameters[0]); + expect(result).toBeUndefined(); + }); +}); + +describe('genericValidator', () => { + it('should return an error if the value is not a string', () => { + const result = genericValidator({ + value: 123, + path: 'generic', + } as Parameters[0]); + expect(result).toEqual({ + code: 'ERR_NOT_STRING', + message: 'Value should be a string', + path: 'generic', + }); + }); + + it('should return an error if the value is not valid', () => { + const result = genericValidator({ + value: 'invalid value!', + path: 'generic', + } as Parameters[0]); + expect(result).toEqual({ + code: 'ERR_NOT_VALID', + message: 'Value is invalid', + path: 'generic', + }); + }); + + it('should return undefined if the value is valid', () => { + const result = genericValidator({ + value: 'valid_value', + path: 'generic', + } as Parameters[0]); + expect(result).toBeUndefined(); + }); +}); + +describe('domainValidator', () => { + it('should return undefined for a valid domain', () => { + const result = domainValidator({ + value: 'example.com', + path: 'domain', + } as Parameters[0]); + expect(result).toBeUndefined(); + }); + + it('should return an error for an invalid domain', () => { + const result = domainValidator({ + value: '-invalid.com', + path: 'domain', + } as Parameters[0]); + expect(result).toEqual({ + code: 'ERR_NOT_VALID', + message: 'Value is invalid', + path: 'domain', + }); + }); + + it('should return an error for hyphen-spaced strings', () => { + const result = domainValidator({ + value: 'test-test', + path: 'domain', + } as Parameters[0]); + expect(result).toEqual({ + code: 'ERR_NOT_VALID', + message: 'Value is invalid', + path: 'domain', + }); + }); + + it('should return an error for a non-string value', () => { + const result = domainValidator({ + value: 12345, + path: 'domain', + } as Parameters[0]); + expect(result).toEqual({ + code: 'ERR_NOT_STRING', + message: 'Value should be a string', + path: 'domain', + }); + }); +}); + +describe('ipv4Validator', () => { + it('should return undefined for a valid ipv4', () => { + const result = ipv4Validator({ + value: '127.0.0.1', + path: 'ipv4', + } as Parameters[0]); + expect(result).toBeUndefined(); + }); + + it('should return an error for invalid ipv4', () => { + const result = domainValidator({ + value: 'invalid ip', + path: 'ipv4', + } as Parameters[0]); + expect(result).toEqual({ + code: 'ERR_NOT_VALID', + message: 'Value is invalid', + path: 'ipv4', + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/observables/fields_config.ts b/x-pack/plugins/cases/public/components/observables/fields_config.ts new file mode 100644 index 0000000000000..b858bb1251bb2 --- /dev/null +++ b/x-pack/plugins/cases/public/components/observables/fields_config.ts @@ -0,0 +1,209 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { type ValidationFunc } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { parseAddressList } from 'email-addresses'; +import ipaddr from 'ipaddr.js'; +import { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers'; + +import { + OBSERVABLE_TYPE_DOMAIN, + OBSERVABLE_TYPE_EMAIL, + OBSERVABLE_TYPE_IPV4, + OBSERVABLE_TYPE_IPV6, + OBSERVABLE_TYPE_URL, +} from '../../../common/constants'; +import * as i18n from './translations'; + +export const normalizeValueType = (value: string): keyof typeof fieldsConfig.value | 'generic' => { + if (value in fieldsConfig.value) { + return value as keyof typeof fieldsConfig.value; + } + + return 'generic'; +}; + +const DOMAIN_REGEX = /^(?!-)[A-Za-z0-9-]{1,63}(? ({ + code: 'ERR_NOT_STRING', + message: 'Value should be a string', + path, +}); + +const { emptyField } = fieldValidators; + +const validatorFactory = + ( + regex: RegExp, + message: string = i18n.INVALID_VALUE, + code: string = 'ERR_NOT_VALID' + ): ValidationFunc => + (...args: Parameters) => { + const [{ value, path }] = args; + + if (typeof value !== 'string') { + return notStringError(path); + } + + if (!regex.test(value)) { + return { + code, + message, + path, + }; + } + }; + +export const genericValidator = validatorFactory(GENERIC_REGEX); +export const domainValidator = validatorFactory(DOMAIN_REGEX); + +const ipValidatorFactory = + (kind: 'ipv6' | 'ipv4') => + (...args: Parameters) => { + const [{ value, path }] = args; + + if (typeof value !== 'string') { + return notStringError(path); + } + + try { + const parsed = ipaddr.parse(value); + + if (parsed.kind() !== kind) { + return { + code: 'ERR_NOT_VALID', + message: i18n.INVALID_VALUE, + path, + }; + } + } catch (error) { + return { + code: 'ERR_NOT_VALID', + message: i18n.INVALID_VALUE, + path, + }; + } + }; + +export const ipv6Validator = ipValidatorFactory('ipv6'); +export const ipv4Validator = ipValidatorFactory('ipv4'); + +export const urlValidator = (...args: Parameters) => { + const [{ value, path }] = args; + + if (typeof value !== 'string') { + return notStringError(path); + } + + try { + new URL(value); + } catch (error) { + return { + code: 'ERR_NOT_VALID', + message: i18n.INVALID_VALUE, + path, + }; + } +}; + +export const emailValidator = (...args: Parameters) => { + const [{ value, path }] = args; + + if (typeof value !== 'string') { + return notStringError(path); + } + + const emailAddresses = parseAddressList(value); + + if (emailAddresses == null) { + return { message: i18n.INVALID_EMAIL, code: 'ERR_NOT_EMAIL', path }; + } +}; + +export const fieldsConfig = { + value: { + generic: { + validations: [ + { + validator: emptyField(i18n.REQUIRED_VALUE), + }, + { + validator: genericValidator, + }, + ], + label: i18n.FIELD_LABEL_VALUE, + }, + [OBSERVABLE_TYPE_EMAIL.key]: { + validations: [ + { + validator: emptyField(i18n.REQUIRED_VALUE), + }, + { + validator: emailValidator, + }, + ], + label: 'Email', + }, + [OBSERVABLE_TYPE_DOMAIN.key]: { + validations: [ + { + validator: emptyField(i18n.REQUIRED_VALUE), + }, + { + validator: domainValidator, + }, + ], + label: 'Domain', + }, + [OBSERVABLE_TYPE_IPV4.key]: { + validations: [ + { + validator: emptyField(i18n.REQUIRED_VALUE), + }, + { + validator: ipv4Validator, + }, + ], + label: 'IPv4', + }, + [OBSERVABLE_TYPE_IPV6.key]: { + validations: [ + { + validator: emptyField(i18n.REQUIRED_VALUE), + }, + { + validator: ipv6Validator, + }, + ], + label: 'IPv6', + }, + [OBSERVABLE_TYPE_URL.key]: { + validations: [ + { + validator: emptyField(i18n.REQUIRED_VALUE), + }, + { + validator: urlValidator, + }, + ], + label: 'URL', + }, + }, + typeKey: { + validations: [ + { + validator: emptyField(i18n.REQUIRED_VALUE), + }, + ], + label: i18n.FIELD_LABEL_TYPE, + }, + description: { + label: i18n.FIELD_LABEL_DESCRIPTION, + }, +}; diff --git a/x-pack/plugins/cases/public/components/observables/observable_actions_popover_button.test.tsx b/x-pack/plugins/cases/public/components/observables/observable_actions_popover_button.test.tsx new file mode 100644 index 0000000000000..8a3befb11e2f3 --- /dev/null +++ b/x-pack/plugins/cases/public/components/observables/observable_actions_popover_button.test.tsx @@ -0,0 +1,148 @@ +/* + * 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 React from 'react'; + +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import type { AppMockRenderer } from '../../common/mock'; +import { buildCasesPermissions, createAppMockRenderer } from '../../common/mock'; + +import { ObservableActionsPopoverButton } from './observable_actions_popover_button'; +import type { CaseUI } from '../../../common'; +import type { Observable } from '../../../common/types/domain/observable/v1'; +import { mockCase } from '../../containers/mock'; +import { usePostObservable } from '../../containers/use_post_observables'; +import { useDeleteObservable } from '../../containers/use_delete_observables'; + +jest.mock('../../containers/use_post_observables'); +jest.mock('../../containers/use_delete_observables'); + +describe('ObservableActionsPopoverButton', () => { + let appMockRender: AppMockRenderer; + const addObservable = jest.fn().mockResolvedValue({}); + const deleteObservable = jest.fn().mockResolvedValue({}); + + const caseData: CaseUI = { ...mockCase }; + const observable = { id: '05041f40-ac9f-4192-b367-7e6a5dafcee5' } as Observable; + + beforeEach(() => { + jest + .mocked(usePostObservable) + .mockReturnValue({ mutateAsync: addObservable, isLoading: false } as unknown as ReturnType< + typeof usePostObservable + >); + jest + .mocked(useDeleteObservable) + .mockReturnValue({ mutateAsync: deleteObservable, isLoading: false } as unknown as ReturnType< + typeof useDeleteObservable + >); + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + }); + + it('renders observable actions popover button correctly', async () => { + appMockRender.render( + + ); + + expect( + await screen.findByTestId(`cases-observables-actions-popover-button-${observable.id}`) + ).toBeInTheDocument(); + }); + + it('clicking the button opens the popover', async () => { + appMockRender.render( + + ); + + await userEvent.click( + await screen.findByTestId(`cases-observables-actions-popover-button-${observable.id}`) + ); + + expect( + await screen.findByTestId(`cases-observables-popover-${observable.id}`) + ).toBeInTheDocument(); + expect(await screen.findByTestId('cases-observables-delete-button')).toBeInTheDocument(); + expect(await screen.findByTestId('cases-observables-edit-button')).toBeInTheDocument(); + }); + + describe('edit buttton', () => { + it('clicking edit button opens the edit modal', async () => { + appMockRender.render( + + ); + + await userEvent.click( + await screen.findByTestId(`cases-observables-actions-popover-button-${observable.id}`) + ); + + await userEvent.click(await screen.findByTestId('cases-observables-edit-button'), { + pointerEventsCheck: 0, + }); + + expect(await screen.findByTestId('case-observables-edit-modal')).toBeInTheDocument(); + }); + }); + + describe('delete button', () => { + it('clicking delete button opens the confirmation modal', async () => { + appMockRender.render( + + ); + + await userEvent.click( + await screen.findByTestId(`cases-observables-actions-popover-button-${observable.id}`) + ); + + await userEvent.click(await screen.findByTestId('cases-observables-delete-button'), { + pointerEventsCheck: 0, + }); + + expect(await screen.findByTestId('property-actions-confirm-modal')).toBeInTheDocument(); + }); + + it('clicking delete button in the confirmation modal calls deleteObservable with proper params', async () => { + appMockRender.render( + + ); + + await userEvent.click( + await screen.findByTestId(`cases-observables-actions-popover-button-${observable.id}`) + ); + + await userEvent.click(await screen.findByTestId('cases-observables-delete-button'), { + pointerEventsCheck: 0, + }); + + expect(await screen.findByTestId('property-actions-confirm-modal')).toBeInTheDocument(); + + await userEvent.click(await screen.findByTestId('confirmModalConfirmButton')); + + await waitFor(() => { + expect(deleteObservable).toHaveBeenCalledTimes(1); + }); + }); + + it('delete button is not rendered if user has no update permission', async () => { + appMockRender = createAppMockRenderer({ + permissions: buildCasesPermissions({ update: false }), + }); + + appMockRender.render( + + ); + + await userEvent.click( + await screen.findByTestId(`cases-observables-actions-popover-button-${observable.id}`) + ); + + expect(screen.queryByTestId('cases-observables-delete-button')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/components/observables/observable_actions_popover_button.tsx b/x-pack/plugins/cases/public/components/observables/observable_actions_popover_button.tsx new file mode 100644 index 0000000000000..1717abcaae469 --- /dev/null +++ b/x-pack/plugins/cases/public/components/observables/observable_actions_popover_button.tsx @@ -0,0 +1,137 @@ +/* + * 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 React, { useCallback, useMemo, useState } from 'react'; +import type { + EuiContextMenuPanelDescriptor, + EuiContextMenuPanelItemDescriptor, +} from '@elastic/eui'; +import { EuiButtonIcon, EuiPopover, EuiContextMenu, EuiIcon, EuiTextColor } from '@elastic/eui'; +import type { Observable } from '../../../common/types/domain/observable/v1'; +import * as i18n from './translations'; + +import { useCasesContext } from '../cases_context/use_cases_context'; +import { DeleteAttachmentConfirmationModal } from '../user_actions/delete_attachment_confirmation_modal'; +import { useDeletePropertyAction } from '../user_actions/property_actions/use_delete_property_action'; +import { type CaseUI } from '../../containers/types'; +import { EditObservableModal } from './edit_observable_modal'; +import { useDeleteObservable } from '../../containers/use_delete_observables'; + +export const ObservableActionsPopoverButton: React.FC<{ + caseData: CaseUI; + observable: Observable; +}> = ({ caseData, observable }) => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const { permissions } = useCasesContext(); + const [showEditModal, setShowEditModal] = useState(false); + + const { isLoading: isDeleteLoading, mutateAsync: deleteObservable } = useDeleteObservable( + caseData.id, + observable.id + ); + + const isLoading = isDeleteLoading; + + const { + showDeletionModal, + onModalOpen: onDeletionModalOpen, + onConfirm, + onCancel, + } = useDeletePropertyAction({ + onDelete: () => { + deleteObservable(); + }, + }); + + const tooglePopover = useCallback(() => setIsPopoverOpen((prevValue) => !prevValue), []); + const closePopover = useCallback(() => setIsPopoverOpen(false), []); + + const panels = useMemo((): EuiContextMenuPanelDescriptor[] => { + const mainPanelItems: EuiContextMenuPanelItemDescriptor[] = []; + + const panelsToBuild = [ + { + id: 0, + title: i18n.OBSERVABLE_ACTIONS, + items: mainPanelItems, + }, + ]; + + if (permissions.update) { + mainPanelItems.push({ + name: {i18n.DELETE_OBSERVABLE}, + icon: , + onClick: () => { + closePopover(); + onDeletionModalOpen(); + }, + disabled: isLoading, + 'data-test-subj': 'cases-observables-delete-button', + }); + + mainPanelItems.push({ + name: {i18n.EDIT_OBSERVABLE}, + icon: , + onClick: () => { + setShowEditModal(true); + closePopover(); + }, + disabled: isLoading, + 'data-test-subj': 'cases-observables-edit-button', + }); + } + + return panelsToBuild; + }, [closePopover, isLoading, onDeletionModalOpen, permissions]); + + return ( + <> + + } + isOpen={isPopoverOpen} + closePopover={closePopover} + panelPaddingSize="none" + anchorPosition="downLeft" + > + + + {showDeletionModal && ( + + )} + {showEditModal && ( + setShowEditModal(false)} + /> + )} + + ); +}; + +ObservableActionsPopoverButton.displayName = 'FileActionsPopoverButton'; diff --git a/x-pack/plugins/cases/public/components/observables/observable_form.test.tsx b/x-pack/plugins/cases/public/components/observables/observable_form.test.tsx new file mode 100644 index 0000000000000..c2bd6b096f47e --- /dev/null +++ b/x-pack/plugins/cases/public/components/observables/observable_form.test.tsx @@ -0,0 +1,30 @@ +/* + * 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 React from 'react'; +import { type AppMockRenderer, createAppMockRenderer } from '../../common/mock'; +import { ObservableForm, type ObservableFormProps } from './observable_form'; + +describe('ObservableForm', () => { + let appMock: AppMockRenderer; + const props: ObservableFormProps = { + isLoading: false, + onSubmit: jest.fn(), + onCancel: jest.fn(), + }; + + beforeEach(() => { + appMock = createAppMockRenderer(); + jest.clearAllMocks(); + }); + + it('renders correctly', async () => { + const result = appMock.render(); + + expect(result.getByTestId('save-observable')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/observables/observable_form.tsx b/x-pack/plugins/cases/public/components/observables/observable_form.tsx new file mode 100644 index 0000000000000..5e2cb9656b14e --- /dev/null +++ b/x-pack/plugins/cases/public/components/observables/observable_form.tsx @@ -0,0 +1,143 @@ +/* + * 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 React, { type FC, useCallback, useMemo, memo, useState } from 'react'; +import { + useForm, + Form, + UseField, + useFormContext, +} from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { EuiButton, EuiFlexGroup, EuiSpacer } from '@elastic/eui'; + +import { TextAreaField, SelectField } from '@kbn/es-ui-shared-plugin/static/forms/components'; + +import { OBSERVABLE_TYPES_BUILTIN } from '../../../common/constants'; +import type { ObservablePatch, ObservablePost } from '../../../common/types/api'; +import type { Observable } from '../../../common/types/domain'; +import { useGetCaseConfiguration } from '../../containers/configure/use_get_case_configuration'; +import * as i18n from './translations'; +import { fieldsConfig, normalizeValueType } from './fields_config'; +import { getDynamicValueField } from './builder'; + +export interface ObservableFormFieldsProps { + observable?: Observable; +} + +export const ObservableFormFields = memo(({ observable }: ObservableFormFieldsProps) => { + const { data, isLoading } = useGetCaseConfiguration(); + const [selectedTypeKey, setSelectedTypeKey] = useState(observable?.typeKey ?? ''); + + const { validateFields } = useFormContext(); + + const options = useMemo(() => { + return [...OBSERVABLE_TYPES_BUILTIN, ...data.observableTypes].map((observableType) => ({ + value: observableType.key, + text: observableType.label, + })); + }, [data.observableTypes]); + + const handleSelectedTypeChange = useCallback( + (selectedTypeKeyValue: string) => { + validateFields(['value']); + setSelectedTypeKey(selectedTypeKeyValue); + }, + [validateFields] + ); + + // NOTE: dynamic, because of field config changes, depending on the selectedTypeKey + const ValueComponent = useMemo( + () => getDynamicValueField(normalizeValueType(selectedTypeKey)), + [selectedTypeKey] + ); + + return ( + <> + {!observable && ( + + )} + + + + ); +}); +ObservableFormFields.displayName = 'ObservableFormFields'; + +export interface ObservableFormProps { + isLoading: boolean; + onSubmit: (observable: ObservablePatch | ObservablePost) => Promise; + observable?: Observable; + onCancel: VoidFunction; +} + +export const ObservableForm: FC = ({ + isLoading, + onSubmit, + observable, + onCancel, +}) => { + const { form } = useForm({ + defaultValue: observable ?? { + typeKey: '', + value: '', + description: '', + }, + options: { stripEmptyFields: false }, + }); + + const handleSubmitClick = useCallback( + async (e: React.MouseEvent) => { + const { isValid, data } = await form.submit(e); + + if (isValid) { + return onSubmit({ + ...data, + }); + } + }, + [form, onSubmit] + ); + + return ( +
+ + + + + {i18n.CANCEL} + + + {observable ? i18n.SAVE_OBSERVABLE : i18n.ADD_OBSERVABLE} + + + + ); +}; + +ObservableForm.displayName = 'ObservableForm'; diff --git a/x-pack/plugins/cases/public/components/observables/observables_table.test.tsx b/x-pack/plugins/cases/public/components/observables/observables_table.test.tsx new file mode 100644 index 0000000000000..bdbefc49240f8 --- /dev/null +++ b/x-pack/plugins/cases/public/components/observables/observables_table.test.tsx @@ -0,0 +1,43 @@ +/* + * 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 React from 'react'; +import { type AppMockRenderer, createAppMockRenderer } from '../../common/mock'; +import { mockCase, mockObservables } from '../../containers/mock'; +import { ObservablesTable, type ObservablesTableProps } from './observables_table'; + +describe('ObservablesTable', () => { + let appMock: AppMockRenderer; + const props: ObservablesTableProps = { + caseData: { + ...mockCase, + observables: mockObservables, + }, + isLoading: false, + }; + + beforeEach(() => { + appMock = createAppMockRenderer(); + jest.clearAllMocks(); + }); + + it('renders correctly', async () => { + const result = appMock.render(); + + expect(result.getByTestId('cases-observables-table')).toBeInTheDocument(); + + expect(result.getByText('Showing 2 observables')).toBeInTheDocument(); + expect(result.getByText('Observable type')).toBeInTheDocument(); + expect(result.getByText('Observable value')).toBeInTheDocument(); + }); + + it('renders loading indicator when loading', async () => { + const result = appMock.render(); + expect(result.queryByTestId('cases-observables-table')).not.toBeInTheDocument(); + expect(result.getByTestId('cases-observables-table-loading')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/observables/observables_table.tsx b/x-pack/plugins/cases/public/components/observables/observables_table.tsx new file mode 100644 index 0000000000000..423fd31a07ea6 --- /dev/null +++ b/x-pack/plugins/cases/public/components/observables/observables_table.tsx @@ -0,0 +1,120 @@ +/* + * 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 React, { useCallback, useMemo } from 'react'; + +import type { EuiBasicTableColumn } from '@elastic/eui'; + +import { EuiBasicTable, EuiSkeletonText, EuiSpacer, EuiText, EuiEmptyPrompt } from '@elastic/eui'; + +import { OBSERVABLE_TYPES_BUILTIN } from '../../../common/constants'; +import type { Observable, ObservableType } from '../../../common/types/domain'; +import type { CaseUI } from '../../../common/ui'; +import * as i18n from './translations'; +import { AddObservable } from './add_observable'; +import { ObservableActionsPopoverButton } from './observable_actions_popover_button'; +import { useGetCaseConfiguration } from '../../containers/configure/use_get_case_configuration'; + +const getColumns = ( + caseData: CaseUI, + observableTypes: ObservableType[] +): Array> => [ + { + name: i18n.DATE_ADDED, + field: 'createdAt', + 'data-test-subj': 'cases-observables-table-date-added', + dataType: 'date', + }, + { + name: i18n.OBSERVABLE_TYPE, + field: 'typeKey', + 'data-test-subj': 'cases-observables-table-type', + render: (typeKey: string) => + observableTypes.find((observableType) => observableType.key === typeKey)?.label || '-', + }, + { + name: i18n.OBSERVABLE_VALUE, + field: 'value', + 'data-test-subj': 'cases-observables-table-value', + }, + { + name: i18n.OBSERVABLE_ACTIONS, + field: 'actions', + 'data-test-subj': 'cases-observables-table-actions', + width: '120px', + actions: [ + { + name: i18n.OBSERVABLE_ACTIONS, + render: (observable: Observable) => { + return ; + }, + }, + ], + }, +]; + +const EmptyObservablesTable = ({ caseData }: { caseData: CaseUI }) => ( + {i18n.NO_OBSERVABLES}} + data-test-subj="cases-observables-table-empty" + titleSize="xs" + actions={} + /> +); + +EmptyObservablesTable.displayName = 'EmptyObservablesTable'; + +export interface ObservablesTableProps { + caseData: CaseUI; + isLoading: boolean; +} + +export const ObservablesTable = ({ caseData, isLoading }: ObservablesTableProps) => { + const filesTableRowProps = useCallback( + (observable: Observable) => ({ + 'data-test-subj': `cases-observables-table-row-${observable.id}`, + }), + [] + ); + + const { data: currentConfiguration, isLoading: loadingCaseConfigure } = useGetCaseConfiguration(); + + const columns = useMemo( + () => + getColumns(caseData, [...OBSERVABLE_TYPES_BUILTIN, ...currentConfiguration.observableTypes]), + [caseData, currentConfiguration.observableTypes] + ); + + return isLoading || loadingCaseConfigure ? ( + <> + + + + ) : ( + <> + {caseData.observables.length > 0 && ( + <> + + + {i18n.SHOWING_OBSERVABLES(caseData.observables.length)} + + + )} + + } + rowProps={filesTableRowProps} + /> + + ); +}; + +ObservablesTable.displayName = 'ObservablesTable'; diff --git a/x-pack/plugins/cases/public/components/observables/observables_utility_bar.test.tsx b/x-pack/plugins/cases/public/components/observables/observables_utility_bar.test.tsx new file mode 100644 index 0000000000000..9c35939785a0d --- /dev/null +++ b/x-pack/plugins/cases/public/components/observables/observables_utility_bar.test.tsx @@ -0,0 +1,30 @@ +/* + * 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 React from 'react'; +import { type AppMockRenderer, createAppMockRenderer } from '../../common/mock'; +import type { AddObservableProps } from './add_observable'; +import { mockCase } from '../../containers/mock'; +import { ObservablesUtilityBar } from './observables_utility_bar'; + +describe('ObservablesUtilityBar', () => { + let appMock: AppMockRenderer; + const props: AddObservableProps = { + caseData: mockCase, + }; + + beforeEach(() => { + appMock = createAppMockRenderer(); + jest.clearAllMocks(); + }); + + it('renders correctly', async () => { + const result = appMock.render(); + + expect(result.getByTestId('cases-observables-add')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/observables/observables_utility_bar.tsx b/x-pack/plugins/cases/public/components/observables/observables_utility_bar.tsx new file mode 100644 index 0000000000000..3888b1d31c1a0 --- /dev/null +++ b/x-pack/plugins/cases/public/components/observables/observables_utility_bar.tsx @@ -0,0 +1,26 @@ +/* + * 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 React from 'react'; + +import { EuiFlexGroup } from '@elastic/eui'; + +import type { CaseUI } from '../../../common'; +import { AddObservable } from './add_observable'; + +interface ObservablesUtilityBarProps { + caseData: CaseUI; +} + +export const ObservablesUtilityBar = ({ caseData }: ObservablesUtilityBarProps) => { + return ( + + + + ); +}; + +ObservablesUtilityBar.displayName = 'ObservablesUtilityBar'; diff --git a/x-pack/plugins/cases/public/components/observables/translations.tsx b/x-pack/plugins/cases/public/components/observables/translations.tsx new file mode 100644 index 0000000000000..5eb77528e52a5 --- /dev/null +++ b/x-pack/plugins/cases/public/components/observables/translations.tsx @@ -0,0 +1,124 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +export const ADD_OBSERVABLE = i18n.translate('xpack.cases.caseView.observables.addObservable', { + defaultMessage: 'Add observable', +}); + +export const EDIT_OBSERVABLE = i18n.translate('xpack.cases.caseView.observables.edit', { + defaultMessage: 'Edit observable', +}); + +export const NO_OBSERVABLES = i18n.translate( + 'xpack.cases.caseView.observables.noObservablesAvailable', + { + defaultMessage: 'No observables available', + } +); + +export const SHOWING_OBSERVABLES = (totalObservables: number) => + i18n.translate('xpack.cases.caseView.observables.showingObservablesTitle', { + values: { totalObservables }, + defaultMessage: + 'Showing {totalObservables} {totalObservables, plural, =1 {observable} other {observables}}', + }); + +export const OBSERVABLES_TABLE = i18n.translate( + 'xpack.cases.caseView.observables.observablesTable', + { + defaultMessage: 'Observables table', + } +); + +export const SEARCH_PLACEHOLDER = i18n.translate( + 'xpack.cases.caseView.observables.searchPlaceholder', + { + defaultMessage: 'Search observables', + } +); + +export const DATE_ADDED = i18n.translate('xpack.cases.caseView.observables.dateAdded', { + defaultMessage: 'Date added', +}); + +export const OBSERVABLE_TYPE = i18n.translate('xpack.cases.caseView.observables.type', { + defaultMessage: 'Observable type', +}); + +export const OBSERVABLE_VALUE = i18n.translate('xpack.cases.caseView.observables.value', { + defaultMessage: 'Observable value', +}); + +export const OBSERVABLE_ACTIONS = i18n.translate('xpack.cases.caseView.observables.actions', { + defaultMessage: 'Actions', +}); + +export const DELETE_OBSERVABLE = i18n.translate('xpack.cases.caseView.observables.delete', { + defaultMessage: 'Delete observable', +}); + +export const CANCEL = i18n.translate('xpack.cases.caseView.observables.cancel', { + defaultMessage: 'Cancel', +}); + +export const VALUE_PLACEHOLDER = i18n.translate( + 'xpack.cases.caseView.observables.valuePlaceholder', + { + defaultMessage: 'Observable value', + } +); + +export const DELETE_OBSERVABLE_CONFIRM = i18n.translate( + 'xpack.cases.caseView.observables.deleteConfirmation', + { + defaultMessage: 'Are you sure you want to delete this observable?', + } +); + +export const SAVE_OBSERVABLE = i18n.translate('xpack.cases.caseView.observables.save', { + defaultMessage: 'Save observable', +}); + +export const ADDED = (type: string, value: string) => + i18n.translate('xpack.cases.caseView.observables.added', { + defaultMessage: 'observable value "{value}" of type {type} added', + values: { type, value }, + }); + +export const PLATINUM_NOTICE = i18n.translate('xpack.cases.caseView.observables.platinumNotice', { + defaultMessage: + 'In order to assign observables to cases, you must be subscribed to an Elastic Platinum license', +}); + +export const REQUIRED_VALUE = i18n.translate('xpack.cases.caseView.observables.requiredValue', { + defaultMessage: 'Value is required', +}); + +export const INVALID_VALUE = i18n.translate('xpack.cases.caseView.observables.invalidValue', { + defaultMessage: 'Value is invalid', +}); + +export const INVALID_EMAIL = i18n.translate('xpack.cases.caseView.observables.invalidEmail', { + defaultMessage: 'Value should be a valid email', +}); + +export const FIELD_LABEL_VALUE = i18n.translate('xpack.cases.caseView.observables.labelValue', { + defaultMessage: 'Value', +}); + +export const FIELD_LABEL_DESCRIPTION = i18n.translate( + 'xpack.cases.caseView.observables.labelDescription', + { + defaultMessage: 'Description', + } +); + +export const FIELD_LABEL_TYPE = i18n.translate('xpack.cases.caseView.observables.labelType', { + defaultMessage: 'Type', +}); diff --git a/x-pack/plugins/cases/public/components/similar_cases/table.test.tsx b/x-pack/plugins/cases/public/components/similar_cases/table.test.tsx new file mode 100644 index 0000000000000..2d3de8b54ae93 --- /dev/null +++ b/x-pack/plugins/cases/public/components/similar_cases/table.test.tsx @@ -0,0 +1,38 @@ +/* + * 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 React from 'react'; +import { type AppMockRenderer, createAppMockRenderer } from '../../common/mock'; +import { SimilarCasesTable, type SimilarCasesTableProps } from './table'; +import { mockCase, mockObservables } from '../../containers/mock'; + +describe('SimilarCasesTable', () => { + let appMock: AppMockRenderer; + const props: SimilarCasesTableProps = { + cases: [{ ...mockCase, similarities: { observables: mockObservables } }], + isLoading: false, + onChange: jest.fn(), + pagination: { pageIndex: 0, totalItemCount: 1 }, + }; + + beforeEach(() => { + appMock = createAppMockRenderer(); + jest.clearAllMocks(); + }); + + it('renders correctly', async () => { + const result = appMock.render(); + + expect(result.getByTestId('similar-cases-table')).toBeInTheDocument(); + }); + + it('renders loading indicator when loading', async () => { + const result = appMock.render(); + expect(result.queryByTestId('similar-cases-table')).not.toBeInTheDocument(); + expect(result.getByTestId('similar-cases-table-loading')).toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/cases/public/components/similar_cases/table.tsx b/x-pack/plugins/cases/public/components/similar_cases/table.tsx new file mode 100644 index 0000000000000..6ceb299bc1501 --- /dev/null +++ b/x-pack/plugins/cases/public/components/similar_cases/table.tsx @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { FunctionComponent } from 'react'; +import React, { useCallback } from 'react'; +import { css } from '@emotion/react'; +import type { EuiBasicTableProps, Pagination } from '@elastic/eui'; +import { EuiEmptyPrompt, EuiSkeletonText, EuiBasicTable, useEuiTheme } from '@elastic/eui'; + +import type { SimilarCaseUI } from '../../../common/ui/types'; + +import * as i18n from './translations'; +import { useSimilarCasesColumns } from './use_similar_cases_columns'; + +export interface SimilarCasesTableProps { + cases: SimilarCaseUI[]; + isLoading: boolean; + onChange: EuiBasicTableProps['onChange']; + pagination: Pagination; +} + +export const SimilarCasesTable: FunctionComponent = ({ + cases, + isLoading, + onChange, + pagination, +}) => { + const { euiTheme } = useEuiTheme(); + + const { columns } = useSimilarCasesColumns(); + + const tableRowProps = useCallback( + (theCase: SimilarCaseUI) => ({ + 'data-test-subj': `similar-cases-table-row-${theCase.id}`, + }), + [] + ); + + return isLoading ? ( +
+ +
+ ) : ( + <> + {i18n.NO_CASES}} + titleSize="xs" + body={i18n.NO_CASES_BODY} + /> + } + rowProps={tableRowProps} + /> + + ); +}; +SimilarCasesTable.displayName = 'SimilarCasesTable'; diff --git a/x-pack/plugins/cases/public/components/similar_cases/translations.ts b/x-pack/plugins/cases/public/components/similar_cases/translations.ts new file mode 100644 index 0000000000000..3b0f1179d3877 --- /dev/null +++ b/x-pack/plugins/cases/public/components/similar_cases/translations.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export * from '../../common/translations'; +export * from '../user_profiles/translations'; +export { + OPEN as STATUS_OPEN, + IN_PROGRESS as STATUS_IN_PROGRESS, + CLOSED as STATUS_CLOSED, +} from '@kbn/cases-components/src/status/translations'; + +export const NO_CASES = i18n.translate('xpack.cases.similarCaseTable.noCases.title', { + defaultMessage: 'No cases to display', +}); + +export const NO_CASES_BODY = i18n.translate('xpack.cases.similarCaseTable.noCases.readonly.body', { + defaultMessage: 'Edit your filter settings.', +}); + +export const SIMILARITY_REASON = i18n.translate('xpack.cases.similarCaseTable.similarities.title', { + defaultMessage: 'Similar observable values', +}); diff --git a/x-pack/plugins/cases/public/components/similar_cases/use_similar_cases_columns.tsx b/x-pack/plugins/cases/public/components/similar_cases/use_similar_cases_columns.tsx new file mode 100644 index 0000000000000..54ad38363f14e --- /dev/null +++ b/x-pack/plugins/cases/public/components/similar_cases/use_similar_cases_columns.tsx @@ -0,0 +1,191 @@ +/* + * 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 React, { useMemo } from 'react'; +import { css } from '@emotion/react'; +import type { + EuiTableActionsColumnType, + EuiTableComputedColumnType, + EuiTableFieldDataColumnType, +} from '@elastic/eui'; +import { EuiBadgeGroup, EuiBadge, EuiHealth, EuiToolTip } from '@elastic/eui'; +import { Status } from '@kbn/cases-components/src/status/status'; + +import { CaseSeverity } from '../../../common/types/domain'; +import type { CaseUI, SimilarCaseUI } from '../../../common/ui/types'; +import { getEmptyCellValue } from '../empty_value'; +import { CaseDetailsLink } from '../links'; +import { TruncatedText } from '../truncated_text'; +import { severities } from '../severity/config'; +import { useCasesColumnsConfiguration } from '../all_cases/use_cases_columns_configuration'; +import * as i18n from './translations'; + +type SimilarCasesColumns = + | EuiTableActionsColumnType + | EuiTableComputedColumnType + | EuiTableFieldDataColumnType; + +const LINE_CLAMP = 3; +const getLineClampedCss = css` + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: ${LINE_CLAMP}; + -webkit-box-orient: vertical; + overflow: hidden; + word-break: normal; +`; + +const SIMILARITIES_FIELD = 'similarities' as const; + +export interface UseSimilarCasesColumnsReturnValue { + columns: SimilarCasesColumns[]; + rowHeader: string; +} + +export const useSimilarCasesColumns = (): UseSimilarCasesColumnsReturnValue => { + const casesColumnsConfig = useCasesColumnsConfiguration(false); + const columns: SimilarCasesColumns[] = useMemo( + () => [ + { + field: casesColumnsConfig.title.field, + name: casesColumnsConfig.title.name, + sortable: false, + render: (title: string, theCase: SimilarCaseUI) => { + if (theCase.id != null && theCase.title != null) { + const caseDetailsLinkComponent = ( + + + + ); + + return caseDetailsLinkComponent; + } + return getEmptyCellValue(); + }, + width: '20%', + }, + { + field: casesColumnsConfig.tags.field, + name: casesColumnsConfig.tags.name, + render: (tags: CaseUI['tags']) => { + if (tags != null && tags.length > 0) { + const clampedBadges = ( + + {tags.map((tag: string, i: number) => ( + + {tag} + + ))} + + ); + + const unclampedBadges = ( + + {tags.map((tag: string, i: number) => ( + + {tag} + + ))} + + ); + + return ( + + {clampedBadges} + + ); + } + return getEmptyCellValue(); + }, + width: '12%', + }, + { + field: casesColumnsConfig.category.field, + name: casesColumnsConfig.category.name, + sortable: false, + render: (category: CaseUI['category']) => { + if (category != null) { + return ( + + {category} + + ); + } + return getEmptyCellValue(); + }, + width: '120px', + }, + { + field: casesColumnsConfig.status.field, + name: casesColumnsConfig.status.name, + sortable: false, + render: (status: CaseUI['status']) => { + if (status != null) { + return ; + } + + return getEmptyCellValue(); + }, + width: '110px', + }, + { + field: casesColumnsConfig.severity.field, + name: casesColumnsConfig.severity.name, + sortable: false, + render: (severity: CaseUI['severity']) => { + if (severity != null) { + const severityData = severities[severity ?? CaseSeverity.LOW]; + return ( + + {severityData.label} + + ); + } + return getEmptyCellValue(); + }, + width: '90px', + }, + { + field: SIMILARITIES_FIELD, + name: i18n.SIMILARITY_REASON, + sortable: false, + render: (similarities: SimilarCaseUI['similarities'], theCase: SimilarCaseUI) => { + if (theCase.id != null && theCase.title != null) { + return similarities.observables.map((similarity) => similarity.value).join(', '); + } + return getEmptyCellValue(); + }, + width: '20%', + }, + ], + [casesColumnsConfig] + ); + + return { columns, rowHeader: casesColumnsConfig.title.field }; +}; diff --git a/x-pack/plugins/cases/public/components/templates/form.test.tsx b/x-pack/plugins/cases/public/components/templates/form.test.tsx index 349457c2be98f..60fb63c64e8b3 100644 --- a/x-pack/plugins/cases/public/components/templates/form.test.tsx +++ b/x-pack/plugins/cases/public/components/templates/form.test.tsx @@ -57,6 +57,7 @@ describe('TemplateForm', () => { version: '', id: '', owner: mockedTestProvidersOwner[0], + observableTypes: [], }, onChange: jest.fn(), initialValue: null, @@ -343,6 +344,7 @@ describe('TemplateForm', () => { description: undefined, name: 'Template 1', tags: [], + observables: [], }, isValid: true, }) diff --git a/x-pack/plugins/cases/public/components/templates/form_fields.test.tsx b/x-pack/plugins/cases/public/components/templates/form_fields.test.tsx index 48c6f956ccc7c..b1f08415acdec 100644 --- a/x-pack/plugins/cases/public/components/templates/form_fields.test.tsx +++ b/x-pack/plugins/cases/public/components/templates/form_fields.test.tsx @@ -43,6 +43,7 @@ describe('form fields', () => { version: '', id: '', owner: mockedTestProvidersOwner[0], + observableTypes: [], }, }; diff --git a/x-pack/plugins/cases/public/components/user_actions/comment/comment.tsx b/x-pack/plugins/cases/public/components/user_actions/comment/comment.tsx index 36dd0c5325e48..48d00f075c81f 100644 --- a/x-pack/plugins/cases/public/components/user_actions/comment/comment.tsx +++ b/x-pack/plugins/cases/public/components/user_actions/comment/comment.tsx @@ -10,7 +10,7 @@ import type { EuiCommentProps } from '@elastic/eui'; import type { SnakeToCamelCase } from '../../../../common/types'; import type { CommentUserAction } from '../../../../common/types/domain'; import { UserActionActions, AttachmentType } from '../../../../common/types/domain'; -import type { AttachmentTypeRegistry } from '../../../../common/registry'; +import { type AttachmentTypeRegistry } from '../../../../common/registry'; import type { UserActionBuilder, UserActionBuilderArgs } from '../types'; import { createCommonUpdateUserActionBuilder } from '../common'; import type { AttachmentUI } from '../../../containers/types'; @@ -242,6 +242,7 @@ const getCreateCommentUserAction = ({ }); return persistableBuilder.build(); + default: return []; } diff --git a/x-pack/plugins/cases/public/containers/__mocks__/api.ts b/x-pack/plugins/cases/public/containers/__mocks__/api.ts index 2e792fbd134cc..ab12561e2c733 100644 --- a/x-pack/plugins/cases/public/containers/__mocks__/api.ts +++ b/x-pack/plugins/cases/public/containers/__mocks__/api.ts @@ -30,6 +30,7 @@ import { getCaseUserActionsStatsResponse, getCaseUsersMockResponse, customFieldsMock, + allCasesSnake, } from '../mock'; import type { CaseConnectors, @@ -187,3 +188,9 @@ export const replaceCustomField = async ({ customFieldValue: string | boolean | null; caseVersion: string; }): Promise => Promise.resolve(customFieldsMock[0]); + +export const getSimilarCases = async () => allCasesSnake; + +export const postObservable = jest.fn(); +export const patchObservable = jest.fn(); +export const deleteObservable = jest.fn(); diff --git a/x-pack/plugins/cases/public/containers/api.test.tsx b/x-pack/plugins/cases/public/containers/api.test.tsx index d72eece49e1e3..2934c4d1f3432 100644 --- a/x-pack/plugins/cases/public/containers/api.test.tsx +++ b/x-pack/plugins/cases/public/containers/api.test.tsx @@ -40,6 +40,10 @@ import { deleteFileAttachments, getCategories, replaceCustomField, + postObservable, + getSimilarCases, + patchObservable, + deleteObservable, } from './api'; import { @@ -63,6 +67,9 @@ import { getCaseUserActionsStatsResponse, basicFileMock, customFieldsMock, + mockCase, + similarCases, + similarCasesSnake, } from './mock'; import { DEFAULT_FILTER_OPTIONS, DEFAULT_QUERY_PARAMS } from './constants'; @@ -1154,4 +1161,164 @@ describe('Cases API', () => { expect(resp).toEqual(customFieldsMock[0]); }); }); + + describe('getSimilarCases', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockResolvedValue(similarCasesSnake); + }); + + it('should be called with correct url, method, signal', async () => { + await getSimilarCases({ + caseId: mockCase.id, + signal: abortCtrl.signal, + page: 0, + perPage: 10, + }); + expect(fetchMock).toHaveBeenCalledWith(`${CASES_INTERNAL_URL}/${mockCase.id}/_similar`, { + method: 'POST', + body: JSON.stringify({ + page: 0, + perPage: 10, + }), + signal: abortCtrl.signal, + }); + }); + + it('should return correct response', async () => { + const resp = await getSimilarCases({ + caseId: mockCase.id, + signal: abortCtrl.signal, + page: 1, + perPage: 10, + }); + expect(resp).toEqual(similarCases); + }); + }); + + describe('postObservable', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockResolvedValue(basicCaseSnake); + }); + + it('should be called with correct check url, method, signal', async () => { + await postObservable( + { + observable: { + typeKey: '18b62f19-8c60-415e-8a08-706d1078c556', + value: 'test value', + description: '', + }, + }, + mockCase.id, + abortCtrl.signal + ); + + expect(fetchMock).toHaveBeenCalledWith(`${CASES_INTERNAL_URL}/${mockCase.id}/observables`, { + method: 'POST', + body: JSON.stringify({ + observable: { + typeKey: '18b62f19-8c60-415e-8a08-706d1078c556', + value: 'test value', + description: '', + }, + }), + signal: abortCtrl.signal, + }); + }); + + it('should return correct response', async () => { + const resp = await postObservable( + { + observable: { + typeKey: '18b62f19-8c60-415e-8a08-706d1078c556', + value: 'test value', + description: '', + }, + }, + mockCase.id, + abortCtrl.signal + ); + expect(resp).toEqual(basicCase); + }); + }); + + describe('patchObservable', () => { + const observableId = 'afa44220-862c-4a21-b574-351ab4d0a732'; + + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockResolvedValue(basicCaseSnake); + }); + + it('should be called with correct check url, method, signal', async () => { + await patchObservable( + { + observable: { + value: 'test value', + description: '', + }, + }, + mockCase.id, + observableId, + abortCtrl.signal + ); + + expect(fetchMock).toHaveBeenCalledWith( + `${CASES_INTERNAL_URL}/${mockCase.id}/observables/${observableId}`, + { + method: 'PATCH', + body: JSON.stringify({ + observable: { + value: 'test value', + description: '', + }, + }), + signal: abortCtrl.signal, + } + ); + }); + + it('should return correct response', async () => { + const resp = await patchObservable( + { + observable: { + value: 'test value', + description: '', + }, + }, + mockCase.id, + observableId, + abortCtrl.signal + ); + expect(resp).toEqual(basicCase); + }); + }); + + describe('deleteObservable', () => { + const observableId = 'afa44220-862c-4a21-b574-351ab4d0a732'; + + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockResolvedValue(basicCaseSnake); + }); + + it('should be called with correct check url, method, signal', async () => { + await deleteObservable(mockCase.id, observableId, abortCtrl.signal); + + expect(fetchMock).toHaveBeenCalledWith( + `${CASES_INTERNAL_URL}/${mockCase.id}/observables/${observableId}`, + { + method: 'DELETE', + signal: abortCtrl.signal, + } + ); + }); + + it('should return correct response', async () => { + const resp = await deleteObservable(mockCase.id, observableId, abortCtrl.signal); + expect(resp).toEqual(undefined); + }); + }); }); diff --git a/x-pack/plugins/cases/public/containers/api.ts b/x-pack/plugins/cases/public/containers/api.ts index 020a4629552f4..4216421892b8c 100644 --- a/x-pack/plugins/cases/public/containers/api.ts +++ b/x-pack/plugins/cases/public/containers/api.ts @@ -22,6 +22,9 @@ import type { UserActionFindResponse, SingleCaseMetricsResponse, CustomFieldPutRequest, + CasesSimilarResponse, + AddObservableRequest, + UpdateObservableRequest, } from '../../common/types/api'; import type { CaseConnectors, @@ -36,6 +39,8 @@ import type { CasesUI, FilterOptions, CaseUICustomField, + SimilarCasesProps, + CasesSimilarResponseUI, } from '../../common/ui/types'; import { SortFieldCase } from '../../common/ui/types'; import { @@ -50,6 +55,10 @@ import { getCaseUsersUrl, getCaseUserActionStatsUrl, getCustomFieldReplaceUrl, + getCaseCreateObservableUrl, + getCaseUpdateObservableUrl, + getCaseDeleteObservableUrl, + getCaseSimilarCasesUrl, } from '../../common/api'; import { CASE_REPORTERS_URL, @@ -71,6 +80,7 @@ import { convertCaseToCamelCase, convertCasesToCamelCase, convertCaseResolveToCamelCase, + convertSimilarCasesToCamel, } from '../api/utils'; import type { @@ -93,7 +103,7 @@ import { decodeCaseUserActionStatsResponse, constructCustomFieldsFilter, } from './utils'; -import { decodeCasesFindResponse } from '../api/decoders'; +import { decodeCasesFindResponse, decodeCasesSimilarResponse } from '../api/decoders'; export const getCase = async ( caseId: string, @@ -608,3 +618,62 @@ export const getCaseUsers = async ({ signal, }); }; + +export const postObservable = async ( + request: AddObservableRequest, + caseId: string, + signal?: AbortSignal +): Promise => { + const response = await KibanaServices.get().http.fetch(getCaseCreateObservableUrl(caseId), { + method: 'POST', + body: JSON.stringify({ observable: request.observable }), + signal, + }); + return convertCaseToCamelCase(decodeCaseResponse(response)); +}; + +export const patchObservable = async ( + request: UpdateObservableRequest, + caseId: string, + observableId: string, + signal?: AbortSignal +): Promise => { + const response = await KibanaServices.get().http.fetch( + getCaseUpdateObservableUrl(caseId, observableId), + { + method: 'PATCH', + body: JSON.stringify({ observable: request.observable }), + signal, + } + ); + return convertCaseToCamelCase(decodeCaseResponse(response)); +}; + +export const deleteObservable = async ( + caseId: string, + observableId: string, + signal?: AbortSignal +): Promise => { + await KibanaServices.get().http.fetch(getCaseDeleteObservableUrl(caseId, observableId), { + method: 'DELETE', + signal, + }); +}; + +export const getSimilarCases = async ({ + caseId, + signal, + perPage, + page, +}: SimilarCasesProps): Promise => { + const response = await KibanaServices.get().http.fetch( + getCaseSimilarCasesUrl(caseId), + { + method: 'POST', + body: JSON.stringify({ page, perPage }), + signal, + } + ); + + return convertSimilarCasesToCamel(decodeCasesSimilarResponse(response)); +}; diff --git a/x-pack/plugins/cases/public/containers/configure/api.ts b/x-pack/plugins/cases/public/containers/configure/api.ts index b67e8f53f2268..4fb6149e3cb2b 100644 --- a/x-pack/plugins/cases/public/containers/configure/api.ts +++ b/x-pack/plugins/cases/public/containers/configure/api.ts @@ -115,8 +115,27 @@ export const fetchActionTypes = async ({ signal }: ApiProps): Promise ): CasesConfigurationUI => { - const { id, version, mappings, customFields, templates, closureType, connector, owner } = - configuration; - - return { id, version, mappings, customFields, templates, closureType, connector, owner }; + const { + id, + version, + mappings, + customFields, + templates, + closureType, + connector, + owner, + observableTypes, + } = configuration; + + return { + id, + version, + mappings, + customFields, + templates, + closureType, + connector, + owner, + observableTypes, + }; }; diff --git a/x-pack/plugins/cases/public/containers/configure/mock.ts b/x-pack/plugins/cases/public/containers/configure/mock.ts index 1124283e5aa94..b699fe753656e 100644 --- a/x-pack/plugins/cases/public/containers/configure/mock.ts +++ b/x-pack/plugins/cases/public/containers/configure/mock.ts @@ -11,7 +11,11 @@ import { ConnectorTypes } from '../../../common/types/domain'; import { SECURITY_SOLUTION_OWNER } from '../../../common/constants'; import type { CaseConnectorMapping } from './types'; import type { CasesConfigurationUI } from '../types'; -import { customFieldsConfigurationMock, templatesConfigurationMock } from '../mock'; +import { + customFieldsConfigurationMock, + observableTypesMock, + templatesConfigurationMock, +} from '../mock'; export const mappings: CaseConnectorMapping[] = [ { @@ -50,6 +54,7 @@ export const caseConfigurationResponseMock: Configuration = { version: 'WzHJ12', customFields: customFieldsConfigurationMock, templates: templatesConfigurationMock, + observableTypes: observableTypesMock, }; export const caseConfigurationRequest: ConfigurationRequest = { @@ -77,4 +82,5 @@ export const casesConfigurationsMock: CasesConfigurationUI = { customFields: customFieldsConfigurationMock, templates: templatesConfigurationMock, owner: 'securitySolution', + observableTypes: observableTypesMock, }; diff --git a/x-pack/plugins/cases/public/containers/configure/use_get_all_case_configurations.test.ts b/x-pack/plugins/cases/public/containers/configure/use_get_all_case_configurations.test.ts index 52d4df20e5401..395675d37cb87 100644 --- a/x-pack/plugins/cases/public/containers/configure/use_get_all_case_configurations.test.ts +++ b/x-pack/plugins/cases/public/containers/configure/use_get_all_case_configurations.test.ts @@ -46,6 +46,7 @@ describe('Use get all case configurations hook', () => { mappings: [], version: '', owner: '', + observableTypes: [], }, ]); @@ -77,6 +78,7 @@ describe('Use get all case configurations hook', () => { mappings: [], version: '', owner: '', + observableTypes: [], }, ]) ); diff --git a/x-pack/plugins/cases/public/containers/configure/use_persist_configuration.test.tsx b/x-pack/plugins/cases/public/containers/configure/use_persist_configuration.test.tsx index 04b266478f667..a8929a32212f5 100644 --- a/x-pack/plugins/cases/public/containers/configure/use_persist_configuration.test.tsx +++ b/x-pack/plugins/cases/public/containers/configure/use_persist_configuration.test.tsx @@ -14,7 +14,11 @@ import type { AppMockRenderer } from '../../common/mock'; import { createAppMockRenderer } from '../../common/mock'; import { ConnectorTypes } from '../../../common'; import { casesQueriesKeys } from '../constants'; -import { customFieldsConfigurationMock, templatesConfigurationMock } from '../mock'; +import { + customFieldsConfigurationMock, + observableTypesMock, + templatesConfigurationMock, +} from '../mock'; jest.mock('./api'); jest.mock('../../common/lib/kibana'); @@ -42,6 +46,7 @@ describe('usePersistConfiguration', () => { templates: [], version: '', id: '', + observableTypes: observableTypesMock, }; let appMockRender: AppMockRenderer; @@ -70,6 +75,7 @@ describe('usePersistConfiguration', () => { customFields: [], owner: 'securitySolution', templates: [], + observableTypes: observableTypesMock, }); }); @@ -95,6 +101,7 @@ describe('usePersistConfiguration', () => { customFields: [], templates: [], owner: 'securitySolution', + observableTypes: observableTypesMock, }); }); @@ -125,6 +132,7 @@ describe('usePersistConfiguration', () => { customFields: customFieldsConfigurationMock, templates: templatesConfigurationMock, owner: 'securitySolution', + observableTypes: observableTypesMock, }); }); }); @@ -148,6 +156,7 @@ describe('usePersistConfiguration', () => { customFields: [], templates: [], version: 'test-version', + observableTypes: observableTypesMock, }); }); @@ -171,6 +180,37 @@ describe('usePersistConfiguration', () => { result.current.mutate({ ...newRequest, id: 'test-id', version: 'test-version' }); }); + await waitFor(() => { + expect(spyPatch).toHaveBeenCalledWith('test-id', { + closure_type: 'close-by-user', + connector: { fields: null, id: 'none', name: 'none', type: '.none' }, + customFields: customFieldsConfigurationMock, + templates: templatesConfigurationMock, + version: 'test-version', + observableTypes: observableTypesMock, + }); + }); + }); + + it('calls patchCaseConfigure without observableTypes if it is not specified', async () => { + const spyPatch = jest.spyOn(api, 'patchCaseConfigure'); + + const { result } = renderHook(() => usePersistConfiguration(), { + wrapper: appMockRender.AppWrapper, + }); + + const { observableTypes, ...rest } = request; + + const newRequest = { + ...rest, + customFields: customFieldsConfigurationMock, + templates: templatesConfigurationMock, + }; + + act(() => { + result.current.mutate({ ...newRequest, id: 'test-id', version: 'test-version' }); + }); + await waitFor(() => { expect(spyPatch).toHaveBeenCalledWith('test-id', { closure_type: 'close-by-user', diff --git a/x-pack/plugins/cases/public/containers/configure/use_persist_configuration.tsx b/x-pack/plugins/cases/public/containers/configure/use_persist_configuration.tsx index dc9bed95d1df8..b2943c9f0b128 100644 --- a/x-pack/plugins/cases/public/containers/configure/use_persist_configuration.tsx +++ b/x-pack/plugins/cases/public/containers/configure/use_persist_configuration.tsx @@ -27,7 +27,15 @@ export const usePersistConfiguration = () => { const { showErrorToast, showSuccessToast } = useCasesToast(); return useMutation( - ({ id, version, closureType, customFields, templates, connector }: Request) => { + ({ + id, + version, + closureType, + customFields, + templates, + connector, + observableTypes, + }: Request) => { if (isEmpty(id) || isEmpty(version)) { return postCaseConfigure({ closure_type: closureType, @@ -35,6 +43,7 @@ export const usePersistConfiguration = () => { customFields: customFields ?? [], templates: templates ?? [], owner: owner[0], + observableTypes, }); } @@ -44,6 +53,7 @@ export const usePersistConfiguration = () => { connector, customFields: customFields ?? [], templates: templates ?? [], + observableTypes, }); }, { diff --git a/x-pack/plugins/cases/public/containers/configure/utils.ts b/x-pack/plugins/cases/public/containers/configure/utils.ts index e4416beb5ce57..91d06721f65af 100644 --- a/x-pack/plugins/cases/public/containers/configure/utils.ts +++ b/x-pack/plugins/cases/public/containers/configure/utils.ts @@ -21,6 +21,7 @@ export const initialConfiguration: CasesConfigurationUI = { version: '', id: '', owner: '', + observableTypes: [], }; export const getConfigurationByOwner = ({ diff --git a/x-pack/plugins/cases/public/containers/constants.ts b/x-pack/plugins/cases/public/containers/constants.ts index 0f57a729bc58b..b89f261fdd46c 100644 --- a/x-pack/plugins/cases/public/containers/constants.ts +++ b/x-pack/plugins/cases/public/containers/constants.ts @@ -23,6 +23,8 @@ export const casesQueriesKeys = { casesMetrics: () => [...casesQueriesKeys.casesList(), 'metrics'] as const, casesStatuses: () => [...casesQueriesKeys.casesList(), 'statuses'] as const, cases: (params: unknown) => [...casesQueriesKeys.casesList(), 'all-cases', params] as const, + similarCases: (id: string, params: unknown) => + [...casesQueriesKeys.caseView(), id, 'similar', params] as const, caseView: () => [...casesQueriesKeys.all, 'case'] as const, case: (id: string) => [...casesQueriesKeys.caseView(), id] as const, caseFiles: (id: string, params: unknown) => @@ -64,6 +66,9 @@ export const casesMutationsKeys = { bulkCreateAttachments: ['bulk-create-attachments'] as const, persistCaseConfiguration: ['persist-case-configuration'] as const, replaceCustomField: ['replace-custom-field'] as const, + postObservable: ['post-observable'] as const, + patchObservable: ['patch-observable'] as const, + deleteObservable: ['delete-observable'] as const, }; const DEFAULT_SEARCH_FIELDS = ['title', 'description']; diff --git a/x-pack/plugins/cases/public/containers/mock.ts b/x-pack/plugins/cases/public/containers/mock.ts index 1ed160cf1847b..e45fd7c10bd43 100644 --- a/x-pack/plugins/cases/public/containers/mock.ts +++ b/x-pack/plugins/cases/public/containers/mock.ts @@ -16,6 +16,7 @@ import type { Cases, CaseConnector, Attachment, + ObservableType, } from '../../common/types/domain'; import { CaseSeverity, @@ -46,9 +47,11 @@ import type { CaseUICustomField, CasesConfigurationUICustomField, CasesConfigurationUITemplate, + CasesSimilarResponseUI, + ObservableUI, } from '../../common/ui/types'; import { CaseMetricsFeature } from '../../common/types/api'; -import { SECURITY_SOLUTION_OWNER } from '../../common/constants'; +import { OBSERVABLE_TYPE_IPV4, SECURITY_SOLUTION_OWNER } from '../../common/constants'; import type { SnakeToCamelCase } from '../../common/types'; import { covertToSnakeCase } from './utils'; import type { @@ -56,7 +59,11 @@ import type { AttachmentViewObject, PersistableStateAttachmentType, } from '../client/attachment_framework/types'; -import type { CasesFindResponse, UserActionWithResponse } from '../../common/types/api'; +import type { + CasesFindResponse, + CasesSimilarResponse, + UserActionWithResponse, +} from '../../common/types/api'; export { connectorsMock } from '../common/mock/connectors'; export const basicCaseId = 'basic-case-id'; @@ -248,6 +255,7 @@ export const basicCase: CaseUI = { assignees: [{ uid: 'u_J41Oh6L9ki-Vo2tOogS8WRTENzhHurGtRc87NgEAlkc_0' }], category: null, customFields: [], + observables: [], }; export const basicFileMock: FileJSON = { @@ -371,6 +379,7 @@ export const mockCase: CaseUI = { assignees: [], category: null, customFields: [], + observables: [], }; export const basicCasePost: CaseUI = { @@ -461,6 +470,16 @@ export const allCases: CasesFindResponseUI = { countClosedCases: 130, }; +export const similarCases: CasesSimilarResponseUI = { + cases: cases.map(({ comments, ...theCase }) => ({ + ...theCase, + similarities: { observables: [] }, + })), + page: 1, + perPage: 5, + total: 10, +}; + export const actionLicenses: ActionLicense[] = [ { id: '.servicenow', @@ -622,6 +641,13 @@ export const allCasesSnake: CasesFindResponse = { count_open_cases: 20, }; +export const similarCasesSnake: CasesSimilarResponse = { + cases: casesSnake.map(({ ...theCase }) => ({ ...theCase, similarities: { observables: [] } })), + page: 1, + per_page: 5, + total: 10, +}; + export const getUserAction = ( type: UserActionType, action: UserActionAction, @@ -1267,3 +1293,33 @@ export const templatesConfigurationMock: CasesConfigurationUITemplate[] = [ }, }, ]; + +export const observableTypesMock: ObservableType[] = [ + { + label: 'test_observable_type_1', + key: '26f3f226-6611-4371-9242-c959b37c7af6', + }, + { + label: 'test_observable_type_2', + key: '67ec9e77-f64c-47d9-900c-1142239e0d25', + }, +]; + +export const mockObservables: ObservableUI[] = [ + { + id: 'fa6dfb79-7fd5-44d0-a582-ca196e3a5e69', + value: '127.0.0.1', + typeKey: OBSERVABLE_TYPE_IPV4.key, + description: null, + createdAt: '2024-12-11', + updatedAt: '2024-12-11', + }, + { + id: '096ca782-bd39-4dbf-8cf1-253d18277fdc', + value: '10.0.0.1', + typeKey: OBSERVABLE_TYPE_IPV4.key, + description: null, + createdAt: '2024-12-11', + updatedAt: '2024-12-11', + }, +]; diff --git a/x-pack/plugins/cases/public/containers/translations.ts b/x-pack/plugins/cases/public/containers/translations.ts index 5e78e1a71e2b2..4c1e332f7b67a 100644 --- a/x-pack/plugins/cases/public/containers/translations.ts +++ b/x-pack/plugins/cases/public/containers/translations.ts @@ -68,3 +68,15 @@ export const CATEGORIES_ERROR_TITLE = i18n.translate( defaultMessage: 'Error fetching categories', } ); + +export const OBSERVABLE_CREATED = i18n.translate('xpack.cases.caseView.observables.created', { + defaultMessage: 'Observable created', +}); + +export const OBSERVABLE_REMOVED = i18n.translate('xpack.cases.caseView.observables.removed', { + defaultMessage: 'Observable removed', +}); + +export const OBSERVABLE_UPDATED = i18n.translate('xpack.cases.caseView.observables.updated', { + defaultMessage: 'Observable updated', +}); diff --git a/x-pack/plugins/cases/public/containers/use_delete_observables.test.tsx b/x-pack/plugins/cases/public/containers/use_delete_observables.test.tsx new file mode 100644 index 0000000000000..adf0921820e72 --- /dev/null +++ b/x-pack/plugins/cases/public/containers/use_delete_observables.test.tsx @@ -0,0 +1,67 @@ +/* + * 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 { renderHook, act } from '@testing-library/react-hooks'; +import { useDeleteObservable } from './use_delete_observables'; +import { deleteObservable } from './api'; +import { useCasesToast } from '../common/use_cases_toast'; +import { useRefreshCaseViewPage } from '../components/case_view/use_on_refresh_case_view_page'; +import type { AppMockRenderer } from '../common/mock'; +import { createAppMockRenderer } from '../common/mock'; + +jest.mock('./api'); +jest.mock('../common/use_cases_toast'); +jest.mock('../components/case_view/use_on_refresh_case_view_page'); + +describe('useDeleteObservable', () => { + const caseId = 'test-case-id'; + const observableId = 'test-observable-id'; + const showErrorToast = jest.fn(); + const showSuccessToast = jest.fn(); + const refreshCaseViewPage = useRefreshCaseViewPage(); + + let appMockRender: AppMockRenderer; + + beforeEach(() => { + appMockRender = createAppMockRenderer(); + jest.clearAllMocks(); + (useCasesToast as jest.Mock).mockReturnValue({ showErrorToast, showSuccessToast }); + }); + + it('should call deleteObservable and show success toast on success', async () => { + (deleteObservable as jest.Mock).mockResolvedValue({}); + + const { result, waitFor } = renderHook(() => useDeleteObservable(caseId, observableId), { + wrapper: appMockRender.AppWrapper, + }); + + act(() => { + result.current.mutate(); + }); + + await waitFor(() => expect(deleteObservable).toHaveBeenCalledWith(caseId, observableId)); + expect(showSuccessToast).toHaveBeenCalledWith(expect.any(String)); + expect(refreshCaseViewPage).toHaveBeenCalled(); + }); + + it('should show error toast on failure', async () => { + const error = new Error('Failed to delete observable'); + (deleteObservable as jest.Mock).mockRejectedValue(error); + + const { result, waitFor } = renderHook(() => useDeleteObservable(caseId, observableId), { + wrapper: appMockRender.AppWrapper, + }); + + act(() => { + result.current.mutate(); + }); + + await waitFor(() => + expect(showErrorToast).toHaveBeenCalledWith(error, { title: expect.any(String) }) + ); + }); +}); diff --git a/x-pack/plugins/cases/public/containers/use_delete_observables.tsx b/x-pack/plugins/cases/public/containers/use_delete_observables.tsx new file mode 100644 index 0000000000000..76ce836094faa --- /dev/null +++ b/x-pack/plugins/cases/public/containers/use_delete_observables.tsx @@ -0,0 +1,38 @@ +/* + * 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 { useMutation } from '@tanstack/react-query'; +import { deleteObservable } from './api'; +import * as i18n from './translations'; +import type { ServerError } from '../types'; +import { useCasesToast } from '../common/use_cases_toast'; +import { casesMutationsKeys } from './constants'; +import { useRefreshCaseViewPage } from '../components/case_view/use_on_refresh_case_view_page'; + +export const useDeleteObservable = (caseId: string, observableId: string) => { + const { showErrorToast } = useCasesToast(); + const refreshCaseViewPage = useRefreshCaseViewPage(); + const { showSuccessToast } = useCasesToast(); + + return useMutation( + () => { + return deleteObservable(caseId, observableId); + }, + { + mutationKey: casesMutationsKeys.deleteObservable, + onError: (error: ServerError) => { + showErrorToast(error, { title: i18n.ERROR_TITLE }); + }, + onSuccess: () => { + showSuccessToast(i18n.OBSERVABLE_REMOVED); + refreshCaseViewPage(); + }, + } + ); +}; + +export type UseDeleteObservables = ReturnType; diff --git a/x-pack/plugins/cases/public/containers/use_get_similar_cases.test.tsx b/x-pack/plugins/cases/public/containers/use_get_similar_cases.test.tsx new file mode 100644 index 0000000000000..25bcadec2e7af --- /dev/null +++ b/x-pack/plugins/cases/public/containers/use_get_similar_cases.test.tsx @@ -0,0 +1,72 @@ +/* + * 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 { renderHook } from '@testing-library/react-hooks'; +import * as api from './api'; +import type { AppMockRenderer } from '../common/mock'; +import { createAppMockRenderer } from '../common/mock'; +import { useToasts } from '../common/lib/kibana/hooks'; +import { useGetSimilarCases } from './use_get_similar_cases'; +import { mockCase } from './mock'; + +jest.mock('./api'); +jest.mock('../common/lib/kibana/hooks'); + +describe('useGetSimilarCases', () => { + const abortCtrl = new AbortController(); + const addSuccess = jest.fn(); + (useToasts as jest.Mock).mockReturnValue({ addSuccess, addError: jest.fn() }); + + let appMockRender: AppMockRenderer; + + beforeEach(() => { + jest.clearAllMocks(); + appMockRender = createAppMockRenderer(); + }); + + it('calls getSimilarCases with correct arguments', async () => { + const spyOnGetCases = jest.spyOn(api, 'getSimilarCases'); + const { waitFor } = renderHook( + () => useGetSimilarCases({ caseId: mockCase.id, perPage: 10, page: 0 }), + { + wrapper: appMockRender.AppWrapper, + } + ); + + await waitFor(() => { + expect(spyOnGetCases).toBeCalled(); + }); + + expect(spyOnGetCases).toBeCalledWith({ + caseId: mockCase.id, + signal: abortCtrl.signal, + page: 0, + perPage: 10, + }); + }); + + it('shows a toast error message when an error occurs in the response', async () => { + const spyOnGetCases = jest.spyOn(api, 'getSimilarCases'); + spyOnGetCases.mockImplementation(() => { + throw new Error('Something went wrong'); + }); + + const addError = jest.fn(); + (useToasts as jest.Mock).mockReturnValue({ addSuccess, addError }); + + const { waitFor } = renderHook( + () => useGetSimilarCases({ caseId: mockCase.id, perPage: 10, page: 0 }), + { + wrapper: appMockRender.AppWrapper, + } + ); + + await waitFor(() => { + expect(addError).toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/plugins/cases/public/containers/use_get_similar_cases.tsx b/x-pack/plugins/cases/public/containers/use_get_similar_cases.tsx new file mode 100644 index 0000000000000..e5df1b6a6fac0 --- /dev/null +++ b/x-pack/plugins/cases/public/containers/use_get_similar_cases.tsx @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { UseQueryResult } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; +import { casesQueriesKeys } from './constants'; +import type { CasesSimilarResponseUI } from './types'; +import { useToasts } from '../common/lib/kibana'; +import * as i18n from './translations'; +import { getSimilarCases } from './api'; +import type { ServerError } from '../types'; + +export const initialData: CasesSimilarResponseUI = { + cases: [], + page: 0, + perPage: 0, + total: 0, +}; + +export const useGetSimilarCases = (params: { + caseId: string; + perPage: number; + page: number; +}): UseQueryResult => { + const toasts = useToasts(); + + return useQuery( + casesQueriesKeys.similarCases(params.caseId, params), + ({ signal }) => { + return getSimilarCases({ + caseId: params.caseId, + perPage: params.perPage, + page: params.page, + signal, + }); + }, + { + keepPreviousData: true, + onError: (error: ServerError) => { + if (error.name !== 'AbortError') { + toasts.addError( + error.body && error.body.message ? new Error(error.body.message) : error, + { title: i18n.ERROR_TITLE } + ); + } + }, + } + ); +}; diff --git a/x-pack/plugins/cases/public/containers/use_patch_observables.test.tsx b/x-pack/plugins/cases/public/containers/use_patch_observables.test.tsx new file mode 100644 index 0000000000000..98f1d50e77658 --- /dev/null +++ b/x-pack/plugins/cases/public/containers/use_patch_observables.test.tsx @@ -0,0 +1,75 @@ +/* + * 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 { renderHook, act } from '@testing-library/react-hooks'; +import { usePatchObservable } from './use_patch_observables'; +import { patchObservable } from './api'; +import { useCasesToast } from '../common/use_cases_toast'; +import { useRefreshCaseViewPage } from '../components/case_view/use_on_refresh_case_view_page'; +import * as i18n from './translations'; +import type { AppMockRenderer } from '../common/mock'; +import { createAppMockRenderer } from '../common/mock'; + +jest.mock('../common/use_cases_toast'); +jest.mock('../components/case_view/use_on_refresh_case_view_page'); + +jest.mock('./api'); +jest.mock('../common/lib/kibana'); + +describe('usePatchObservable', () => { + const caseId = 'test-case-id'; + const observableId = 'test-observable-id'; + const showErrorToast = jest.fn(); + const showSuccessToast = jest.fn(); + const refreshCaseViewPage = useRefreshCaseViewPage(); + + const mockRequest = { observable: { value: 'value', typeKey: 'test', description: null } }; + + let appMockRender: AppMockRenderer; + + beforeEach(() => { + jest.clearAllMocks(); + (useCasesToast as jest.Mock).mockReturnValue({ showErrorToast, showSuccessToast }); + + appMockRender = createAppMockRenderer(); + }); + + it('should call patchObservable and show success toast on success', async () => { + (patchObservable as jest.Mock).mockResolvedValue({}); + + const { result, waitFor } = renderHook(() => usePatchObservable(caseId, observableId), { + wrapper: appMockRender.AppWrapper, + }); + + act(() => { + result.current.mutate(mockRequest); + }); + + await waitFor(() => + expect(patchObservable).toHaveBeenCalledWith(mockRequest, caseId, observableId) + ); + expect(showSuccessToast).toHaveBeenCalledWith(i18n.OBSERVABLE_UPDATED); + expect(refreshCaseViewPage).toHaveBeenCalled(); + }); + + it('should show error toast on failure', async () => { + const error = new Error('Failed to patch observable'); + (patchObservable as jest.Mock).mockRejectedValue(error); + + const { result, waitFor } = renderHook(() => usePatchObservable(caseId, observableId), { + wrapper: appMockRender.AppWrapper, + }); + + act(() => { + result.current.mutate(mockRequest); + }); + + await waitFor(() => + expect(showErrorToast).toHaveBeenCalledWith(error, { title: i18n.ERROR_TITLE }) + ); + }); +}); diff --git a/x-pack/plugins/cases/public/containers/use_patch_observables.tsx b/x-pack/plugins/cases/public/containers/use_patch_observables.tsx new file mode 100644 index 0000000000000..772997435436a --- /dev/null +++ b/x-pack/plugins/cases/public/containers/use_patch_observables.tsx @@ -0,0 +1,38 @@ +/* + * 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 { useMutation } from '@tanstack/react-query'; +import type { UpdateObservableRequest } from '../../common/types/api'; +import { patchObservable } from './api'; +import * as i18n from './translations'; +import type { ServerError } from '../types'; +import { useCasesToast } from '../common/use_cases_toast'; +import { casesMutationsKeys } from './constants'; +import { useRefreshCaseViewPage } from '../components/case_view/use_on_refresh_case_view_page'; + +export const usePatchObservable = (caseId: string, observableId: string) => { + const { showErrorToast, showSuccessToast } = useCasesToast(); + const refreshCaseViewPage = useRefreshCaseViewPage(); + + return useMutation( + (request: UpdateObservableRequest) => { + return patchObservable(request, caseId, observableId); + }, + { + mutationKey: casesMutationsKeys.patchObservable, + onError: (error: ServerError) => { + showErrorToast(error, { title: i18n.ERROR_TITLE }); + }, + onSuccess: () => { + showSuccessToast(i18n.OBSERVABLE_UPDATED); + refreshCaseViewPage(); + }, + } + ); +}; + +export type UsePatchObservables = ReturnType; diff --git a/x-pack/plugins/cases/public/containers/use_post_observables.test.tsx b/x-pack/plugins/cases/public/containers/use_post_observables.test.tsx new file mode 100644 index 0000000000000..177b18d6b36de --- /dev/null +++ b/x-pack/plugins/cases/public/containers/use_post_observables.test.tsx @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook, act } from '@testing-library/react-hooks'; +import * as api from './api'; +import { useToasts } from '../common/lib/kibana'; +import type { AppMockRenderer } from '../common/mock'; +import { createAppMockRenderer } from '../common/mock'; +import { usePostObservable } from './use_post_observables'; +import { casesQueriesKeys } from './constants'; +import { mockCase } from './mock'; +import type { AddObservableRequest } from '../../common/types/api'; + +jest.mock('./api'); +jest.mock('../common/lib/kibana'); + +const observableMock: AddObservableRequest = { + observable: { + typeKey: '80a3cc9b-500a-45fa-909a-b4f78751726c', + value: 'test_value', + description: '', + }, +}; + +describe('usePostObservables', () => { + const addSuccess = jest.fn(); + const addError = jest.fn(); + + (useToasts as jest.Mock).mockReturnValue({ addSuccess, addError }); + + let appMockRender: AppMockRenderer; + + beforeEach(() => { + appMockRender = createAppMockRenderer(); + jest.clearAllMocks(); + }); + + it('calls the api when invoked with the correct parameters', async () => { + const spy = jest.spyOn(api, 'postObservable'); + const { waitForNextUpdate, result } = renderHook(() => usePostObservable(mockCase.id), { + wrapper: appMockRender.AppWrapper, + }); + + act(() => { + result.current.mutate(observableMock); + }); + + await waitForNextUpdate(); + + expect(spy).toHaveBeenCalledWith({ observable: observableMock.observable }, mockCase.id); + }); + + it('invalidates the queries correctly', async () => { + const queryClientSpy = jest.spyOn(appMockRender.queryClient, 'invalidateQueries'); + const { waitForNextUpdate, result } = renderHook(() => usePostObservable(mockCase.id), { + wrapper: appMockRender.AppWrapper, + }); + + act(() => { + result.current.mutate(observableMock); + }); + + await waitForNextUpdate(); + + expect(queryClientSpy).toHaveBeenCalledWith(casesQueriesKeys.caseView()); + }); + + it('does shows a success toaster', async () => { + const { waitForNextUpdate, result } = renderHook(() => usePostObservable(mockCase.id), { + wrapper: appMockRender.AppWrapper, + }); + + act(() => { + result.current.mutate(observableMock); + }); + + await waitForNextUpdate(); + + expect(addSuccess).toHaveBeenCalled(); + }); + + it('shows a toast error when the api return an error', async () => { + jest + .spyOn(api, 'postObservable') + .mockRejectedValue(new Error('usePostObservables: Test error')); + + const { waitForNextUpdate, result } = renderHook(() => usePostObservable(mockCase.id), { + wrapper: appMockRender.AppWrapper, + }); + + act(() => { + result.current.mutate(observableMock); + }); + + await waitForNextUpdate(); + + expect(addError).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/cases/public/containers/use_post_observables.tsx b/x-pack/plugins/cases/public/containers/use_post_observables.tsx new file mode 100644 index 0000000000000..401b0c0e33da4 --- /dev/null +++ b/x-pack/plugins/cases/public/containers/use_post_observables.tsx @@ -0,0 +1,38 @@ +/* + * 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 { useMutation } from '@tanstack/react-query'; +import type { AddObservableRequest } from '../../common/types/api'; +import { postObservable } from './api'; +import * as i18n from './translations'; +import type { ServerError } from '../types'; +import { useCasesToast } from '../common/use_cases_toast'; +import { casesMutationsKeys } from './constants'; +import { useRefreshCaseViewPage } from '../components/case_view/use_on_refresh_case_view_page'; + +export const usePostObservable = (caseId: string) => { + const { showErrorToast, showSuccessToast } = useCasesToast(); + const refreshCaseViewPage = useRefreshCaseViewPage(); + + return useMutation( + (request: AddObservableRequest) => { + return postObservable(request, caseId); + }, + { + mutationKey: casesMutationsKeys.postObservable, + onError: (error: ServerError) => { + showErrorToast(error, { title: i18n.ERROR_TITLE }); + }, + onSuccess: () => { + refreshCaseViewPage(); + showSuccessToast(i18n.OBSERVABLE_CREATED); + }, + } + ); +}; + +export type UsePostObservables = ReturnType; diff --git a/x-pack/plugins/cases/server/client/cases/bulk_create.test.ts b/x-pack/plugins/cases/server/client/cases/bulk_create.test.ts index c7f047aa6b385..ace3c39d31e0a 100644 --- a/x-pack/plugins/cases/server/client/cases/bulk_create.test.ts +++ b/x-pack/plugins/cases/server/client/cases/bulk_create.test.ts @@ -121,6 +121,7 @@ describe('bulkCreate', () => { "duration": null, "external_service": null, "id": "mock-id-1", + "observables": Array [], "owner": "securitySolution", "settings": Object { "syncAlerts": true, @@ -164,6 +165,7 @@ describe('bulkCreate', () => { "duration": null, "external_service": null, "id": "mock-id-1", + "observables": Array [], "owner": "securitySolution", "settings": Object { "syncAlerts": true, @@ -243,6 +245,7 @@ describe('bulkCreate', () => { "duration": null, "external_service": null, "id": "mock-saved-object-id", + "observables": Array [], "owner": "securitySolution", "settings": Object { "syncAlerts": true, @@ -281,6 +284,7 @@ describe('bulkCreate', () => { "duration": null, "external_service": null, "id": "mock-saved-object-id", + "observables": Array [], "owner": "securitySolution", "settings": Object { "syncAlerts": true, diff --git a/x-pack/plugins/cases/server/client/cases/bulk_update.test.ts b/x-pack/plugins/cases/server/client/cases/bulk_update.test.ts index 5cdd4c943b944..bfde249171009 100644 --- a/x-pack/plugins/cases/server/client/cases/bulk_update.test.ts +++ b/x-pack/plugins/cases/server/client/cases/bulk_update.test.ts @@ -794,6 +794,7 @@ describe('update', () => { "duration": null, "external_service": null, "id": "mock-id-1", + "observables": Array [], "owner": "securitySolution", "settings": Object { "syncAlerts": true, @@ -837,6 +838,7 @@ describe('update', () => { "duration": null, "external_service": null, "id": "mock-id-2", + "observables": Array [], "owner": "securitySolution", "settings": Object { "syncAlerts": true, diff --git a/x-pack/plugins/cases/server/client/cases/client.ts b/x-pack/plugins/cases/server/client/cases/client.ts index 68ee6f003f8b2..c615cb4ac6508 100644 --- a/x-pack/plugins/cases/server/client/cases/client.ts +++ b/x-pack/plugins/cases/server/client/cases/client.ts @@ -20,6 +20,10 @@ import type { BulkCreateCasesRequest, BulkCreateCasesResponse, CasesSearchRequest, + SimilarCasesSearchRequest, + CasesSimilarResponse, + AddObservableRequest, + UpdateObservableRequest, } from '../../../common/types/api'; import type { CasesClient } from '../client'; import type { CasesClientInternal } from '../client_internal'; @@ -36,6 +40,8 @@ import { bulkUpdate } from './bulk_update'; import { bulkCreate } from './bulk_create'; import type { ReplaceCustomFieldArgs } from './replace_custom_field'; import { replaceCustomField } from './replace_custom_field'; +import { similar } from './similar'; +import { addObservable, deleteObservable, updateObservable } from './observables'; /** * API for interacting with the cases entities. @@ -102,6 +108,26 @@ export interface CasesSubClient { * Replace custom field with specific customFieldId and CaseId */ replaceCustomField(params: ReplaceCustomFieldArgs): Promise; + /** + * Returns cases that are similar to given case (by observables) + */ + similar(caseId: string, params: SimilarCasesSearchRequest): Promise; + /** + * Adds observable to the case + */ + addObservable(caseId: string, params: AddObservableRequest): Promise; + /** + * Updates observable + */ + updateObservable( + caseId: string, + observableId: string, + params: UpdateObservableRequest + ): Promise; + /** + * Removes observable + */ + deleteObservable(caseId: string, observableId: string): Promise; } /** @@ -130,6 +156,14 @@ export const createCasesSubClient = ( getCasesByAlertID: (params: CasesByAlertIDParams) => getCasesByAlertID(params, clientArgs), replaceCustomField: (params: ReplaceCustomFieldArgs) => replaceCustomField(params, clientArgs, casesClient), + similar: (caseId: string, params: SimilarCasesSearchRequest) => + similar(caseId, params, clientArgs, casesClient), + addObservable: (caseId: string, params: AddObservableRequest) => + addObservable(caseId, params, clientArgs, casesClient), + updateObservable: (caseId: string, observableId: string, params: UpdateObservableRequest) => + updateObservable(caseId, observableId, params, clientArgs, casesClient), + deleteObservable: (caseId: string, observableId: string) => + deleteObservable(caseId, observableId, clientArgs, casesClient), }; return Object.freeze(casesSubClient); diff --git a/x-pack/plugins/cases/server/client/cases/create.test.ts b/x-pack/plugins/cases/server/client/cases/create.test.ts index 8b24c79c530b0..5a3eb7bd4f54f 100644 --- a/x-pack/plugins/cases/server/client/cases/create.test.ts +++ b/x-pack/plugins/cases/server/client/cases/create.test.ts @@ -197,6 +197,7 @@ describe('create', () => { status: CaseStatuses.open, category: null, customFields: [], + observables: [], }, id: expect.any(String), refresh: false, @@ -274,6 +275,7 @@ describe('create', () => { status: CaseStatuses.open, category: null, customFields: [], + observables: [], }, id: expect.any(String), refresh: false, @@ -353,6 +355,7 @@ describe('create', () => { status: CaseStatuses.open, category: null, customFields: [], + observables: [], }, id: expect.any(String), refresh: false, @@ -419,6 +422,7 @@ describe('create', () => { duration: null, status: CaseStatuses.open, customFields: [], + observables: [], }, id: expect.any(String), refresh: false, @@ -498,6 +502,7 @@ describe('create', () => { duration: null, status: CaseStatuses.open, customFields: theCustomFields, + observables: [], }, id: expect.any(String), refresh: false, @@ -526,6 +531,7 @@ describe('create', () => { { key: 'first_key', type: 'text', value: 'default value' }, { key: 'second_key', type: 'toggle', value: null }, ], + observables: [], }, id: expect.any(String), refresh: false, diff --git a/x-pack/plugins/cases/server/client/cases/observables.test.ts b/x-pack/plugins/cases/server/client/cases/observables.test.ts new file mode 100644 index 0000000000000..1d211615cab7a --- /dev/null +++ b/x-pack/plugins/cases/server/client/cases/observables.test.ts @@ -0,0 +1,228 @@ +/* + * 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 { addObservable, deleteObservable, updateObservable } from './observables'; +import Boom from '@hapi/boom'; +import { LICENSING_CASE_OBSERVABLES_FEATURE } from '../../common/constants'; +import { createCasesClientMock, createCasesClientMockArgs } from '../mocks'; +import { mockCases } from '../../mocks'; +import { OBSERVABLE_TYPE_IPV4 } from '../../../common/constants'; + +const caseSO = mockCases[0]; + +const mockCasesClient = createCasesClientMock(); +const mockClientArgs = createCasesClientMockArgs(); + +const mockLicensingService = mockClientArgs.services.licensingService; +const mockCaseService = mockClientArgs.services.caseService; + +const mockObservable = { + value: '127.0.0.1', + typeKey: OBSERVABLE_TYPE_IPV4.key, + id: '5c431380-c6ef-459f-b0fe-1699e978517b', + description: null, + createdAt: '2024-12-05', + updatedAt: '2024-12-05', +}; +const caseSOWithObservables = { + ...caseSO, + attributes: { + ...caseSO.attributes, + observables: [mockObservable], + }, +}; +describe('addObservable', () => { + beforeEach(() => { + mockCaseService.patchCase.mockResolvedValue(caseSO); + mockCaseService.getCase.mockResolvedValue(caseSO); + jest.clearAllMocks(); + }); + + it('should add an observable successfully', async () => { + mockLicensingService.isAtLeastPlatinum.mockResolvedValue(true); + const result = await addObservable( + 'case-id', + { observable: { typeKey: OBSERVABLE_TYPE_IPV4.key, value: '127.0.0.1', description: '' } }, + mockClientArgs, + mockCasesClient + ); + + expect(mockLicensingService.notifyUsage).toHaveBeenCalledWith( + LICENSING_CASE_OBSERVABLES_FEATURE + ); + expect(result).toBeDefined(); + }); + + it('should throw an error if license is not platinum', async () => { + mockLicensingService.isAtLeastPlatinum.mockResolvedValue(false); + + await expect( + addObservable( + 'case-id', + { observable: { typeKey: OBSERVABLE_TYPE_IPV4.key, value: '127.0.0.1', description: '' } }, + mockClientArgs, + mockCasesClient + ) + ).rejects.toThrow( + Boom.forbidden( + 'In order to assign observables to cases, you must be subscribed to an Elastic Platinum license' + ) + ); + }); + + it('should throw an error if observable type is invalid', async () => { + mockLicensingService.isAtLeastPlatinum.mockResolvedValue(true); + + await expect( + addObservable( + 'case-id', + { observable: { typeKey: 'invalid type', value: '127.0.0.1', description: '' } }, + mockClientArgs, + mockCasesClient + ) + ).rejects.toThrow( + Boom.badRequest( + 'Failed to add observable: Error: Invalid observable type, key does not exist: invalid type' + ) + ); + }); + + it('should throw an error if duplicate observable is posted', async () => { + mockLicensingService.isAtLeastPlatinum.mockResolvedValue(true); + + mockCaseService.getCase.mockResolvedValue(caseSOWithObservables); + + await expect( + addObservable( + 'case-id', + { observable: { typeKey: OBSERVABLE_TYPE_IPV4.key, value: '127.0.0.1', description: '' } }, + mockClientArgs, + mockCasesClient + ) + ).rejects.toThrow( + Boom.badRequest('Failed to add observable: Error: Invalid duplicated observables in request.') + ); + }); + + it('should handle errors and throw boom', async () => { + mockLicensingService.isAtLeastPlatinum.mockResolvedValue(true); + mockCaseService.getCase.mockRejectedValue(new Error('Case not found')); + + await expect( + addObservable( + 'case-id', + { observable: { typeKey: 'typeKey', value: 'test', description: '' } }, + mockClientArgs, + mockCasesClient + ) + ).rejects.toThrow(); + }); +}); + +describe('updateObservable', () => { + beforeEach(() => { + mockCaseService.patchCase.mockResolvedValue(caseSOWithObservables); + mockCaseService.getCase.mockResolvedValue(caseSOWithObservables); + jest.clearAllMocks(); + }); + + it('should update an observable successfully', async () => { + mockLicensingService.isAtLeastPlatinum.mockResolvedValue(true); + const result = await updateObservable( + 'case-id', + mockObservable.id, + { + observable: { + value: '192.168.0.1', + description: 'Updated description', + }, + }, + mockClientArgs, + mockCasesClient + ); + + expect(mockLicensingService.notifyUsage).toHaveBeenCalledWith( + LICENSING_CASE_OBSERVABLES_FEATURE + ); + expect(result).toBeDefined(); + }); + + it('should throw an error if license is not platinum', async () => { + mockLicensingService.isAtLeastPlatinum.mockResolvedValue(false); + + await expect( + updateObservable( + 'case-id', + 'observable-id', + { + observable: { + value: '192.168.0.1', + description: 'Updated description', + }, + }, + mockClientArgs, + mockCasesClient + ) + ).rejects.toThrow( + Boom.forbidden( + 'In order to update observables in cases, you must be subscribed to an Elastic Platinum license' + ) + ); + }); + + it('should handle errors and throw boom', async () => { + mockLicensingService.isAtLeastPlatinum.mockResolvedValue(true); + mockCaseService.getCase.mockRejectedValue(new Error('Case not found')); + + await expect( + updateObservable( + 'case-id', + 'observable-id', + { observable: { value: 'test', description: 'Updated description' } }, + mockClientArgs, + mockCasesClient + ) + ).rejects.toThrow(); + }); +}); + +describe('deleteObservable', () => { + beforeEach(() => { + mockCaseService.patchCase.mockResolvedValue(caseSOWithObservables); + mockCaseService.getCase.mockResolvedValue(caseSOWithObservables); + jest.clearAllMocks(); + }); + + it('should delete an observable successfully', async () => { + mockLicensingService.isAtLeastPlatinum.mockResolvedValue(true); + await deleteObservable('case-id', mockObservable.id, mockClientArgs, mockCasesClient); + + expect(mockLicensingService.notifyUsage).toHaveBeenCalledWith( + LICENSING_CASE_OBSERVABLES_FEATURE + ); + }); + + it('should throw an error if license is not platinum', async () => { + mockLicensingService.isAtLeastPlatinum.mockResolvedValue(false); + + await expect( + deleteObservable('case-id', 'observable-id', mockClientArgs, mockCasesClient) + ).rejects.toThrow( + Boom.forbidden( + 'In order to delete observables from cases, you must be subscribed to an Elastic Platinum license' + ) + ); + }); + + it('should handle errors and throw boom', async () => { + mockLicensingService.isAtLeastPlatinum.mockResolvedValue(true); + mockCaseService.getCase.mockRejectedValue(new Error('Case not found')); + + await expect( + deleteObservable('case-id', 'observable-id', mockClientArgs, mockCasesClient) + ).rejects.toThrow(); + }); +}); diff --git a/x-pack/plugins/cases/server/client/cases/observables.ts b/x-pack/plugins/cases/server/client/cases/observables.ts new file mode 100644 index 0000000000000..732dbd73e4a39 --- /dev/null +++ b/x-pack/plugins/cases/server/client/cases/observables.ts @@ -0,0 +1,235 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { PublicMethodsOf } from '@kbn/utility-types'; +import { v4 } from 'uuid'; +import Boom from '@hapi/boom'; + +import { MAX_OBSERVABLES_PER_CASE } from '../../../common/constants'; +import { CaseRt } from '../../../common/types/domain'; +import { + AddObservableRequestRt, + type AddObservableRequest, + type UpdateObservableRequest, + UpdateObservableRequestRt, +} from '../../../common/types/api'; +import type { CasesClient } from '../client'; +import type { CasesClientArgs } from '../types'; +import { decodeOrThrow, decodeWithExcessOrThrow } from '../../common/runtime_types'; +import type { Authorization } from '../../authorization'; +import { Operations } from '../../authorization'; +import type { CaseSavedObjectTransformed } from '../../common/types/case'; +import { flattenCaseSavedObject } from '../../common/utils'; +import { LICENSING_CASE_OBSERVABLES_FEATURE } from '../../common/constants'; +import { + validateDuplicatedObservablesInRequest, + validateObservableTypeKeyExists, +} from '../validators'; + +const ensureUpdateAuthorized = async ( + authorization: PublicMethodsOf, + theCase: CaseSavedObjectTransformed +) => { + return authorization.ensureAuthorized({ + operation: Operations.updateCase, + entities: [ + { + id: theCase.id, + owner: theCase.attributes.owner, + }, + ], + }); +}; + +export const addObservable = async ( + caseId: string, + params: AddObservableRequest, + clientArgs: CasesClientArgs, + casesClient: CasesClient +) => { + const { + services: { caseService, licensingService }, + authorization, + } = clientArgs; + + const hasPlatinumLicenseOrGreater = await licensingService.isAtLeastPlatinum(); + + if (!hasPlatinumLicenseOrGreater) { + throw Boom.forbidden( + 'In order to assign observables to cases, you must be subscribed to an Elastic Platinum license' + ); + } + + licensingService.notifyUsage(LICENSING_CASE_OBSERVABLES_FEATURE); + + try { + const paramArgs = decodeWithExcessOrThrow(AddObservableRequestRt)(params); + const retrievedCase = await caseService.getCase({ id: caseId }); + await ensureUpdateAuthorized(authorization, retrievedCase); + + await validateObservableTypeKeyExists(casesClient, { + caseOwner: retrievedCase.attributes.owner, + observableTypeKey: params.observable.typeKey, + }); + + const currentObservables = retrievedCase.attributes.observables ?? []; + + if (currentObservables.length === MAX_OBSERVABLES_PER_CASE) { + throw Boom.forbidden(`Max ${MAX_OBSERVABLES_PER_CASE} observables per case is allowed.`); + } + + const updatedObservables = [ + ...currentObservables, + { + ...paramArgs.observable, + id: v4(), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + ]; + + validateDuplicatedObservablesInRequest({ + requestFields: updatedObservables, + }); + + const updatedCase = await caseService.patchCase({ + caseId: retrievedCase.id, + originalCase: retrievedCase, + updatedAttributes: { + observables: updatedObservables, + }, + }); + + const res = flattenCaseSavedObject({ + savedObject: { + ...retrievedCase, + ...updatedCase, + attributes: { ...retrievedCase.attributes, ...updatedCase?.attributes }, + references: retrievedCase.references, + }, + }); + + return decodeOrThrow(CaseRt)(res); + } catch (error) { + throw Boom.badRequest(`Failed to add observable: ${error}`); + } +}; + +export const updateObservable = async ( + caseId: string, + observableId: string, + params: UpdateObservableRequest, + clientArgs: CasesClientArgs, + casesClient: CasesClient +) => { + const { + services: { caseService, licensingService }, + authorization, + } = clientArgs; + + const hasPlatinumLicenseOrGreater = await licensingService.isAtLeastPlatinum(); + + if (!hasPlatinumLicenseOrGreater) { + throw Boom.forbidden( + 'In order to update observables in cases, you must be subscribed to an Elastic Platinum license' + ); + } + + licensingService.notifyUsage(LICENSING_CASE_OBSERVABLES_FEATURE); + + try { + const paramArgs = decodeWithExcessOrThrow(UpdateObservableRequestRt)(params); + const retrievedCase = await caseService.getCase({ id: caseId }); + await ensureUpdateAuthorized(authorization, retrievedCase); + + const currentObservables = retrievedCase.attributes.observables ?? []; + + const observableIndex = currentObservables.findIndex( + (observable) => observable.id === observableId + ); + + if (observableIndex === -1) { + throw Boom.notFound(`Failed to update observable: observable id ${observableId} not found`); + } + + const updatedObservables = [...currentObservables]; + updatedObservables[observableIndex] = { + ...updatedObservables[observableIndex], + ...paramArgs.observable, + updatedAt: new Date().toISOString(), + }; + + validateDuplicatedObservablesInRequest({ + requestFields: updatedObservables, + }); + + const updatedCase = await caseService.patchCase({ + caseId: retrievedCase.id, + originalCase: retrievedCase, + updatedAttributes: { + observables: updatedObservables, + }, + }); + + const res = flattenCaseSavedObject({ + savedObject: { + ...retrievedCase, + ...updatedCase, + attributes: { ...retrievedCase.attributes, ...updatedCase?.attributes }, + references: retrievedCase.references, + }, + }); + + return decodeOrThrow(CaseRt)(res); + } catch (error) { + throw Boom.badRequest(`Failed to update observable: ${error}`); + } +}; + +export const deleteObservable = async ( + caseId: string, + observableId: string, + clientArgs: CasesClientArgs, + casesClient: CasesClient +) => { + const { + services: { caseService, licensingService }, + authorization, + } = clientArgs; + + const hasPlatinumLicenseOrGreater = await licensingService.isAtLeastPlatinum(); + + if (!hasPlatinumLicenseOrGreater) { + throw Boom.forbidden( + 'In order to delete observables from cases, you must be subscribed to an Elastic Platinum license' + ); + } + + licensingService.notifyUsage(LICENSING_CASE_OBSERVABLES_FEATURE); + + try { + const retrievedCase = await caseService.getCase({ id: caseId }); + await ensureUpdateAuthorized(authorization, retrievedCase); + + const updatedObservables = retrievedCase.attributes.observables.filter( + (observable) => observable.id !== observableId + ); + + // NOTE: same length of observables pre and post filter means that the observable id has not been found + if (updatedObservables.length === retrievedCase.attributes.observables.length) { + throw Boom.notFound(`Failed to delete observable: observable id ${observableId} not found`); + } + + await caseService.patchCase({ + caseId: retrievedCase.id, + originalCase: retrievedCase, + updatedAttributes: { observables: updatedObservables }, + }); + } catch (error) { + throw Boom.badRequest(`Failed to delete observable id: ${observableId}: ${error}`); + } +}; diff --git a/x-pack/plugins/cases/server/client/cases/similar.test.ts b/x-pack/plugins/cases/server/client/cases/similar.test.ts new file mode 100644 index 0000000000000..9ded5b9c4f987 --- /dev/null +++ b/x-pack/plugins/cases/server/client/cases/similar.test.ts @@ -0,0 +1,236 @@ +/* + * 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 { mockCases } from '../../mocks'; +import { createCasesClientMock, createCasesClientMockArgs } from '../mocks'; +import { similar } from './similar'; +import { mockCase } from '../../../public/containers/mock'; +import { OBSERVABLE_TYPE_IPV4 } from '../../../common/constants'; +import Boom from '@hapi/boom'; + +const mockClientArgs = createCasesClientMockArgs(); +const mockCasesClient = createCasesClientMock(); + +const mockLicensingService = mockClientArgs.services.licensingService; + +describe('similar', () => { + beforeEach(() => { + mockLicensingService.isAtLeastPlatinum.mockResolvedValue(true); + + jest.mocked(mockClientArgs.services.caseService.getCase).mockResolvedValue({ + ...mockCases[0], + attributes: { + ...mockCases[0].attributes, + observables: [ + { + id: 'ddfb207d-4b46-4545-bae8-5193c1551e50', + value: '127.0.0.1', + typeKey: OBSERVABLE_TYPE_IPV4.key, + createdAt: '2024-11-07', + updatedAt: '2024-11-07', + description: '', + }, + ], + }, + }); + + mockClientArgs.services.caseService.findCases.mockResolvedValue({ + page: 1, + per_page: 10, + total: mockCases.length, + saved_objects: [], + }); + + mockClientArgs.services.caseConfigureService.find.mockResolvedValue({ + saved_objects: [], + page: 1, + per_page: 10, + total: 0, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should execute query with observable type key and value and proper filters', async () => { + await similar( + mockCase.id, + { + page: 1, + perPage: 10, + }, + mockClientArgs, + mockCasesClient + ); + expect(mockClientArgs.services.caseService.findCases).toHaveBeenCalled(); + + const call = mockClientArgs.services.caseService.findCases.mock.calls[0][0]; + + expect(call).toMatchInlineSnapshot(` + Object { + "filter": Object { + "arguments": Array [ + Object { + "arguments": Array [ + Object { + "isQuoted": false, + "type": "literal", + "value": "cases.attributes.observables", + }, + Object { + "arguments": Array [ + Object { + "arguments": Array [ + Object { + "isQuoted": false, + "type": "literal", + "value": "value", + }, + Object { + "isQuoted": true, + "type": "literal", + "value": "127.0.0.1", + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "isQuoted": false, + "type": "literal", + "value": "typeKey", + }, + Object { + "isQuoted": true, + "type": "literal", + "value": "observable-type-ipv4", + }, + ], + "function": "is", + "type": "function", + }, + ], + "function": "and", + "type": "function", + }, + ], + "function": "nested", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "isQuoted": false, + "type": "literal", + "value": "cases.attributes.owner", + }, + Object { + "isQuoted": false, + "type": "literal", + "value": "securitySolution", + }, + ], + "function": "is", + "type": "function", + }, + ], + "function": "and", + "type": "function", + }, + "page": 1, + "perPage": 10, + "rootSearchFields": Array [ + "_id", + ], + "search": "-\\"cases:mock-id\\"", + "sortField": "created_at", + } + `); + }); + + it('should throw an error if license is not platinum', async () => { + mockLicensingService.isAtLeastPlatinum.mockResolvedValue(false); + + await expect( + similar( + mockCase.id, + { + page: 1, + perPage: 10, + }, + mockClientArgs, + mockCasesClient + ) + ).rejects.toThrow( + Boom.forbidden( + 'In order to use the similar cases feature, you must be subscribed to an Elastic Platinum license' + ) + ); + }); + + it('should not call findCases when the case has no observables', async () => { + jest.mocked(mockClientArgs.services.caseService.getCase).mockResolvedValue({ + ...mockCases[0], + attributes: { + ...mockCases[0].attributes, + observables: [], + }, + }); + + await similar( + mockCase.id, + { + page: 1, + perPage: 10, + }, + mockClientArgs, + mockCasesClient + ); + expect(mockClientArgs.services.caseService.findCases).not.toHaveBeenCalled(); + }); + + it('should not call findCases when unknown typeKey is specified for an observable', async () => { + jest.mocked(mockClientArgs.services.caseService.getCase).mockResolvedValue({ + ...mockCases[0], + attributes: { + ...mockCases[0].attributes, + observables: [ + { + id: '4491eedc-2336-41e3-bf98-29147c133b95', + typeKey: 'unknown', + value: 'some value', + createdAt: '2024-12-16', + updatedAt: '2024-12-16', + description: null, + }, + { + id: 'e7d3f99d-c8be-41df-ada0-640021571bd4', + typeKey: 'unknown', + value: 'some value', + createdAt: '2024-12-16', + updatedAt: '2024-12-16', + description: null, + }, + ], + }, + }); + + await similar( + mockCase.id, + { + page: 1, + perPage: 10, + }, + mockClientArgs, + mockCasesClient + ); + expect(mockClientArgs.services.caseService.findCases).not.toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/cases/server/client/cases/similar.ts b/x-pack/plugins/cases/server/client/cases/similar.ts new file mode 100644 index 0000000000000..daca8d1e6b573 --- /dev/null +++ b/x-pack/plugins/cases/server/client/cases/similar.ts @@ -0,0 +1,163 @@ +/* + * 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 { intersection } from 'lodash'; +import Boom from '@hapi/boom'; +import { OWNER_FIELD } from '../../../common/constants'; +import type { CasesSimilarResponse, SimilarCasesSearchRequest } from '../../../common/types/api'; +import { SimilarCasesSearchRequestRt, CasesSimilarResponseRt } from '../../../common/types/api'; +import { decodeWithExcessOrThrow, decodeOrThrow } from '../../common/runtime_types'; + +import { createCaseError } from '../../common/error'; +import type { CasesClient, CasesClientArgs } from '..'; +import { defaultSortField, flattenCaseSavedObject } from '../../common/utils'; +import { Operations } from '../../authorization'; +import { buildFilter, buildObservablesFieldsFilter, combineFilters } from '../utils'; +import { combineFilterWithAuthorizationFilter } from '../../authorization/utils'; +import type { CaseSavedObjectTransformed } from '../../common/types/case'; +import { getAvailableObservableTypesSet } from '../observable_types'; + +interface Similarity { + typeKey: string; + value: string; +} + +const getSimilarities = ( + a: CaseSavedObjectTransformed, + b: CaseSavedObjectTransformed, + availableObservableTypes: Set +): Similarity[] => { + const stringify = (observable: { typeKey: string; value: string }) => + [observable.typeKey, observable.value].join(','); + + const setA = new Set(a.attributes.observables.map(stringify)); + const setB = new Set(b.attributes.observables.map(stringify)); + + const intersectingObservables: string[] = intersection([...setA], [...setB]); + + return intersectingObservables + .map((item) => { + const [typeKey, value] = item.split(','); + + return { + typeKey, + value, + }; + }) + .filter((observable) => availableObservableTypes.has(observable.typeKey)); +}; + +/** + * Retrieves cases similar to a given Case + */ +export const similar = async ( + caseId: string, + params: SimilarCasesSearchRequest, + clientArgs: CasesClientArgs, + casesClient: CasesClient +): Promise => { + const { + services: { caseService, licensingService }, + logger, + authorization, + } = clientArgs; + + const hasPlatinumLicenseOrGreater = await licensingService.isAtLeastPlatinum(); + + if (!hasPlatinumLicenseOrGreater) { + throw Boom.forbidden( + 'In order to use the similar cases feature, you must be subscribed to an Elastic Platinum license' + ); + } + + try { + const paramArgs = decodeWithExcessOrThrow(SimilarCasesSearchRequestRt)(params); + const retrievedCase = await caseService.getCase({ id: caseId }); + + const availableObservableTypesSet = await getAvailableObservableTypesSet( + casesClient, + retrievedCase.attributes.owner + ); + + const ownerFilter = buildFilter({ + filters: retrievedCase.attributes.owner, + field: OWNER_FIELD, + operator: 'or', + }); + + const { filter: authorizationFilter, ensureSavedObjectsAreAuthorized } = + await authorization.getAuthorizationFilter(Operations.findCases); + + const similarCasesFilter = buildObservablesFieldsFilter( + retrievedCase.attributes.observables.reduce((observableMap, observable) => { + // NOTE: skip non-existent observable types + if (!availableObservableTypesSet.has(observable.typeKey)) { + return observableMap; + } + + if (!observableMap[observable.typeKey]) { + observableMap[observable.typeKey] = []; + } + + observableMap[observable.typeKey].push(observable.value); + + return observableMap; + }, {} as Record) + ); + + // NOTE: empty similar cases filter means that we are unable to show similar cases + // and should not combine it with general filters below. + if (!similarCasesFilter) { + return { + cases: [], + page: 1, + per_page: paramArgs.perPage ?? 0, + total: 0, + }; + } + + const filters = combineFilters([similarCasesFilter, ownerFilter]); + + const finalCasesFilter = combineFilterWithAuthorizationFilter(filters, authorizationFilter); + + const cases = await caseService.findCases({ + filter: finalCasesFilter, + sortField: defaultSortField, + search: `-"cases:${caseId}"`, + rootSearchFields: ['_id'], + page: paramArgs.page, + perPage: paramArgs.perPage, + }); + + ensureSavedObjectsAreAuthorized( + cases.saved_objects.map((caseSavedObject) => ({ + id: caseSavedObject.id, + owner: caseSavedObject.attributes.owner, + })) + ); + + const res = { + cases: cases.saved_objects.map((so) => ({ + ...flattenCaseSavedObject({ savedObject: so }), + similarities: { + observables: getSimilarities(retrievedCase, so, availableObservableTypesSet), + }, + })), + page: cases.page, + per_page: cases.per_page, + total: cases.total, + }; + + return decodeOrThrow(CasesSimilarResponseRt)(res); + } catch (error) { + throw createCaseError({ + message: `Failed to find cases: ${JSON.stringify(params)}: ${error}`, + error, + logger, + }); + } +}; diff --git a/x-pack/plugins/cases/server/client/configure/client.test.ts b/x-pack/plugins/cases/server/client/configure/client.test.ts index aab8937591f9e..323d872d4b10e 100644 --- a/x-pack/plugins/cases/server/client/configure/client.test.ts +++ b/x-pack/plugins/cases/server/client/configure/client.test.ts @@ -16,6 +16,7 @@ import { MAX_CUSTOM_FIELDS_PER_CASE, MAX_SUPPORTED_CONNECTORS_RETURNED, MAX_TEMPLATES_LENGTH, + OBSERVABLE_TYPE_IPV4, } from '../../../common/constants'; import { ConnectorTypes } from '../../../common'; import type { TemplatesConfiguration } from '../../../common/types/domain'; @@ -403,6 +404,7 @@ describe('client', () => { email: 'testemail@elastic.co', username: 'elastic', }, + observableTypes: [], }, }); @@ -463,6 +465,7 @@ describe('client', () => { }, }, ], + observableTypes: [], }, version: 'test-version', }); @@ -474,6 +477,7 @@ describe('client', () => { namespaces: ['default'], references: [], attributes: { + observableTypes: [], templates: [], created_at: '2019-11-25T21:54:48.952Z', created_by: { @@ -1063,6 +1067,7 @@ describe('client', () => { ], closure_type: 'close-by-user', owner: 'cases', + observableTypes: [], }, id: 'test-id', version: 'test-version', @@ -1130,6 +1135,7 @@ describe('client', () => { name: 'template 1', }, ], + observableTypes: [], }, id: 'test-id', version: 'test-version', @@ -1197,6 +1203,31 @@ describe('client', () => { ); }); }); + + describe('observableTypes', () => { + it('throws when trying to set duplicate observableTypes', async () => { + clientArgs.services.licensingService.isAtLeastPlatinum.mockResolvedValue(true); + + await expect( + update( + 'test-id', + { + version: 'test-version', + observableTypes: [ + { + key: 'e638af17-ebb6-4678-a937-b734bffee36a', + label: OBSERVABLE_TYPE_IPV4.label, + }, + ], + }, + clientArgs, + casesClientInternal + ) + ).rejects.toThrow( + 'Failed to get patch configure in route: Error: Invalid duplicated observable types in request: ipv4' + ); + }); + }); }); }); @@ -1363,6 +1394,7 @@ describe('client', () => { }, updated_at: null, updated_by: null, + observableTypes: [], }, score: 0, }, @@ -1388,6 +1420,7 @@ describe('client', () => { }, updated_at: null, updated_by: null, + observableTypes: [], }, }); @@ -1576,6 +1609,30 @@ describe('client', () => { ); }); }); + + describe('observableTypes', () => { + it('throws when trying to set duplicate observableTypes', async () => { + clientArgs.services.licensingService.isAtLeastPlatinum.mockResolvedValue(true); + + await expect( + create( + { + ...baseRequest, + observableTypes: [ + { + key: 'e638af17-ebb6-4678-a937-b734bffee36a', + label: OBSERVABLE_TYPE_IPV4.label, + }, + ], + }, + clientArgs, + casesClientInternal + ) + ).rejects.toThrow( + 'Failed to create case configuration: Error: Invalid duplicated observable types in request: ipv4' + ); + }); + }); }); }); }); diff --git a/x-pack/plugins/cases/server/client/configure/client.ts b/x-pack/plugins/cases/server/client/configure/client.ts index 00810cb742323..4b4800f3c8657 100644 --- a/x-pack/plugins/cases/server/client/configure/client.ts +++ b/x-pack/plugins/cases/server/client/configure/client.ts @@ -49,7 +49,10 @@ import type { MappingsArgs, CreateMappingsArgs, UpdateMappingsArgs } from './typ import { createMappings } from './create_mappings'; import { updateMappings } from './update_mappings'; import { ConfigurationRt, ConfigurationsRt } from '../../../common/types/domain'; -import { validateDuplicatedKeysInRequest } from '../validators'; +import { + validateDuplicatedKeysInRequest, + validateDuplicatedObservableTypesInRequest, +} from '../validators'; import { validateCustomFieldTypesInRequest, validateTemplatesCustomFieldsInRequest, @@ -308,6 +311,10 @@ export async function update( fieldName: 'customFields', }); + validateDuplicatedObservableTypesInRequest({ + requestFields: request.observableTypes, + }); + const { version, templates, ...queryWithoutVersion } = request; const configuration = await caseConfigureService.get({ @@ -442,6 +449,10 @@ export async function create( customFields: validatedConfigurationRequest.customFields, }); + validateDuplicatedObservableTypesInRequest({ + requestFields: validatedConfigurationRequest.observableTypes, + }); + let error = null; const { filter: authorizationFilter, ensureSavedObjectsAreAuthorized } = @@ -521,6 +532,7 @@ export async function create( created_by: user, updated_at: null, updated_by: null, + observableTypes: validatedConfigurationRequest.observableTypes ?? [], }, id: savedObjectID, }); diff --git a/x-pack/plugins/cases/server/client/mocks.ts b/x-pack/plugins/cases/server/client/mocks.ts index 50dca1920b625..f305fbec6d536 100644 --- a/x-pack/plugins/cases/server/client/mocks.ts +++ b/x-pack/plugins/cases/server/client/mocks.ts @@ -72,6 +72,10 @@ const createCasesSubClientMock = (): CasesSubClientMock => { getCasesByAlertID: jest.fn(), getCategories: jest.fn(), replaceCustomField: jest.fn(), + similar: jest.fn(), + addObservable: jest.fn(), + updateObservable: jest.fn(), + deleteObservable: jest.fn(), }; }; diff --git a/x-pack/plugins/cases/server/client/observable_types.test.ts b/x-pack/plugins/cases/server/client/observable_types.test.ts new file mode 100644 index 0000000000000..d5ba5d21cbe29 --- /dev/null +++ b/x-pack/plugins/cases/server/client/observable_types.test.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Configurations } from '../../common/types/domain/configure/v1'; +import { OBSERVABLE_TYPES_BUILTIN_KEYS } from '../../common/constants'; +import { createCasesClientMock } from './mocks'; +import { getAvailableObservableTypesSet } from './observable_types'; + +const mockCasesClient = createCasesClientMock(); + +describe('getAvailableObservableTypesSet', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return a set of available observable types', async () => { + const mockObservableTypes = [ + { key: 'type1', label: 'test 1' }, + { key: 'type2', label: 'test 2' }, + ]; + + jest.mocked(mockCasesClient.configure.get).mockResolvedValue([ + { + observableTypes: mockObservableTypes, + }, + ] as unknown as Configurations); + + const result = await getAvailableObservableTypesSet(mockCasesClient, 'mock-owner'); + + expect(result).toEqual(new Set(['type1', 'type2', ...OBSERVABLE_TYPES_BUILTIN_KEYS])); + }); + + it('should return only built-in observable types if no types are configured', async () => { + jest.mocked(mockCasesClient.configure.get).mockResolvedValue([ + { + observableTypes: [], + }, + ] as unknown as Configurations); + + const result = await getAvailableObservableTypesSet(mockCasesClient, 'mock-owner'); + + expect(result).toEqual(new Set(OBSERVABLE_TYPES_BUILTIN_KEYS)); + }); + + it('should handle errors and return an empty set', async () => { + jest + .mocked(mockCasesClient.configure.get) + .mockRejectedValue(new Error('Failed to fetch configuration')); + + const result = await getAvailableObservableTypesSet(mockCasesClient, 'mock-owner'); + + expect(result).toEqual(new Set()); + }); +}); diff --git a/x-pack/plugins/cases/server/client/observable_types.ts b/x-pack/plugins/cases/server/client/observable_types.ts new file mode 100644 index 0000000000000..a6183fc6833b5 --- /dev/null +++ b/x-pack/plugins/cases/server/client/observable_types.ts @@ -0,0 +1,26 @@ +/* + * 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 { OBSERVABLE_TYPES_BUILTIN } from '../../common/constants'; +import type { CasesClient } from './client'; + +export const getAvailableObservableTypesSet = async (casesClient: CasesClient, owner: string) => { + try { + const configurations = await casesClient.configure.get({ + owner, + }); + const observableTypes = configurations?.[0]?.observableTypes ?? []; + + const availableObservableTypesSet = new Set( + [...observableTypes, ...OBSERVABLE_TYPES_BUILTIN].map(({ key }) => key) + ); + + return availableObservableTypesSet; + } catch (error) { + return new Set(); + } +}; diff --git a/x-pack/plugins/cases/server/client/utils.test.ts b/x-pack/plugins/cases/server/client/utils.test.ts index 7b5d692f6aa79..fbb269e20db70 100644 --- a/x-pack/plugins/cases/server/client/utils.test.ts +++ b/x-pack/plugins/cases/server/client/utils.test.ts @@ -5,8 +5,6 @@ * 2.0. */ -import { v1 as uuidv1 } from 'uuid'; - import { DEFAULT_NAMESPACE_STRING } from '@kbn/core-saved-objects-utils-server'; import type { KueryNode } from '@kbn/es-query'; import { toElasticsearchQuery, toKqlExpression } from '@kbn/es-query'; @@ -16,6 +14,7 @@ import { arraysDifference, buildAttachmentRequestFromFileJSON, buildFilter, + buildObservablesFieldsFilter, buildRangeFilter, constructQueryOptions, constructSearch, @@ -497,24 +496,14 @@ describe('utils', () => { [CaseStatuses['in-progress'], CasePersistedStatus.IN_PROGRESS], [CaseStatuses.closed, CasePersistedStatus.CLOSED], ])('creates a filter for status "%s"', (status, expectedStatus) => { - expect(constructQueryOptions({ status }).filter).toMatchInlineSnapshot(` - Object { - "arguments": Array [ - Object { - "isQuoted": false, - "type": "literal", - "value": "cases.attributes.status", - }, - Object { - "isQuoted": false, - "type": "literal", - "value": "${expectedStatus}", - }, - ], - "function": "is", - "type": "function", - } - `); + expect(constructQueryOptions({ status }).filter).toMatchObject({ + arguments: [ + { isQuoted: false, type: 'literal', value: 'cases.attributes.status' }, + { isQuoted: false, type: 'literal', value: `${expectedStatus}` }, + ], + function: 'is', + type: 'function', + }); }); it('should create a filter for multiple status values', () => { @@ -567,24 +556,14 @@ describe('utils', () => { [CaseSeverity.HIGH, CasePersistedSeverity.HIGH], [CaseSeverity.CRITICAL, CasePersistedSeverity.CRITICAL], ])('creates a filter for severity "%s"', (severity, expectedSeverity) => { - expect(constructQueryOptions({ severity }).filter).toMatchInlineSnapshot(` - Object { - "arguments": Array [ - Object { - "isQuoted": false, - "type": "literal", - "value": "cases.attributes.severity", - }, - Object { - "isQuoted": false, - "type": "literal", - "value": "${expectedSeverity}", - }, - ], - "function": "is", - "type": "function", - } - `); + expect(constructQueryOptions({ severity }).filter).toMatchObject({ + arguments: [ + { isQuoted: false, type: 'literal', value: 'cases.attributes.severity' }, + { isQuoted: false, type: 'literal', value: `${expectedSeverity}` }, + ], + function: 'is', + type: 'function', + }); }); it('should create a filter for multiple severity values', () => { @@ -1106,7 +1085,7 @@ describe('utils', () => { const savedObjectsSerializer = createSavedObjectsSerializerMock(); it('returns the rootSearchFields and search with correct values when given a uuid', () => { - const uuid = uuidv1(); // the specific version is irrelevant + const uuid = 'b52e293e-4a37-4e67-9aa6-716bb6e69b42'; // the specific version is irrelevant expect(constructSearch(uuid, DEFAULT_NAMESPACE_STRING, savedObjectsSerializer)) .toMatchInlineSnapshot(` @@ -1114,7 +1093,7 @@ describe('utils', () => { "rootSearchFields": Array [ "_id", ], - "search": "\\"${uuid}\\" \\"cases:${uuid}\\"", + "search": "\\"b52e293e-4a37-4e67-9aa6-716bb6e69b42\\" \\"cases:b52e293e-4a37-4e67-9aa6-716bb6e69b42\\"", } `); }); @@ -1579,6 +1558,62 @@ describe('utils', () => { }); }); + describe('buildObservablesFieldsFilter', () => { + it('builds the filter escaping quotes in the value', () => { + expect(buildObservablesFieldsFilter({ type: ['{"json":"value"}'] })).toMatchInlineSnapshot(` + Object { + "arguments": Array [ + Object { + "isQuoted": false, + "type": "literal", + "value": "cases.attributes.observables", + }, + Object { + "arguments": Array [ + Object { + "arguments": Array [ + Object { + "isQuoted": false, + "type": "literal", + "value": "value", + }, + Object { + "isQuoted": true, + "type": "literal", + "value": "{\\"json\\":\\"value\\"}", + }, + ], + "function": "is", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "isQuoted": false, + "type": "literal", + "value": "typeKey", + }, + Object { + "isQuoted": true, + "type": "literal", + "value": "type", + }, + ], + "function": "is", + "type": "function", + }, + ], + "function": "and", + "type": "function", + }, + ], + "function": "nested", + "type": "function", + } + `); + }); + }); + describe('buildAttachmentRequestFromFileJSON', () => { it('builds attachment request correctly', () => { expect( diff --git a/x-pack/plugins/cases/server/client/utils.ts b/x-pack/plugins/cases/server/client/utils.ts index 6447b53abd00a..80c2339551b1e 100644 --- a/x-pack/plugins/cases/server/client/utils.ts +++ b/x-pack/plugins/cases/server/client/utils.ts @@ -16,8 +16,10 @@ import type { KueryNode } from '@kbn/es-query'; import { nodeBuilder, fromKueryExpression, escapeKuery } from '@kbn/es-query'; import { spaceIdToNamespace } from '@kbn/spaces-plugin/server/lib/utils/namespace'; +import { escapeQuotes } from '@kbn/es-query/src/kuery/utils/escape_kuery'; import type { FileJSON } from '@kbn/shared-ux-file-types'; import { FILE_SO_TYPE } from '@kbn/files-plugin/common/constants'; + import type { CaseCustomField, CaseSeverity, @@ -665,6 +667,26 @@ export const transformTemplateCustomFields = ({ }); }; +export const buildObservablesFieldsFilter = (observables: Record) => { + // NOTE: empty observables mean that we should not construct the filter and it should lead + // to early return in the calling context (it is required). + if (!Object.keys(observables).length) { + return; + } + + const filterExpressions = Object.keys(observables).flatMap((typeKey) => { + return Object.values(observables[typeKey]).map((observableValue) => { + return fromKueryExpression( + `cases.attributes.observables:{value: "${escapeQuotes( + observableValue + )}" AND typeKey: "${typeKey}"}` + ); + }); + }); + + return nodeBuilder.or(filterExpressions); +}; + export const buildAttachmentRequestFromFileJSON = ({ owner, fileMetadata, diff --git a/x-pack/plugins/cases/server/client/validators.test.ts b/x-pack/plugins/cases/server/client/validators.test.ts index 77867aedbcb4a..869c32cae06e8 100644 --- a/x-pack/plugins/cases/server/client/validators.test.ts +++ b/x-pack/plugins/cases/server/client/validators.test.ts @@ -5,7 +5,14 @@ * 2.0. */ -import { validateDuplicatedKeysInRequest } from './validators'; +import { OBSERVABLE_TYPE_IPV4 } from '../../common/constants'; +import { createCasesClientMock } from './mocks'; +import { + validateDuplicatedKeysInRequest, + validateDuplicatedObservableTypesInRequest, + validateDuplicatedObservablesInRequest, + validateObservableTypeKeyExists, +} from './validators'; describe('validators', () => { describe('validateDuplicatedKeysInRequest', () => { @@ -53,4 +60,164 @@ describe('validators', () => { ).not.toThrow(); }); }); + + describe('validateDuplicatedObservableTypesInRequest', () => { + it('returns fields in request that have duplicated observable types (by labels)', () => { + expect(() => + validateDuplicatedObservableTypesInRequest({ + requestFields: [ + { + label: 'triplicated_label', + key: '3aa53239-a608-4ccd-a69f-cb7d08d0b5cb', + }, + { + label: 'triplicated_label', + key: 'a71629ae-05eb-48d5-a669-bb9f3eec81b6', + }, + { + label: 'triplicated_label', + key: 'd5ff16a2-ead3-4f1d-b888-39376bfad8f2', + }, + { + label: 'duplicated_label', + key: '9774be21-abc7-4aa4-9443-86636fea40bc', + }, + { + label: 'duplicated_label', + key: 'fb638551-3b76-4bd9-8b45-7a86ddcb3b80', + }, + ], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"Invalid duplicated observable types in request: triplicated_label,duplicated_label"` + ); + }); + + it('returns fields in request that have duplicated observable types (by keys)', () => { + expect(() => + validateDuplicatedObservableTypesInRequest({ + requestFields: [ + { + label: 'a', + key: 'triplicated_key', + }, + { + label: 'b', + key: 'triplicated_key', + }, + { + label: 'c', + key: 'triplicated_key', + }, + { + label: 'd', + key: 'duplicated_key', + }, + { + label: 'e', + key: 'duplicated_key', + }, + ], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"Invalid duplicated observable types in request: b,c,e"` + ); + }); + + it('does not throw if no fields in request have duplicated observable types', () => { + expect(() => + validateDuplicatedObservableTypesInRequest({ + requestFields: [ + { + label: '1', + key: '1', + }, + { + label: '2', + key: '2', + }, + ], + }) + ).not.toThrow(); + }); + + it('does throw if the provided label duplicates builtin type', () => { + expect(() => + validateDuplicatedObservableTypesInRequest({ + requestFields: [ + { + label: 'email', + key: 'email', + }, + ], + }) + ).toThrowErrorMatchingInlineSnapshot( + `"Invalid duplicated observable types in request: email"` + ); + }); + }); + + describe('validateDuplicatedObservablesInRequest', () => { + it('returns observables in request that have duplicated labels', () => { + expect(() => + validateDuplicatedObservablesInRequest({ + requestFields: [ + { + value: 'value', + typeKey: 'typeKey', + }, + { + value: 'value', + typeKey: 'typeKey', + }, + ], + }) + ).toThrowErrorMatchingInlineSnapshot(`"Invalid duplicated observables in request."`); + }); + + it('does not throw if no fields in request have duplicated observables', () => { + expect(() => + validateDuplicatedObservablesInRequest({ + requestFields: [ + { + value: 'value', + typeKey: 'typeKey', + }, + { + value: 'value 1', + typeKey: 'typeKey', + }, + { + value: 'value', + typeKey: 'typeKey 2', + }, + ], + }) + ).not.toThrow(); + }); + }); + + describe('validateObservableTypeKeyExists', () => { + const mockCasesClient = createCasesClientMock(); + + it('does not throw if all observable type keys exist', async () => { + await expect( + validateObservableTypeKeyExists(mockCasesClient, { + caseOwner: 'securityFixture', + observableTypeKey: OBSERVABLE_TYPE_IPV4.key, + }) + ).resolves.not.toThrow(); + }); + + it('throws an error if any observable type key does not exist', async () => { + await expect(() => + validateObservableTypeKeyExists(mockCasesClient, { + caseOwner: 'securityFixture', + observableTypeKey: 'random key', + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Invalid observable type, key does not exist: random key"` + ); + }); + }); }); diff --git a/x-pack/plugins/cases/server/client/validators.ts b/x-pack/plugins/cases/server/client/validators.ts index 24527ac81155b..23f26d2321e78 100644 --- a/x-pack/plugins/cases/server/client/validators.ts +++ b/x-pack/plugins/cases/server/client/validators.ts @@ -6,6 +6,9 @@ */ import Boom from '@hapi/boom'; +import { OBSERVABLE_TYPES_BUILTIN } from '../../common/constants'; +import { type CasesClient } from './client'; +import { getAvailableObservableTypesSet } from './observable_types'; /** * Throws an error if the request has custom fields with duplicated keys. @@ -34,3 +37,93 @@ export const validateDuplicatedKeysInRequest = ({ ); } }; + +/** + * Throws an error if the request has observable types with duplicated labels. + */ +export const validateDuplicatedObservableTypesInRequest = ({ + requestFields = [], +}: { + requestFields?: Array<{ label: string; key: string }>; +}) => { + const extractLabelFromItem = (item: { label: string }) => item.label.toLowerCase(); + const extractKeyFromItem = (item: { key: string }) => item.key.toLowerCase(); + + // NOTE: this prevents adding duplicates for the builtin types + const builtinLabels = OBSERVABLE_TYPES_BUILTIN.map(extractLabelFromItem); + const builtinKeys = OBSERVABLE_TYPES_BUILTIN.map(extractKeyFromItem); + + const uniqueLabels = new Set(builtinLabels); + const uniqueKeys = new Set(builtinKeys); + + const duplicatedLabels = new Set(); + + requestFields.forEach((item) => { + const observableTypeLabel = extractLabelFromItem(item); + const observableTypeKey = extractKeyFromItem(item); + + if (uniqueKeys.has(observableTypeKey)) { + duplicatedLabels.add(observableTypeLabel); + } else { + uniqueKeys.add(observableTypeKey); + } + + if (uniqueLabels.has(observableTypeLabel)) { + duplicatedLabels.add(observableTypeLabel); + } else { + uniqueLabels.add(observableTypeLabel); + } + }); + + if (duplicatedLabels.size > 0) { + throw Boom.badRequest( + `Invalid duplicated observable types in request: ${Array.from(duplicatedLabels.values())}` + ); + } +}; + +/** + * Throws an error if the request has observable types with duplicated labels. + */ +export const validateDuplicatedObservablesInRequest = ({ + requestFields = [], +}: { + requestFields?: Array<{ typeKey: string; value: string }>; +}) => { + const stringifyItem = (item: { value: string; typeKey: string }) => + [item.typeKey, item.value].join(); + + const uniqueObservables = new Set(); + const duplicatedObservables = new Set(); + + requestFields.forEach((item) => { + if (uniqueObservables.has(stringifyItem(item))) { + duplicatedObservables.add(stringifyItem(item)); + } else { + uniqueObservables.add(stringifyItem(item)); + } + }); + + if (duplicatedObservables.size > 0) { + throw Boom.badRequest(`Invalid duplicated observables in request.`); + } +}; + +/** + * Throws an error if observable type key is not valid + */ +export const validateObservableTypeKeyExists = async ( + casesClient: CasesClient, + { + caseOwner, + observableTypeKey, + }: { + caseOwner: string; + observableTypeKey: string; + } +) => { + const observableTypesSet = await getAvailableObservableTypesSet(casesClient, caseOwner); + if (!observableTypesSet.has(observableTypeKey)) { + throw Boom.badRequest(`Invalid observable type, key does not exist: ${observableTypeKey}`); + } +}; diff --git a/x-pack/plugins/cases/server/common/constants.ts b/x-pack/plugins/cases/server/common/constants.ts index e7f2ba1e3ff5b..a57f15e346a78 100644 --- a/x-pack/plugins/cases/server/common/constants.ts +++ b/x-pack/plugins/cases/server/common/constants.ts @@ -38,7 +38,12 @@ export const EXTERNAL_REFERENCE_REF_NAME = 'externalReferenceId'; /** * The name of the licensing feature to notify for feature usage with the licensing plugin */ -export const LICENSING_CASE_ASSIGNMENT_FEATURE = 'Cases user assignment'; +export const LICENSING_CASE_ASSIGNMENT_FEATURE = 'Cases user usage'; + +/** + * The name of the licensing feature to notify for cases feature usage with the licensing plugin + */ +export const LICENSING_CASE_OBSERVABLES_FEATURE = 'Cases observable assignment'; export const SEVERITY_EXTERNAL_TO_ESMODEL: Record = { [CaseSeverity.LOW]: CasePersistedSeverity.LOW, diff --git a/x-pack/plugins/cases/server/common/types/case.test.ts b/x-pack/plugins/cases/server/common/types/case.test.ts index ed7356546e56d..cec16b9293be7 100644 --- a/x-pack/plugins/cases/server/common/types/case.test.ts +++ b/x-pack/plugins/cases/server/common/types/case.test.ts @@ -50,6 +50,7 @@ describe('case types', () => { }, owner: SECURITY_SOLUTION_OWNER, assignees: [], + observables: [], }; const caseTransformedAttributesProps = CaseTransformedAttributesRt.types.reduce( (acc, type) => ({ ...acc, ...type.type.props }), diff --git a/x-pack/plugins/cases/server/common/types/case.ts b/x-pack/plugins/cases/server/common/types/case.ts index 9a9a0e79104e7..b0d82af762438 100644 --- a/x-pack/plugins/cases/server/common/types/case.ts +++ b/x-pack/plugins/cases/server/common/types/case.ts @@ -8,7 +8,7 @@ import type { SavedObject } from '@kbn/core-saved-objects-server'; import type { Type } from 'io-ts'; import { exact, partial, strict, string } from 'io-ts'; -import type { CaseAttributes } from '../../../common/types/domain'; +import type { CaseAttributes, Observable } from '../../../common/types/domain'; import { CaseAttributesRt } from '../../../common/types/domain'; import type { ConnectorPersisted } from './connectors'; import type { ExternalServicePersisted } from './external_service'; @@ -49,6 +49,7 @@ export interface CasePersistedAttributes { updated_by: User | null; category?: string | null; customFields?: CasePersistedCustomFields; + observables?: Observable[]; } type CasePersistedCustomFields = Array<{ diff --git a/x-pack/plugins/cases/server/common/types/configure.ts b/x-pack/plugins/cases/server/common/types/configure.ts index 27e66ba76eb02..630b4020634f5 100644 --- a/x-pack/plugins/cases/server/common/types/configure.ts +++ b/x-pack/plugins/cases/server/common/types/configure.ts @@ -32,8 +32,14 @@ export interface ConfigurationPersistedAttributes { updated_by: User | null; customFields?: PersistedCustomFieldsConfiguration; templates?: PersistedTemplatesConfiguration; + observableTypes?: PersistedObservableTypesConfiguration; } +type PersistedObservableTypesConfiguration = Array<{ + key: string; + label: string; +}>; + type PersistedCustomFieldsConfiguration = Array<{ key: string; type: string; diff --git a/x-pack/plugins/cases/server/common/utils.test.ts b/x-pack/plugins/cases/server/common/utils.test.ts index a12146b30b193..15bcceafc256e 100644 --- a/x-pack/plugins/cases/server/common/utils.test.ts +++ b/x-pack/plugins/cases/server/common/utils.test.ts @@ -150,6 +150,7 @@ describe('common utils', () => { "description": "A description", "duration": null, "external_service": null, + "observables": Array [], "owner": "securitySolution", "settings": Object { "syncAlerts": true, @@ -205,6 +206,7 @@ describe('common utils', () => { "description": "A description", "duration": null, "external_service": null, + "observables": Array [], "owner": "securitySolution", "settings": Object { "syncAlerts": true, @@ -264,6 +266,7 @@ describe('common utils', () => { "description": "A description", "duration": null, "external_service": null, + "observables": Array [], "owner": "securitySolution", "settings": Object { "syncAlerts": true, @@ -329,6 +332,7 @@ describe('common utils', () => { "description": "A description", "duration": null, "external_service": null, + "observables": Array [], "owner": "securitySolution", "settings": Object { "syncAlerts": true, @@ -389,6 +393,7 @@ describe('common utils', () => { "duration": null, "external_service": null, "id": "mock-id-1", + "observables": Array [], "owner": "securitySolution", "settings": Object { "syncAlerts": true, @@ -432,6 +437,7 @@ describe('common utils', () => { "duration": null, "external_service": null, "id": "mock-id-2", + "observables": Array [], "owner": "securitySolution", "settings": Object { "syncAlerts": true, @@ -479,6 +485,7 @@ describe('common utils', () => { "duration": null, "external_service": null, "id": "mock-id-3", + "observables": Array [], "owner": "securitySolution", "settings": Object { "syncAlerts": true, @@ -530,6 +537,7 @@ describe('common utils', () => { "duration": null, "external_service": null, "id": "mock-id-4", + "observables": Array [], "owner": "securitySolution", "settings": Object { "syncAlerts": true, @@ -610,6 +618,7 @@ describe('common utils', () => { "duration": null, "external_service": null, "id": "mock-id-1", + "observables": Array [], "owner": "securitySolution", "settings": Object { "syncAlerts": true, @@ -678,6 +687,7 @@ describe('common utils', () => { "duration": null, "external_service": null, "id": "mock-id-3", + "observables": Array [], "owner": "securitySolution", "settings": Object { "syncAlerts": true, @@ -737,6 +747,7 @@ describe('common utils', () => { "duration": null, "external_service": null, "id": "mock-id-3", + "observables": Array [], "owner": "securitySolution", "settings": Object { "syncAlerts": true, @@ -819,6 +830,7 @@ describe('common utils', () => { "duration": null, "external_service": null, "id": "mock-id-3", + "observables": Array [], "owner": "securitySolution", "settings": Object { "syncAlerts": true, @@ -876,6 +888,7 @@ describe('common utils', () => { "duration": null, "external_service": null, "id": "mock-id-1", + "observables": Array [], "owner": "securitySolution", "settings": Object { "syncAlerts": true, @@ -938,6 +951,7 @@ describe('common utils', () => { "duration": null, "external_service": null, "id": "mock-id-1", + "observables": Array [], "owner": "securitySolution", "settings": Object { "syncAlerts": true, diff --git a/x-pack/plugins/cases/server/common/utils.ts b/x-pack/plugins/cases/server/common/utils.ts index aad86d988705d..0b9852e61cad1 100644 --- a/x-pack/plugins/cases/server/common/utils.ts +++ b/x-pack/plugins/cases/server/common/utils.ts @@ -88,6 +88,7 @@ export const transformNewCase = ({ assignees: dedupAssignees(newCase.assignees) ?? [], category: newCase.category ?? null, customFields: newCase.customFields ?? [], + observables: [], }); export const transformCases = ({ diff --git a/x-pack/plugins/cases/server/mocks.ts b/x-pack/plugins/cases/server/mocks.ts index 637cee85ed84b..d05d949142e6a 100644 --- a/x-pack/plugins/cases/server/mocks.ts +++ b/x-pack/plugins/cases/server/mocks.ts @@ -150,6 +150,7 @@ export const mockCases: CaseSavedObjectTransformed[] = [ title: 'Super Bad Security Issue', status: CaseStatuses.open, tags: ['defacement'], + observables: [], updated_at: '2019-11-25T21:54:48.952Z', updated_by: { full_name: 'elastic', @@ -202,6 +203,7 @@ export const mockCases: CaseSavedObjectTransformed[] = [ settings: { syncAlerts: true, }, + observables: [], owner: SECURITY_SOLUTION_OWNER, assignees: [], category: null, @@ -245,6 +247,7 @@ export const mockCases: CaseSavedObjectTransformed[] = [ settings: { syncAlerts: true, }, + observables: [], owner: SECURITY_SOLUTION_OWNER, assignees: [], category: null, @@ -292,6 +295,7 @@ export const mockCases: CaseSavedObjectTransformed[] = [ settings: { syncAlerts: true, }, + observables: [], owner: SECURITY_SOLUTION_OWNER, assignees: [], category: null, diff --git a/x-pack/plugins/cases/server/plugin.ts b/x-pack/plugins/cases/server/plugin.ts index 5a4bd7b20b9db..d62aa5582ec32 100644 --- a/x-pack/plugins/cases/server/plugin.ts +++ b/x-pack/plugins/cases/server/plugin.ts @@ -38,7 +38,10 @@ import { getInternalRoutes } from './routes/api/get_internal_routes'; import { PersistableStateAttachmentTypeRegistry } from './attachment_framework/persistable_state_registry'; import { ExternalReferenceAttachmentTypeRegistry } from './attachment_framework/external_reference_registry'; import { UserProfileService } from './services'; -import { LICENSING_CASE_ASSIGNMENT_FEATURE } from './common/constants'; +import { + LICENSING_CASE_ASSIGNMENT_FEATURE, + LICENSING_CASE_OBSERVABLES_FEATURE, +} from './common/constants'; import { registerInternalAttachments } from './internal_attachments'; import { registerCaseFileKinds } from './files'; import type { ConfigType } from './config'; @@ -140,6 +143,7 @@ export class CasePlugin }); plugins.licensing.featureUsage.register(LICENSING_CASE_ASSIGNMENT_FEATURE, 'platinum'); + plugins.licensing.featureUsage.register(LICENSING_CASE_OBSERVABLES_FEATURE, 'platinum'); const getCasesClient = async (request: KibanaRequest): Promise => { const [coreStart] = await core.getStartServices(); diff --git a/x-pack/plugins/cases/server/routes/api/cases/similar.ts b/x-pack/plugins/cases/server/routes/api/cases/similar.ts new file mode 100644 index 0000000000000..c1516d7648082 --- /dev/null +++ b/x-pack/plugins/cases/server/routes/api/cases/similar.ts @@ -0,0 +1,48 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { INTERNAL_CASE_SIMILAR_CASES_URL } from '../../../../common/constants'; +import { createCaseError } from '../../../common/error'; +import { createCasesRoute } from '../create_cases_route'; +import type { caseApiV1 } from '../../../../common/types/api'; + +export const similarCaseRoute = createCasesRoute({ + method: 'post', + path: INTERNAL_CASE_SIMILAR_CASES_URL, + params: { + params: schema.object({ + case_id: schema.string(), + }), + }, + routerOptions: { + access: 'internal', + summary: `Similar cases`, + }, + handler: async ({ context, request, response }) => { + const options = request.body as caseApiV1.SimilarCasesSearchRequest; + const caseId = request.params.case_id; + + try { + const caseContext = await context.cases; + const casesClient = await caseContext.getCasesClient(); + + const res: caseApiV1.CasesSimilarResponse = await casesClient.cases.similar(caseId, { + ...options, + }); + + return response.ok({ + body: res, + }); + } catch (error) { + throw createCaseError({ + message: `Failed to find similar cases in route for case with ID ${caseId}: ${error}`, + error, + }); + } + }, +}); diff --git a/x-pack/plugins/cases/server/routes/api/get_internal_routes.ts b/x-pack/plugins/cases/server/routes/api/get_internal_routes.ts index 79e5189c02f57..63c5953b3ea32 100644 --- a/x-pack/plugins/cases/server/routes/api/get_internal_routes.ts +++ b/x-pack/plugins/cases/server/routes/api/get_internal_routes.ts @@ -20,6 +20,10 @@ import { getCaseMetricRoute } from './internal/get_case_metrics'; import { getCasesMetricRoute } from './internal/get_cases_metrics'; import { searchCasesRoute } from './internal/search_cases'; import { replaceCustomFieldRoute } from './internal/replace_custom_field'; +import { postObservableRoute } from './observables/post_observable'; +import { similarCaseRoute } from './cases/similar'; +import { patchObservableRoute } from './observables/patch_observable'; +import { deleteObservableRoute } from './observables/delete_observable'; export const getInternalRoutes = (userProfileService: UserProfileService) => [ @@ -36,4 +40,8 @@ export const getInternalRoutes = (userProfileService: UserProfileService) => getCasesMetricRoute, searchCasesRoute, replaceCustomFieldRoute, + postObservableRoute, + patchObservableRoute, + deleteObservableRoute, + similarCaseRoute, ] as CaseRoute[]; diff --git a/x-pack/plugins/cases/server/routes/api/observables/delete_observable.ts b/x-pack/plugins/cases/server/routes/api/observables/delete_observable.ts new file mode 100644 index 0000000000000..49f2b27fc0064 --- /dev/null +++ b/x-pack/plugins/cases/server/routes/api/observables/delete_observable.ts @@ -0,0 +1,43 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { INTERNAL_CASE_OBSERVABLES_DELETE_URL } from '../../../../common/constants'; +import { createCaseError } from '../../../common/error'; +import { createCasesRoute } from '../create_cases_route'; + +export const deleteObservableRoute = createCasesRoute({ + method: 'delete', + path: INTERNAL_CASE_OBSERVABLES_DELETE_URL, + params: { + params: schema.object({ + case_id: schema.string(), + observable_id: schema.string(), + }), + }, + routerOptions: { + access: 'internal', + summary: `Delete a case observable`, + }, + handler: async ({ context, request, response }) => { + try { + const caseContext = await context.cases; + const casesClient = await caseContext.getCasesClient(); + const caseId = request.params.case_id; + const observableId = request.params.observable_id; + + await casesClient.cases.deleteObservable(caseId, observableId); + + return response.noContent(); + } catch (error) { + throw createCaseError({ + message: `Failed to delete observable in route case id: ${request.params.case_id}, observable id: ${request.params.observable_id}: ${error}`, + error, + }); + } + }, +}); diff --git a/x-pack/plugins/cases/server/routes/api/observables/patch_observable.ts b/x-pack/plugins/cases/server/routes/api/observables/patch_observable.ts new file mode 100644 index 0000000000000..49630bb12ded6 --- /dev/null +++ b/x-pack/plugins/cases/server/routes/api/observables/patch_observable.ts @@ -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 { schema } from '@kbn/config-schema'; +import { INTERNAL_CASE_OBSERVABLES_PATCH_URL } from '../../../../common/constants'; +import { createCaseError } from '../../../common/error'; +import { createCasesRoute } from '../create_cases_route'; +import type { observableApiV1 } from '../../../../common/types/api'; + +export const patchObservableRoute = createCasesRoute({ + method: 'patch', + path: INTERNAL_CASE_OBSERVABLES_PATCH_URL, + params: { + params: schema.object({ + case_id: schema.string(), + observable_id: schema.string(), + }), + }, + routerOptions: { + access: 'internal', + summary: `Update a case observable`, + }, + handler: async ({ context, request, response }) => { + try { + const caseContext = await context.cases; + const casesClient = await caseContext.getCasesClient(); + const caseId = request.params.case_id; + const observableId = request.params.observable_id; + + const { observable } = request.body as observableApiV1.UpdateObservableRequest; + + const theCase = await casesClient.cases.updateObservable(caseId, observableId, { + observable, + }); + + return response.ok({ + body: theCase, + }); + } catch (error) { + throw createCaseError({ + message: `Failed to patch observable in route case id: ${request.params.case_id}, observable id: ${request.params.observable_id}: ${error}`, + error, + }); + } + }, +}); diff --git a/x-pack/plugins/cases/server/routes/api/observables/post_observable.ts b/x-pack/plugins/cases/server/routes/api/observables/post_observable.ts new file mode 100644 index 0000000000000..6cffa0861bab4 --- /dev/null +++ b/x-pack/plugins/cases/server/routes/api/observables/post_observable.ts @@ -0,0 +1,46 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { INTERNAL_CASE_OBSERVABLES_URL } from '../../../../common/constants'; +import { createCaseError } from '../../../common/error'; +import { createCasesRoute } from '../create_cases_route'; +import type { observableApiV1 } from '../../../../common/types/api'; + +export const postObservableRoute = createCasesRoute({ + method: 'post', + path: INTERNAL_CASE_OBSERVABLES_URL, + params: { + params: schema.object({ + case_id: schema.string(), + }), + }, + routerOptions: { + access: 'internal', + summary: `Add a case observable`, + description: 'Each case can have a maximum of 10 observables.', + // You must have `all` privileges for the **Cases** feature in the **Management**, **Observability**, or **Security** section of the Kibana feature privileges, depending on the owner of the case you're creating. + }, + handler: async ({ context, request, response }) => { + try { + const caseContext = await context.cases; + const casesClient = await caseContext.getCasesClient(); + const caseId = request.params.case_id; + const { observable } = request.body as observableApiV1.AddObservableRequest; + const theCase = await casesClient.cases.addObservable(caseId, { observable }); + + return response.ok({ + body: theCase, + }); + } catch (error) { + throw createCaseError({ + message: `Failed to post observable in route case id: ${request.params.case_id}: ${error}`, + error, + }); + } + }, +}); diff --git a/x-pack/plugins/cases/server/saved_object_types/cases/cases.ts b/x-pack/plugins/cases/server/saved_object_types/cases/cases.ts index 8e9160604a69d..0cf1905ca0cf4 100644 --- a/x-pack/plugins/cases/server/saved_object_types/cases/cases.ts +++ b/x-pack/plugins/cases/server/saved_object_types/cases/cases.ts @@ -17,7 +17,7 @@ import { CASE_SAVED_OBJECT } from '../../../common/constants'; import type { CasePersistedAttributes } from '../../common/types/case'; import { handleExport } from '../import_export/export'; import { caseMigrations } from '../migrations'; -import { modelVersion1 } from './model_versions'; +import { modelVersion1, modelVersion2 } from './model_versions'; export const createCaseSavedObjectType = ( coreSetup: CoreSetup, @@ -229,11 +229,23 @@ export const createCaseSavedObjectType = ( }, }, }, + observables: { + type: 'nested', + properties: { + typeKey: { + type: 'keyword', + }, + value: { + type: 'keyword', + }, + }, + }, }, }, migrations: caseMigrations, modelVersions: { 1: modelVersion1, + 2: modelVersion2, }, management: { importableAndExportable: true, diff --git a/x-pack/plugins/cases/server/saved_object_types/cases/model_versions.test.ts b/x-pack/plugins/cases/server/saved_object_types/cases/model_versions.test.ts index 2c301709ca5c9..67e89cd9b18b6 100644 --- a/x-pack/plugins/cases/server/saved_object_types/cases/model_versions.test.ts +++ b/x-pack/plugins/cases/server/saved_object_types/cases/model_versions.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { modelVersion1 } from './model_versions'; +import { modelVersion1, modelVersion2 } from './model_versions'; describe('Model versions', () => { describe('1', () => { @@ -56,4 +56,27 @@ describe('Model versions', () => { `); }); }); + + describe('2', () => { + expect(modelVersion2.changes).toMatchInlineSnapshot(` + Array [ + Object { + "addedMappings": Object { + "observables": Object { + "properties": Object { + "typeKey": Object { + "type": "keyword", + }, + "value": Object { + "type": "keyword", + }, + }, + "type": "nested", + }, + }, + "type": "mappings_addition", + }, + ] + `); + }); }); diff --git a/x-pack/plugins/cases/server/saved_object_types/cases/model_versions.ts b/x-pack/plugins/cases/server/saved_object_types/cases/model_versions.ts index 7d46789a3b79f..522c51eb8e30f 100644 --- a/x-pack/plugins/cases/server/saved_object_types/cases/model_versions.ts +++ b/x-pack/plugins/cases/server/saved_object_types/cases/model_versions.ts @@ -6,7 +6,7 @@ */ import type { SavedObjectsModelVersion } from '@kbn/core-saved-objects-server'; -import { casesSchemaV1 } from './schemas'; +import { casesSchemaV1, casesSchemaV2 } from './schemas'; /** * Adds custom fields to the cases SO. @@ -59,3 +59,30 @@ export const modelVersion1: SavedObjectsModelVersion = { forwardCompatibility: casesSchemaV1.extends({}, { unknowns: 'ignore' }), }, }; + +/** + * Adds case observables to the cases SO. + */ +export const modelVersion2: SavedObjectsModelVersion = { + changes: [ + { + type: 'mappings_addition', + addedMappings: { + observables: { + type: 'nested', + properties: { + typeKey: { + type: 'keyword', + }, + value: { + type: 'keyword', + }, + }, + }, + }, + }, + ], + schemas: { + forwardCompatibility: casesSchemaV2.extends({}, { unknowns: 'ignore' }), + }, +}; diff --git a/x-pack/plugins/cases/server/saved_object_types/cases/schemas/index.ts b/x-pack/plugins/cases/server/saved_object_types/cases/schemas/index.ts index 85d9239f72dba..a38b3a1134911 100644 --- a/x-pack/plugins/cases/server/saved_object_types/cases/schemas/index.ts +++ b/x-pack/plugins/cases/server/saved_object_types/cases/schemas/index.ts @@ -8,3 +8,4 @@ export * from './latest'; export { casesSchema as casesSchemaV1 } from './v1'; +export { casesSchema as casesSchemaV2 } from './v2'; diff --git a/x-pack/plugins/cases/server/saved_object_types/cases/schemas/latest.ts b/x-pack/plugins/cases/server/saved_object_types/cases/schemas/latest.ts index 25300c97a6d2e..a0841d392cbc1 100644 --- a/x-pack/plugins/cases/server/saved_object_types/cases/schemas/latest.ts +++ b/x-pack/plugins/cases/server/saved_object_types/cases/schemas/latest.ts @@ -5,4 +5,4 @@ * 2.0. */ -export * from './v1'; +export * from './v2'; diff --git a/x-pack/plugins/cases/server/saved_object_types/cases/schemas/v2.ts b/x-pack/plugins/cases/server/saved_object_types/cases/schemas/v2.ts new file mode 100644 index 0000000000000..6368e08a621a7 --- /dev/null +++ b/x-pack/plugins/cases/server/saved_object_types/cases/schemas/v2.ts @@ -0,0 +1,27 @@ +/* + * 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 { schema } from '@kbn/config-schema'; + +import { casesSchema as casesSchemaV1 } from './v1'; + +export const casesSchema = casesSchemaV1.extends({ + observables: schema.maybe( + schema.nullable( + schema.arrayOf( + schema.object({ + id: schema.string(), + createdAt: schema.string(), + updatedAt: schema.nullable(schema.string()), + description: schema.nullable(schema.string()), + typeKey: schema.string(), + value: schema.any(), + }) + ) + ) + ), +}); diff --git a/x-pack/plugins/cases/server/services/cases/index.test.ts b/x-pack/plugins/cases/server/services/cases/index.test.ts index 045701ce77aad..7fea2c6b27548 100644 --- a/x-pack/plugins/cases/server/services/cases/index.test.ts +++ b/x-pack/plugins/cases/server/services/cases/index.test.ts @@ -238,6 +238,7 @@ describe('CasesService', () => { "username": "elastic", }, }, + "observables": Array [], "owner": "securitySolution", "settings": Object { "syncAlerts": true, @@ -314,6 +315,7 @@ describe('CasesService', () => { "customFields": Array [], "description": "This is a brand new case of a bad meanie defacing data", "duration": null, + "observables": Array [], "owner": "securitySolution", "settings": Object { "syncAlerts": true, @@ -838,6 +840,7 @@ describe('CasesService', () => { "username": "elastic", }, }, + "observables": Array [], "owner": "securitySolution", "settings": Object { "syncAlerts": true, @@ -1058,6 +1061,7 @@ describe('CasesService', () => { "description": "This is a brand new case of a bad meanie defacing data", "duration": null, "external_service": null, + "observables": Array [], "owner": "securitySolution", "settings": Object { "syncAlerts": true, @@ -1690,6 +1694,7 @@ describe('CasesService', () => { "username": "elastic", }, }, + "observables": Array [], "owner": "securitySolution", "settings": Object { "syncAlerts": true, @@ -2202,7 +2207,8 @@ describe('CasesService', () => { 'connector', 'external_service', 'category', - 'customFields' + 'customFields', + 'observables' ); describe('getCaseIdsByAlertId', () => { @@ -2302,6 +2308,7 @@ describe('CasesService', () => { "description": "This is a brand new case of a bad meanie defacing data", "duration": null, "external_service": null, + "observables": Array [], "owner": "securitySolution", "settings": Object { "syncAlerts": true, @@ -2404,6 +2411,7 @@ describe('CasesService', () => { "username": "elastic", }, }, + "observables": Array [], "owner": "securitySolution", "settings": Object { "syncAlerts": true, @@ -2497,6 +2505,7 @@ describe('CasesService', () => { "username": "elastic", }, }, + "observables": Array [], "owner": "securitySolution", "settings": Object { "syncAlerts": true, @@ -2590,6 +2599,7 @@ describe('CasesService', () => { "username": "elastic", }, }, + "observables": Array [], "owner": "securitySolution", "settings": Object { "syncAlerts": true, @@ -2696,6 +2706,7 @@ describe('CasesService', () => { "username": "elastic", }, }, + "observables": Array [], "owner": "securitySolution", "settings": Object { "syncAlerts": true, @@ -2752,6 +2763,7 @@ describe('CasesService', () => { "username": "elastic", }, }, + "observables": Array [], "owner": "securitySolution", "settings": Object { "syncAlerts": true, @@ -2854,6 +2866,7 @@ describe('CasesService', () => { "username": "elastic", }, }, + "observables": Array [], "owner": "securitySolution", "settings": Object { "syncAlerts": true, @@ -2972,6 +2985,7 @@ describe('CasesService', () => { "username": "elastic", }, }, + "observables": Array [], "owner": "securitySolution", "settings": Object { "syncAlerts": true, diff --git a/x-pack/plugins/cases/server/services/cases/transform.test.ts b/x-pack/plugins/cases/server/services/cases/transform.test.ts index e6dc9dfb48768..e267263ccb880 100644 --- a/x-pack/plugins/cases/server/services/cases/transform.test.ts +++ b/x-pack/plugins/cases/server/services/cases/transform.test.ts @@ -582,5 +582,49 @@ describe('case transforms', () => { transformSavedObjectToExternalModel(CaseSOResponseWithoutCategory).attributes.category ).toBe('foobar'); }); + + it('returns observables array when it is defined', () => { + const CaseSOResponseWithObservables = createCaseSavedObjectResponse({ + overrides: { + observables: [ + { + id: '27318f00-334b-44b1-b29c-0cfaefbeeb8a', + value: 'test', + typeKey: 'c661b01e-24f5-44aa-a172-d5d219cd1bd4', + createdAt: '2024-11-07', + updatedAt: '2024-11-07', + description: '', + }, + ], + }, + }); + + expect( + transformSavedObjectToExternalModel(CaseSOResponseWithObservables).attributes.observables + ).toMatchInlineSnapshot(` + Array [ + Object { + "createdAt": "2024-11-07", + "description": "", + "id": "27318f00-334b-44b1-b29c-0cfaefbeeb8a", + "typeKey": "c661b01e-24f5-44aa-a172-d5d219cd1bd4", + "updatedAt": "2024-11-07", + "value": "test", + }, + ] + `); + }); + + it('returns observables array when it is not defined', () => { + const CaseSOResponseWithObservables = createCaseSavedObjectResponse({ + overrides: { + observables: undefined, + }, + }); + + expect( + transformSavedObjectToExternalModel(CaseSOResponseWithObservables).attributes.observables + ).toMatchInlineSnapshot(`Array []`); + }); }); }); diff --git a/x-pack/plugins/cases/server/services/cases/transform.ts b/x-pack/plugins/cases/server/services/cases/transform.ts index 10eb6fc292323..beba8be79902f 100644 --- a/x-pack/plugins/cases/server/services/cases/transform.ts +++ b/x-pack/plugins/cases/server/services/cases/transform.ts @@ -181,6 +181,7 @@ export function transformSavedObjectToExternalModel( const customFields = !caseSavedObjectAttributes.customFields ? [] : (caseSavedObjectAttributes.customFields as CaseCustomFields); + const observables = caseSavedObjectAttributes.observables ?? []; return { ...caseSavedObject, @@ -192,6 +193,7 @@ export function transformSavedObjectToExternalModel( external_service: externalService, category, customFields, + observables, }, }; } diff --git a/x-pack/plugins/cases/server/services/configure/index.test.ts b/x-pack/plugins/cases/server/services/configure/index.test.ts index 627263de50849..751a6f4c9a25b 100644 --- a/x-pack/plugins/cases/server/services/configure/index.test.ts +++ b/x-pack/plugins/cases/server/services/configure/index.test.ts @@ -97,6 +97,12 @@ const basicConfigFields = { }, }, ], + observableTypes: [ + { + key: '011c2c4e-794f-4837-8d94-22b07722ab14', + label: 'test observable type', + }, + ], }; const createConfigUpdateParams = (connector?: CaseConnector): Partial => ({ @@ -241,6 +247,12 @@ describe('CaseConfigureService', () => { "type": "text", }, ], + "observableTypes": Array [ + Object { + "key": "011c2c4e-794f-4837-8d94-22b07722ab14", + "label": "test observable type", + }, + ], "owner": "securitySolution", "templates": Array [ Object { @@ -567,6 +579,12 @@ describe('CaseConfigureService', () => { "type": "text", }, ], + "observableTypes": Array [ + Object { + "key": "011c2c4e-794f-4837-8d94-22b07722ab14", + "label": "test observable type", + }, + ], "owner": "securitySolution", "templates": Array [ Object { diff --git a/x-pack/plugins/cases/server/services/configure/index.ts b/x-pack/plugins/cases/server/services/configure/index.ts index f50ac271bc4ff..1eadc2a258d28 100644 --- a/x-pack/plugins/cases/server/services/configure/index.ts +++ b/x-pack/plugins/cases/server/services/configure/index.ts @@ -232,6 +232,11 @@ function transformToExternalModel( ? [] : (configuration.attributes.templates as ConfigurationTransformedAttributes['templates']); + const observableTypes = !configuration.attributes.observableTypes + ? [] + : (configuration.attributes + .observableTypes as ConfigurationTransformedAttributes['observableTypes']); + return { ...configuration, attributes: { @@ -239,6 +244,7 @@ function transformToExternalModel( connector, customFields, templates, + observableTypes, }, }; } diff --git a/x-pack/plugins/cases/server/services/test_utils.ts b/x-pack/plugins/cases/server/services/test_utils.ts index cd0f16d66e4cb..c37fe87ee7088 100644 --- a/x-pack/plugins/cases/server/services/test_utils.ts +++ b/x-pack/plugins/cases/server/services/test_utils.ts @@ -165,6 +165,7 @@ export const basicCaseFields: CaseAttributes = { assignees: [], category: null, customFields: [], + observables: [], }; export const createCaseSavedObjectResponse = ({ diff --git a/x-pack/test/cases_api_integration/common/lib/api/configuration.ts b/x-pack/test/cases_api_integration/common/lib/api/configuration.ts index 09f828c44dd73..e898082134b43 100644 --- a/x-pack/test/cases_api_integration/common/lib/api/configuration.ts +++ b/x-pack/test/cases_api_integration/common/lib/api/configuration.ts @@ -56,6 +56,7 @@ export const getConfigurationOutput = (update = false, overwrite = {}): Partial< created_by: { email: null, full_name: null, username: 'elastic' }, updated_by: update ? { email: null, full_name: null, username: 'elastic' } : null, customFields: [], + observableTypes: [], ...overwrite, }; }; diff --git a/x-pack/test/cases_api_integration/common/lib/api/index.ts b/x-pack/test/cases_api_integration/common/lib/api/index.ts index 59d91a388f6ea..5b174cc406f60 100644 --- a/x-pack/test/cases_api_integration/common/lib/api/index.ts +++ b/x-pack/test/cases_api_integration/common/lib/api/index.ts @@ -25,6 +25,7 @@ import { CASE_USER_ACTION_SAVED_OBJECT, INTERNAL_CASE_METRICS_URL, INTERNAL_GET_CASE_CATEGORIES_URL, + INTERNAL_CASE_SIMILAR_CASES_URL, } from '@kbn/cases-plugin/common/constants'; import { CaseMetricsFeature } from '@kbn/cases-plugin/common'; import type { SingleCaseMetricsResponse, CasesMetricsResponse } from '@kbn/cases-plugin/common'; @@ -40,6 +41,8 @@ import { CaseCustomField, } from '@kbn/cases-plugin/common/types/domain'; import { + AddObservableRequest, + UpdateObservableRequest, AlertResponse, CaseResolveResponse, CasesBulkGetResponse, @@ -48,7 +51,14 @@ import { CasesStatusResponse, CustomFieldPutRequest, GetRelatedCasesByAlertResponse, + SimilarCasesSearchRequest, + CasesSimilarResponse, } from '@kbn/cases-plugin/common/types/api'; +import { + getCaseCreateObservableUrl, + getCaseUpdateObservableUrl, + getCaseDeleteObservableUrl, +} from '@kbn/cases-plugin/common/api'; import { User } from '../authentication/types'; import { superUser } from '../authentication/users'; import { getSpaceUrlPrefix, setupAuth } from './helpers'; @@ -846,3 +856,122 @@ export const replaceCustomField = async ({ return theCustomField; }; + +export const addObservable = async ({ + supertest, + params, + expectedHttpCode = 200, + auth = { user: superUser, space: null }, + headers = {}, + caseId, +}: { + supertest: SuperTest.Agent; + params: AddObservableRequest; + expectedHttpCode?: number; + auth?: { user: User; space: string | null } | null; + headers?: Record; + caseId: string; +}): Promise => { + const apiCall = supertest.post( + `${getSpaceUrlPrefix(auth?.space)}${getCaseCreateObservableUrl(caseId)}` + ); + + void setupAuth({ apiCall, headers, auth }); + + const { body: updatedCase } = await apiCall + .set('kbn-xsrf', 'true') + .set('x-elastic-internal-origin', 'foo') + .set(headers) + .send(params) + .expect(expectedHttpCode); + + return updatedCase; +}; + +export const updateObservable = async ({ + supertest, + params, + expectedHttpCode = 200, + auth = { user: superUser, space: null }, + headers = {}, + caseId, + observableId, +}: { + supertest: SuperTest.Agent; + params: UpdateObservableRequest; + expectedHttpCode?: number; + auth?: { user: User; space: string | null } | null; + headers?: Record; + caseId: string; + observableId: string; +}): Promise => { + const apiCall = supertest.patch( + `${getSpaceUrlPrefix(auth?.space)}${getCaseUpdateObservableUrl(caseId, observableId)}` + ); + void setupAuth({ apiCall, headers, auth }); + + const { body: updatedCase } = await apiCall + .set('kbn-xsrf', 'true') + .set('x-elastic-internal-origin', 'foo') + .set(headers) + .send(params) + .expect(expectedHttpCode); + + return updatedCase; +}; + +export const deleteObservable = async ({ + supertest, + expectedHttpCode = 200, + auth = { user: superUser, space: null }, + headers = {}, + caseId, + observableId, +}: { + supertest: SuperTest.Agent; + expectedHttpCode?: number; + auth?: { user: User; space: string | null } | null; + headers?: Record; + caseId: string; + observableId: string; +}): Promise => { + const apiCall = supertest.delete( + `${getSpaceUrlPrefix(auth?.space)}${getCaseDeleteObservableUrl(caseId, observableId)}` + ); + void setupAuth({ apiCall, headers, auth }); + + await apiCall + .set('kbn-xsrf', 'true') + .set('x-elastic-internal-origin', 'foo') + .set(headers) + .send() + .expect(expectedHttpCode); +}; + +export const similarCases = async ({ + supertest, + body, + expectedHttpCode = 200, + auth = { user: superUser, space: null }, + caseId, +}: { + supertest: SuperTest.Agent; + body: SimilarCasesSearchRequest; + expectedHttpCode?: number; + auth?: { user: User; space: string | null }; + caseId: string; +}): Promise => { + const { body: res } = await supertest + .post( + `${getSpaceUrlPrefix(auth.space)}${INTERNAL_CASE_SIMILAR_CASES_URL.replace( + '{case_id}', + caseId + )}` + ) + .auth(auth.user.username, auth.user.password) + .set('kbn-xsrf', 'true') + .send({ ...body }) + .expect(expectedHttpCode); + + return res; +}; diff --git a/x-pack/test/cases_api_integration/common/lib/mock.ts b/x-pack/test/cases_api_integration/common/lib/mock.ts index 2461cded5aeaa..b027cab5298be 100644 --- a/x-pack/test/cases_api_integration/common/lib/mock.ts +++ b/x-pack/test/cases_api_integration/common/lib/mock.ts @@ -183,6 +183,7 @@ export const postCaseResp = ( updated_by: null, category: null, customFields: [], + observables: [], }); interface CommentRequestWithID { diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/migrations.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/migrations.ts index 0a3bd5ab1519d..b0979759e7072 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/migrations.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/migrations.ts @@ -128,6 +128,7 @@ export default function createGetTests({ getService }: FtrProviderContext) { totalComment: 1, updated_at: null, updated_by: null, + observables: [], }); }); }); diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/configure/patch_configure.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/configure/patch_configure.ts index 1e3d69d28d7fe..fb627b41c9d5a 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/configure/patch_configure.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/configure/patch_configure.ts @@ -98,6 +98,50 @@ export default ({ getService }: FtrProviderContext): void => { expect(data).to.eql({ ...getConfigurationOutput(true), customFields }); }); + it('should patch a configuration with observableTypes', async () => { + const observableTypes = [ + { + key: '50d4d08c-12b4-4055-a343-b303e0ab3724', + label: 'type 1', + }, + ] as ConfigurationPatchRequest['observableTypes']; + const configuration = await createConfiguration(supertest); + expect(configuration.observableTypes.length).to.be(0); + + const updatedConfiguration = await updateConfiguration(supertest, configuration.id, { + version: configuration.version, + observableTypes, + }); + + expect(updatedConfiguration.observableTypes.length).to.be.greaterThan(0); + expect(updatedConfiguration.observableTypes[0].key).to.equal(observableTypes?.[0].key); + expect(updatedConfiguration.observableTypes[0].label).to.equal(observableTypes?.[0].label); + }); + + it('should not patch a configuration with duplicated observableTypes', async () => { + const observableTypes = [ + { + key: '50d4d08c-12b4-4055-a343-b303e0ab3724', + label: 'duplicate', + }, + { + key: 'fc3ff698-589a-44fd-bbc4-ffaa0b7211f7', + label: 'duplicate', + }, + ] as ConfigurationPatchRequest['observableTypes']; + const configuration = await createConfiguration(supertest); + + await updateConfiguration( + supertest, + configuration.id, + { + version: configuration.version, + observableTypes, + }, + 400 + ); + }); + it('should update mapping when changing connector', async () => { const configuration = await createConfiguration(supertest); await updateConfiguration(supertest, configuration.id, { diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/connectors/cases/cases_connector.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/connectors/cases/cases_connector.ts index 6d4a095a5da02..ddf58f33bd40c 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/connectors/cases/cases_connector.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/connectors/cases/cases_connector.ts @@ -342,6 +342,7 @@ export default ({ getService }: FtrProviderContext): void => { full_name: null, username: 'elastic', }, + observables: [], }); }); @@ -454,6 +455,7 @@ export default ({ getService }: FtrProviderContext): void => { full_name: null, username: 'elastic', }, + observables: [], }); }); @@ -831,6 +833,7 @@ export default ({ getService }: FtrProviderContext): void => { full_name: null, username: 'elastic', }, + observables: [], }); expect(secondCase).to.eql({ @@ -878,6 +881,7 @@ export default ({ getService }: FtrProviderContext): void => { full_name: null, username: 'elastic', }, + observables: [], }); }); @@ -1415,6 +1419,7 @@ const createCaseWithId = async ({ external_service: null, total_alerts: 0, total_comments: 0, + observables: [], }, overwrite: false, }); diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/index.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/index.ts index 3112dfab7ec66..2b3c282ad6c2b 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/index.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/index.ts @@ -38,6 +38,8 @@ export default ({ loadTestFile, getService }: FtrProviderContext): void => { loadTestFile(require.resolve('./delete_sub_privilege')); loadTestFile(require.resolve('./create_comment_sub_privilege.ts')); loadTestFile(require.resolve('./user_profiles/get_current')); + // case observables are only available with a license above basic + loadTestFile(require.resolve('./internal/observables')); // Internal routes loadTestFile(require.resolve('./internal/get_user_action_stats')); diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/internal/observables.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/internal/observables.ts new file mode 100644 index 0000000000000..6945711b0d148 --- /dev/null +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/internal/observables.ts @@ -0,0 +1,223 @@ +/* + * 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 expect from '@kbn/expect'; + +import { OBSERVABLE_TYPE_IPV4 } from '@kbn/cases-plugin/common/constants'; +import { secOnly } from '../../../../common/lib/authentication/users'; +import { getPostCaseRequest } from '../../../../common/lib/mock'; +import { + createCase, + deleteAllCaseItems, + addObservable, + updateObservable, + deleteObservable, + getCase, +} from '../../../../common/lib/api'; + +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const es = getService('es'); + const supertest = getService('supertest'); + + describe('observables', () => { + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + describe('add observable to a case', () => { + it('can add an observable to a case', async () => { + const postedCase = await createCase(supertest, getPostCaseRequest()); + expect(postedCase.observables).to.eql([]); + + const newObservableData = { + value: '127.0.0.1', + typeKey: OBSERVABLE_TYPE_IPV4.key, + description: '', + }; + + const updatedCase = await addObservable({ + supertest, + caseId: postedCase.id, + params: { + observable: newObservableData, + }, + }); + + expect(updatedCase.observables.length).to.be.greaterThan(0); + }); + + it('returns bad request when using unknown observable type', async () => { + const postedCase = await createCase(supertest, getPostCaseRequest()); + expect(postedCase.observables).to.eql([]); + + const newObservableData = { + value: 'test', + typeKey: 'unknown type', + description: '', + }; + + await addObservable({ + supertest, + caseId: postedCase.id, + params: { + observable: newObservableData, + }, + expectedHttpCode: 400, + }); + }); + }); + + describe('update observable', () => { + it('updates an observable on a case', async () => { + const postedCase = await createCase(supertest, getPostCaseRequest()); + + const newObservableData = { + value: '127.0.0.1', + typeKey: OBSERVABLE_TYPE_IPV4.key, + description: '', + }; + + const { + observables: [observable], + } = await addObservable({ + supertest, + caseId: postedCase.id, + params: { + observable: newObservableData, + }, + }); + + const updatedObservable = await updateObservable({ + supertest, + params: { observable: { description: '', value: '192.168.68.1' } }, + caseId: postedCase.id, + observableId: observable.id as string, + }); + + expect(updatedObservable.observables[0].value).to.be('192.168.68.1'); + }); + }); + + describe('delete observable', () => { + it('deletes an observable on a case', async () => { + const postedCase = await createCase(supertest, getPostCaseRequest()); + + const newObservableData = { + value: '127.0.0.1', + typeKey: OBSERVABLE_TYPE_IPV4.key, + description: '', + }; + + const { + observables: [observable], + } = await addObservable({ + supertest, + caseId: postedCase.id, + params: { + observable: newObservableData, + }, + }); + + await deleteObservable({ + supertest, + caseId: postedCase.id, + observableId: observable.id as string, + expectedHttpCode: 204, + }); + + const { observables } = await getCase({ supertest, caseId: postedCase.id }); + + expect(observables.length).to.be(0); + }); + }); + + describe('rbac', () => { + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + it('should not allow creating observables without permissions', async () => { + const postedCase = await createCase(supertest, getPostCaseRequest()); + expect(postedCase.observables).to.eql([]); + + const newObservableData = { + value: '127.0.0.1', + typeKey: OBSERVABLE_TYPE_IPV4.key, + description: '', + }; + + await addObservable({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + params: { + observable: newObservableData, + }, + auth: { user: secOnly, space: null }, + expectedHttpCode: 403, + }); + }); + + it('should not allow deleting an observable without permissions', async () => { + const postedCase = await createCase(supertest, getPostCaseRequest()); + + const newObservableData = { + value: '127.0.0.1', + typeKey: OBSERVABLE_TYPE_IPV4.key, + description: '', + }; + + const { + observables: [observable], + } = await addObservable({ + supertest, + caseId: postedCase.id, + params: { + observable: newObservableData, + }, + }); + + await deleteObservable({ + supertest: supertestWithoutAuth, + caseId: postedCase.id, + observableId: observable.id as string, + auth: { user: secOnly, space: null }, + expectedHttpCode: 403, + }); + }); + + it('should not allow updating an observable without premissions', async () => { + const postedCase = await createCase(supertest, getPostCaseRequest()); + + const newObservableData = { + value: '127.0.0.1', + typeKey: OBSERVABLE_TYPE_IPV4.key, + description: '', + }; + + const { + observables: [observable], + } = await addObservable({ + supertest, + caseId: postedCase.id, + params: { + observable: newObservableData, + }, + }); + + await updateObservable({ + supertest: supertestWithoutAuth, + params: { observable: { description: '', value: '192.168.68.1' } }, + caseId: postedCase.id, + observableId: observable.id as string, + auth: { user: secOnly, space: null }, + expectedHttpCode: 403, + }); + }); + }); + }); +}; diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/internal/similar_cases.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/internal/similar_cases.ts new file mode 100644 index 0000000000000..2430e70dba6c6 --- /dev/null +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/trial/internal/similar_cases.ts @@ -0,0 +1,152 @@ +/* + * 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 expect from '@kbn/expect'; + +import { OBSERVABLE_TYPES_BUILTIN } from '@kbn/cases-plugin/common/constants'; +import { getPostCaseRequest } from '../../../../common/lib/mock'; +import { + createCase, + deleteAllCaseItems, + addObservable, + similarCases, +} from '../../../../common/lib/api'; + +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const es = getService('es'); + const supertest = getService('supertest'); + + describe('similar case', () => { + afterEach(async () => { + await deleteAllCaseItems(es); + }); + + describe('shows similar cases', () => { + it('returns cases similar to given case', async () => { + const [caseA, caseB] = await Promise.all([ + createCase(supertest, getPostCaseRequest()), + createCase(supertest, getPostCaseRequest()), + createCase(supertest, getPostCaseRequest()), + ]); + + const newObservableData = { + value: 'value', + typeKey: OBSERVABLE_TYPES_BUILTIN[0].key, + description: '', + }; + + const { cases } = await similarCases({ + supertest, + body: { perPage: 10, page: 1 }, + caseId: caseA.id, + }); + expect(cases.length).to.be(0); + + await addObservable({ + supertest, + caseId: caseA.id, + params: { + observable: newObservableData, + }, + }); + + await addObservable({ + supertest, + caseId: caseB.id, + params: { + observable: newObservableData, + }, + }); + + const { cases: casesSimilarToA } = await similarCases({ + supertest, + body: { perPage: 10, page: 1 }, + caseId: caseA.id, + }); + + expect(casesSimilarToA.length).to.be(1); + + const { cases: casesSimilarToB } = await similarCases({ + supertest, + body: { perPage: 10, page: 1 }, + caseId: caseB.id, + }); + + expect(casesSimilarToB.length).to.be(1); + }); + + it('does not return cases similar to given case if the owner does not match', async () => { + const [caseA, caseB] = await Promise.all([ + createCase(supertest, { ...getPostCaseRequest(), owner: 'observabilityFixture' }), + createCase(supertest, getPostCaseRequest()), + createCase(supertest, getPostCaseRequest()), + ]); + + const newObservableData = { + value: 'value', + typeKey: OBSERVABLE_TYPES_BUILTIN[0].key, + description: '', + }; + + const { cases } = await similarCases({ + supertest, + body: { perPage: 10, page: 1 }, + caseId: caseA.id, + }); + expect(cases.length).to.be(0); + + await addObservable({ + supertest, + caseId: caseA.id, + params: { + observable: newObservableData, + }, + }); + + await addObservable({ + supertest, + caseId: caseB.id, + params: { + observable: newObservableData, + }, + }); + + const { cases: casesSimilarToA } = await similarCases({ + supertest, + body: { perPage: 10, page: 1 }, + caseId: caseA.id, + }); + + expect(casesSimilarToA.length).to.be(0); + + const { cases: casesSimilarToB } = await similarCases({ + supertest, + body: { perPage: 10, page: 1 }, + caseId: caseB.id, + }); + + expect(casesSimilarToB.length).to.be(0); + }); + }); + + describe('rbac', () => { + const supertestWithoutAuth = getService('supertestWithoutAuth'); + + it('should not getting similar cases without permissions', async () => { + await similarCases({ + supertest: supertestWithoutAuth, + body: { perPage: 10, page: 1 }, + caseId: 'mock-case-id', + expectedHttpCode: 403, + }); + }); + }); + }); +}; diff --git a/x-pack/test/security_solution_cypress/cypress/objects/case.ts b/x-pack/test/security_solution_cypress/cypress/objects/case.ts index 59ec7dccefa99..a0192df069471 100644 --- a/x-pack/test/security_solution_cypress/cypress/objects/case.ts +++ b/x-pack/test/security_solution_cypress/cypress/objects/case.ts @@ -103,6 +103,7 @@ export const getCaseResponse = (): Case => ({ totalAlerts: 0, version: 'test-version', category: null, + observables: [], }); export const getServiceNowConnector = (): Connector => ({ diff --git a/x-pack/test_serverless/api_integration/services/svl_cases/api.ts b/x-pack/test_serverless/api_integration/services/svl_cases/api.ts index 6886c894c1110..2e3cdab5a48cc 100644 --- a/x-pack/test_serverless/api_integration/services/svl_cases/api.ts +++ b/x-pack/test_serverless/api_integration/services/svl_cases/api.ts @@ -113,6 +113,7 @@ export function SvlCasesApiServiceProvider({ getService }: FtrProviderContext) { updated_by: null, category: null, customFields: [], + observables: [], }; },