From a4d80f430dc679f2b3511f93f6b25d207bbc61ae Mon Sep 17 00:00:00 2001 From: Luke Gmys <11671118+lgestc@users.noreply.github.com> Date: Mon, 23 Dec 2024 14:25:58 +0100 Subject: [PATCH] [Security Solution] [Cases] Introduce case observables (phase 0 & 1) (#190237) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary ### Introducting Case Observables - _phases 0 and 1_ This pull request introduces case observables to Kibana, enhancing the platform's case management capabilities. It adds support for capturing and displaying observables (e.g., IP addresses, URLs, file hashes) linked to cases. The feature integrates with the Cases UI, allowing users to easily associate observables with cases for better tracking and analysis in incident response workflows. This improves investigative efficiency by correlating observables across multiple cases. #### Requirements: https://docs.google.com/document/d/12hZTpyn0eXy3Xnq8qLBd6_sJxBhNZoI7vXztxWHhUds/edit#heading=h.srf6mb8ifiad #### Design document: https://docs.google.com/document/d/1MeDLl6OEWast1RC1M3_hQXnRCd8frrXdGkFnypIYKJQ/edit#heading=h.kb5lrp2j62id Notable Cases sections are added in this pr: **1. Observables section in the case view, allowing for adding and listing up to 10 observables for the case** ![image](https://github.com/user-attachments/assets/f517803d-a6a3-4428-b3e3-478e70c60050) **2. Similar cases view for every case, allowing for similar case discovery** ![image](https://github.com/user-attachments/assets/388fddfb-9533-4f0d-aa8b-f5601e5323e0) **3. Observable types management view in Cases settings** ![image](https://github.com/user-attachments/assets/2d76f8be-c234-4f24-a419-da54228fb111) Original issue: https://github.com/elastic/kibana/issues/180360 Things skipped for now from MVP: - [ ] Allow users to manually create observables from the cases alerts table using the table actions (Phase 1) - [ ] Allow users to manually create observables of type “hash” from the files table using the table actions (Phase 1) --------- Co-authored-by: Christos Nasikas Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Christos Nasikas (cherry picked from commit 3083706bc9541d84700b81252f0e4880949e4ea0) --- .../current_fields.json | 3 + .../current_mappings.json | 11 + .../check_registered_types.test.ts | 2 +- x-pack/plugins/cases/common/api/helpers.ts | 26 ++ .../plugins/cases/common/constants/index.ts | 69 +++++ x-pack/plugins/cases/common/types.ts | 2 + .../cases/common/types/api/case/v1.test.ts | 10 + .../plugins/cases/common/types/api/case/v1.ts | 17 +- .../common/types/api/configure/v1.test.ts | 121 +++++++++ .../cases/common/types/api/configure/v1.ts | 23 ++ .../plugins/cases/common/types/api/index.ts | 2 + .../common/types/api/observable/latest.ts | 8 + .../common/types/api/observable/v1.test.ts | 45 ++++ .../cases/common/types/api/observable/v1.ts | 33 +++ .../cases/common/types/domain/case/v1.test.ts | 2 + .../cases/common/types/domain/case/v1.ts | 17 ++ .../common/types/domain/configure/v1.test.ts | 12 + .../cases/common/types/domain/configure/v1.ts | 9 + .../cases/common/types/domain/index.ts | 2 + .../common/types/domain/observable/latest.ts | 8 + .../common/types/domain/observable/v1.test.ts | 28 +++ .../common/types/domain/observable/v1.ts | 31 +++ x-pack/plugins/cases/common/ui/types.ts | 13 + x-pack/plugins/cases/public/api/decoders.ts | 8 + x-pack/plugins/cases/public/api/utils.test.ts | 9 + x-pack/plugins/cases/public/api/utils.ts | 32 ++- .../public/common/use_cases_features.test.tsx | 3 + .../public/common/use_cases_features.tsx | 2 + .../cases/public/components/app/routes.tsx | 10 +- .../case_view/case_view_page.test.tsx | 16 ++ .../components/case_view/case_view_page.tsx | 8 + .../case_view/case_view_tabs.test.tsx | 81 +++++- .../components/case_view/case_view_tabs.tsx | 127 ++++++++-- .../components/case_view_activity.test.tsx | 3 +- .../components/case_view_observables.test.tsx | 36 +++ .../components/case_view_observables.tsx | 52 ++++ .../case_view_similar_cases.test.tsx | 36 +++ .../components/case_view_similar_cases.tsx | 67 +++++ .../components/case_view/translations.ts | 8 + .../case_view/use_case_observables.test.ts | 94 +++++++ .../case_view/use_case_observables.ts | 34 +++ .../configure_cases/__mock__/index.tsx | 1 + .../configure_cases/flyout.test.tsx | 1 + .../components/configure_cases/index.test.tsx | 176 ++++++++++++- .../components/configure_cases/index.tsx | 131 +++++++++- .../configure_cases/translations.ts | 14 ++ .../experimental_badge/experimental_badge.tsx | 4 +- .../delete_confirmation_modal.test.tsx | 52 ++++ .../delete_confirmation_modal.tsx | 40 +++ .../components/observable_types/form.test.tsx | 127 ++++++++++ .../components/observable_types/form.tsx | 48 ++++ .../observable_types/form_fields.test.tsx | 34 +++ .../observable_types/form_fields.tsx | 39 +++ .../observable_types/index.test.tsx | 79 ++++++ .../components/observable_types/index.tsx | 120 +++++++++ .../observable_types_list/index.test.tsx | 131 ++++++++++ .../observable_types_list/index.tsx | 116 +++++++++ .../components/observable_types/schema.tsx | 39 +++ .../observable_types/translations.ts | 59 +++++ .../observables/add_observable.test.tsx | 102 ++++++++ .../components/observables/add_observable.tsx | 81 ++++++ .../public/components/observables/builder.tsx | 57 +++++ .../edit_observable_modal.test.tsx | 62 +++++ .../observables/edit_observable_modal.tsx | 56 +++++ .../observables/fields_config.test.ts | 146 +++++++++++ .../components/observables/fields_config.ts | 209 ++++++++++++++++ ...observable_actions_popover_button.test.tsx | 148 +++++++++++ .../observable_actions_popover_button.tsx | 137 ++++++++++ .../observables/observable_form.test.tsx | 30 +++ .../observables/observable_form.tsx | 143 +++++++++++ .../observables/observables_table.test.tsx | 43 ++++ .../observables/observables_table.tsx | 120 +++++++++ .../observables_utility_bar.test.tsx | 30 +++ .../observables/observables_utility_bar.tsx | 26 ++ .../components/observables/translations.tsx | 124 +++++++++ .../components/similar_cases/table.test.tsx | 38 +++ .../public/components/similar_cases/table.tsx | 72 ++++++ .../components/similar_cases/translations.ts | 28 +++ .../use_similar_cases_columns.tsx | 191 ++++++++++++++ .../public/components/templates/form.test.tsx | 2 + .../components/templates/form_fields.test.tsx | 1 + .../user_actions/comment/comment.tsx | 3 +- .../cases/public/containers/__mocks__/api.ts | 7 + .../cases/public/containers/api.test.tsx | 167 +++++++++++++ x-pack/plugins/cases/public/containers/api.ts | 71 +++++- .../cases/public/containers/configure/api.ts | 27 +- .../cases/public/containers/configure/mock.ts | 8 +- .../use_get_all_case_configurations.test.ts | 2 + .../use_persist_configuration.test.tsx | 42 +++- .../configure/use_persist_configuration.tsx | 12 +- .../public/containers/configure/utils.ts | 1 + .../cases/public/containers/constants.ts | 5 + .../plugins/cases/public/containers/mock.ts | 60 ++++- .../cases/public/containers/translations.ts | 12 + .../use_delete_observables.test.tsx | 67 +++++ .../containers/use_delete_observables.tsx | 38 +++ .../containers/use_get_similar_cases.test.tsx | 72 ++++++ .../containers/use_get_similar_cases.tsx | 53 ++++ .../containers/use_patch_observables.test.tsx | 75 ++++++ .../containers/use_patch_observables.tsx | 38 +++ .../containers/use_post_observables.test.tsx | 103 ++++++++ .../containers/use_post_observables.tsx | 38 +++ .../server/client/cases/bulk_create.test.ts | 4 + .../server/client/cases/bulk_update.test.ts | 2 + .../cases/server/client/cases/client.ts | 34 +++ .../cases/server/client/cases/create.test.ts | 6 + .../server/client/cases/observables.test.ts | 228 +++++++++++++++++ .../cases/server/client/cases/observables.ts | 235 +++++++++++++++++ .../cases/server/client/cases/similar.test.ts | 236 ++++++++++++++++++ .../cases/server/client/cases/similar.ts | 163 ++++++++++++ .../server/client/configure/client.test.ts | 57 +++++ .../cases/server/client/configure/client.ts | 14 +- x-pack/plugins/cases/server/client/mocks.ts | 4 + .../server/client/observable_types.test.ts | 58 +++++ .../cases/server/client/observable_types.ts | 26 ++ .../plugins/cases/server/client/utils.test.ts | 115 ++++++--- x-pack/plugins/cases/server/client/utils.ts | 22 ++ .../cases/server/client/validators.test.ts | 169 ++++++++++++- .../plugins/cases/server/client/validators.ts | 93 +++++++ .../plugins/cases/server/common/constants.ts | 7 +- .../cases/server/common/types/case.test.ts | 1 + .../plugins/cases/server/common/types/case.ts | 3 +- .../cases/server/common/types/configure.ts | 6 + .../plugins/cases/server/common/utils.test.ts | 14 ++ x-pack/plugins/cases/server/common/utils.ts | 1 + x-pack/plugins/cases/server/mocks.ts | 4 + x-pack/plugins/cases/server/plugin.ts | 6 +- .../cases/server/routes/api/cases/similar.ts | 48 ++++ .../server/routes/api/get_internal_routes.ts | 8 + .../api/observables/delete_observable.ts | 43 ++++ .../api/observables/patch_observable.ts | 50 ++++ .../routes/api/observables/post_observable.ts | 46 ++++ .../server/saved_object_types/cases/cases.ts | 14 +- .../cases/model_versions.test.ts | 25 +- .../cases/model_versions.ts | 29 ++- .../saved_object_types/cases/schemas/index.ts | 1 + .../cases/schemas/latest.ts | 2 +- .../saved_object_types/cases/schemas/v2.ts | 27 ++ .../cases/server/services/cases/index.test.ts | 16 +- .../server/services/cases/transform.test.ts | 44 ++++ .../cases/server/services/cases/transform.ts | 2 + .../server/services/configure/index.test.ts | 18 ++ .../cases/server/services/configure/index.ts | 6 + .../cases/server/services/test_utils.ts | 1 + .../common/lib/api/configuration.ts | 1 + .../common/lib/api/index.ts | 129 ++++++++++ .../cases_api_integration/common/lib/mock.ts | 1 + .../tests/common/cases/migrations.ts | 1 + .../tests/common/configure/patch_configure.ts | 44 ++++ .../trial/connectors/cases/cases_connector.ts | 5 + .../security_and_spaces/tests/trial/index.ts | 2 + .../tests/trial/internal/observables.ts | 223 +++++++++++++++++ .../tests/trial/internal/similar_cases.ts | 152 +++++++++++ .../cypress/objects/case.ts | 1 + .../api_integration/services/svl_cases/api.ts | 1 + 155 files changed, 7434 insertions(+), 92 deletions(-) create mode 100644 x-pack/plugins/cases/common/types/api/observable/latest.ts create mode 100644 x-pack/plugins/cases/common/types/api/observable/v1.test.ts create mode 100644 x-pack/plugins/cases/common/types/api/observable/v1.ts create mode 100644 x-pack/plugins/cases/common/types/domain/observable/latest.ts create mode 100644 x-pack/plugins/cases/common/types/domain/observable/v1.test.ts create mode 100644 x-pack/plugins/cases/common/types/domain/observable/v1.ts create mode 100644 x-pack/plugins/cases/public/components/case_view/components/case_view_observables.test.tsx create mode 100644 x-pack/plugins/cases/public/components/case_view/components/case_view_observables.tsx create mode 100644 x-pack/plugins/cases/public/components/case_view/components/case_view_similar_cases.test.tsx create mode 100644 x-pack/plugins/cases/public/components/case_view/components/case_view_similar_cases.tsx create mode 100644 x-pack/plugins/cases/public/components/case_view/use_case_observables.test.ts create mode 100644 x-pack/plugins/cases/public/components/case_view/use_case_observables.ts create mode 100644 x-pack/plugins/cases/public/components/observable_types/delete_confirmation_modal.test.tsx create mode 100644 x-pack/plugins/cases/public/components/observable_types/delete_confirmation_modal.tsx create mode 100644 x-pack/plugins/cases/public/components/observable_types/form.test.tsx create mode 100644 x-pack/plugins/cases/public/components/observable_types/form.tsx create mode 100644 x-pack/plugins/cases/public/components/observable_types/form_fields.test.tsx create mode 100644 x-pack/plugins/cases/public/components/observable_types/form_fields.tsx create mode 100644 x-pack/plugins/cases/public/components/observable_types/index.test.tsx create mode 100644 x-pack/plugins/cases/public/components/observable_types/index.tsx create mode 100644 x-pack/plugins/cases/public/components/observable_types/observable_types_list/index.test.tsx create mode 100644 x-pack/plugins/cases/public/components/observable_types/observable_types_list/index.tsx create mode 100644 x-pack/plugins/cases/public/components/observable_types/schema.tsx create mode 100644 x-pack/plugins/cases/public/components/observable_types/translations.ts create mode 100644 x-pack/plugins/cases/public/components/observables/add_observable.test.tsx create mode 100644 x-pack/plugins/cases/public/components/observables/add_observable.tsx create mode 100644 x-pack/plugins/cases/public/components/observables/builder.tsx create mode 100644 x-pack/plugins/cases/public/components/observables/edit_observable_modal.test.tsx create mode 100644 x-pack/plugins/cases/public/components/observables/edit_observable_modal.tsx create mode 100644 x-pack/plugins/cases/public/components/observables/fields_config.test.ts create mode 100644 x-pack/plugins/cases/public/components/observables/fields_config.ts create mode 100644 x-pack/plugins/cases/public/components/observables/observable_actions_popover_button.test.tsx create mode 100644 x-pack/plugins/cases/public/components/observables/observable_actions_popover_button.tsx create mode 100644 x-pack/plugins/cases/public/components/observables/observable_form.test.tsx create mode 100644 x-pack/plugins/cases/public/components/observables/observable_form.tsx create mode 100644 x-pack/plugins/cases/public/components/observables/observables_table.test.tsx create mode 100644 x-pack/plugins/cases/public/components/observables/observables_table.tsx create mode 100644 x-pack/plugins/cases/public/components/observables/observables_utility_bar.test.tsx create mode 100644 x-pack/plugins/cases/public/components/observables/observables_utility_bar.tsx create mode 100644 x-pack/plugins/cases/public/components/observables/translations.tsx create mode 100644 x-pack/plugins/cases/public/components/similar_cases/table.test.tsx create mode 100644 x-pack/plugins/cases/public/components/similar_cases/table.tsx create mode 100644 x-pack/plugins/cases/public/components/similar_cases/translations.ts create mode 100644 x-pack/plugins/cases/public/components/similar_cases/use_similar_cases_columns.tsx create mode 100644 x-pack/plugins/cases/public/containers/use_delete_observables.test.tsx create mode 100644 x-pack/plugins/cases/public/containers/use_delete_observables.tsx create mode 100644 x-pack/plugins/cases/public/containers/use_get_similar_cases.test.tsx create mode 100644 x-pack/plugins/cases/public/containers/use_get_similar_cases.tsx create mode 100644 x-pack/plugins/cases/public/containers/use_patch_observables.test.tsx create mode 100644 x-pack/plugins/cases/public/containers/use_patch_observables.tsx create mode 100644 x-pack/plugins/cases/public/containers/use_post_observables.test.tsx create mode 100644 x-pack/plugins/cases/public/containers/use_post_observables.tsx create mode 100644 x-pack/plugins/cases/server/client/cases/observables.test.ts create mode 100644 x-pack/plugins/cases/server/client/cases/observables.ts create mode 100644 x-pack/plugins/cases/server/client/cases/similar.test.ts create mode 100644 x-pack/plugins/cases/server/client/cases/similar.ts create mode 100644 x-pack/plugins/cases/server/client/observable_types.test.ts create mode 100644 x-pack/plugins/cases/server/client/observable_types.ts create mode 100644 x-pack/plugins/cases/server/routes/api/cases/similar.ts create mode 100644 x-pack/plugins/cases/server/routes/api/observables/delete_observable.ts create mode 100644 x-pack/plugins/cases/server/routes/api/observables/patch_observable.ts create mode 100644 x-pack/plugins/cases/server/routes/api/observables/post_observable.ts create mode 100644 x-pack/plugins/cases/server/saved_object_types/cases/schemas/v2.ts create mode 100644 x-pack/test/cases_api_integration/security_and_spaces/tests/trial/internal/observables.ts create mode 100644 x-pack/test/cases_api_integration/security_and_spaces/tests/trial/internal/similar_cases.ts diff --git a/packages/kbn-check-mappings-update-cli/current_fields.json b/packages/kbn-check-mappings-update-cli/current_fields.json index c772323c75f00..ec0d4d481a623 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 b3b3072c11915..c1a8509d98eba 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 ed3028ea286cc..d3fcb2c0a1ce5 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 755084d624b9f..da0fc03712067 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: [], }; },