diff --git a/src/platform/plugins/private/kibana_usage_collection/server/collectors/management/schema.ts b/src/platform/plugins/private/kibana_usage_collection/server/collectors/management/schema.ts index c12d980993bff..9c9f7faa1486d 100644 --- a/src/platform/plugins/private/kibana_usage_collection/server/collectors/management/schema.ts +++ b/src/platform/plugins/private/kibana_usage_collection/server/collectors/management/schema.ts @@ -660,6 +660,12 @@ export const stackManagementSchema: MakeSchemaFrom = { description: 'Enable the new logs overview component.', }, }, + 'cases:incrementalIdDisplay:enabled': { + type: 'boolean', + _meta: { + description: 'Display the incremental id of a case in the relevant pages', + }, + }, 'observability:streamsEnableSignificantEvents': { type: 'boolean', _meta: { diff --git a/src/platform/plugins/private/kibana_usage_collection/server/collectors/management/types.ts b/src/platform/plugins/private/kibana_usage_collection/server/collectors/management/types.ts index ba7a776ecce91..1b308240db013 100644 --- a/src/platform/plugins/private/kibana_usage_collection/server/collectors/management/types.ts +++ b/src/platform/plugins/private/kibana_usage_collection/server/collectors/management/types.ts @@ -172,6 +172,7 @@ export interface UsageStats { 'securitySolution:excludedDataTiersForRuleExecution': string[]; 'securitySolution:maxUnassociatedNotes': number; 'observability:searchExcludedDataTiers': string[]; + 'cases:incrementalIdDisplay:enabled': boolean; 'observability:enableDiagnosticMode': boolean; 'observability:streamsEnableSignificantEvents': boolean; 'genAiSettings:defaultAIConnector': string; diff --git a/src/platform/plugins/shared/telemetry/schema/oss_platform.json b/src/platform/plugins/shared/telemetry/schema/oss_platform.json index 651d30137e1a5..2b096918cb69a 100644 --- a/src/platform/plugins/shared/telemetry/schema/oss_platform.json +++ b/src/platform/plugins/shared/telemetry/schema/oss_platform.json @@ -11036,6 +11036,12 @@ "description": "Enable the new logs overview component." } }, + "cases:incrementalIdDisplay:enabled": { + "type": "boolean", + "_meta": { + "description": "Display the incremental id of a case in the relevant pages" + } + }, "observability:streamsEnableSignificantEvents": { "type": "boolean", "_meta": { diff --git a/src/platform/test/plugin_functional/test_suites/core_plugins/rendering.ts b/src/platform/test/plugin_functional/test_suites/core_plugins/rendering.ts index c70c725094851..f307f815ba49c 100644 --- a/src/platform/test/plugin_functional/test_suites/core_plugins/rendering.ts +++ b/src/platform/test/plugin_functional/test_suites/core_plugins/rendering.ts @@ -227,6 +227,7 @@ export default function ({ getService }: PluginFunctionalProviderContext) { 'xpack.cases.markdownPlugins.lens (boolean?)', 'xpack.cases.stack.enabled (boolean?)', 'xpack.cases.unsafe.enableCaseSummary (boolean?)', + 'xpack.cases.incrementalId.enabled (boolean?)', 'xpack.ccr.ui.enabled (boolean?)', 'xpack.cloud.base_url (string?)', 'xpack.cloud.cname (string?)', diff --git a/x-pack/platform/plugins/shared/cases/common/constants/incremental_id.ts b/x-pack/platform/plugins/shared/cases/common/constants/incremental_id.ts new file mode 100644 index 0000000000000..192732451d8ab --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/common/constants/incremental_id.ts @@ -0,0 +1,9 @@ +/* + * 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 const DEFAULT_TASK_INTERVAL_MINUTES = 10; +export const DEFAULT_TASK_START_DELAY_MINUTES = 10; diff --git a/x-pack/platform/plugins/shared/cases/common/constants/index.ts b/x-pack/platform/plugins/shared/cases/common/constants/index.ts index 0f3802d922987..70c87defa1466 100644 --- a/x-pack/platform/plugins/shared/cases/common/constants/index.ts +++ b/x-pack/platform/plugins/shared/cases/common/constants/index.ts @@ -28,7 +28,7 @@ export const CASE_RULES_SAVED_OBJECT = 'cases-rules' as const; export const CASE_ID_INCREMENTER_SAVED_OBJECT = 'cases-incrementing-id' as const; /** - * If more values are added here please also add them here: x-pack/platform/test/cases_api_integration/common/plugins + * If more values are added here please also add them here: x-pack/test/cases_api_integration/common/plugins */ export const SAVED_OBJECT_TYPES = [ CASE_SAVED_OBJECT, diff --git a/x-pack/platform/plugins/shared/cases/common/types/api/case/v1.test.ts b/x-pack/platform/plugins/shared/cases/common/types/api/case/v1.test.ts index e282d9a8266ec..6c630e1af7e42 100644 --- a/x-pack/platform/plugins/shared/cases/common/types/api/case/v1.test.ts +++ b/x-pack/platform/plugins/shared/cases/common/types/api/case/v1.test.ts @@ -414,7 +414,7 @@ describe('CasesFindRequestRt', () => { page: '1', perPage: '10', search: 'search text', - searchFields: ['title', 'description'], + searchFields: ['title', 'description', 'incremental_id.text'], to: '1w', sortOrder: 'desc', sortField: 'createdAt', @@ -536,7 +536,7 @@ describe('CasesSearchRequestRt', () => { page: '1', perPage: '10', search: 'search text', - searchFields: ['title', 'description'], + searchFields: ['title', 'description', 'incremental_id.text'], to: '1w', sortOrder: 'desc', sortField: 'createdAt', diff --git a/x-pack/platform/plugins/shared/cases/common/types/api/case/v1.ts b/x-pack/platform/plugins/shared/cases/common/types/api/case/v1.ts index baec0afdc97a6..f32f5b998b173 100644 --- a/x-pack/platform/plugins/shared/cases/common/types/api/case/v1.ts +++ b/x-pack/platform/plugins/shared/cases/common/types/api/case/v1.ts @@ -246,6 +246,7 @@ export const BulkCreateCasesResponseRt = rt.strict({ export const CasesFindRequestSearchFieldsRt = rt.keyof({ description: null, title: null, + 'incremental_id.text': null, }); export const CasesFindRequestSortFieldsRt = rt.keyof({ diff --git a/x-pack/platform/plugins/shared/cases/common/ui/types.ts b/x-pack/platform/plugins/shared/cases/common/ui/types.ts index 7341e17e7fdfb..d31a90c2bdeb0 100644 --- a/x-pack/platform/plugins/shared/cases/common/ui/types.ts +++ b/x-pack/platform/plugins/shared/cases/common/ui/types.ts @@ -80,6 +80,9 @@ export interface CasesUiConfigType { unsafe?: { enableCaseSummary: boolean; }; + incrementalId: { + enabled: boolean; + }; } export const UserActionTypeAll = 'all' as const; diff --git a/x-pack/platform/plugins/shared/cases/public/components/all_cases/all_cases_list.test.tsx b/x-pack/platform/plugins/shared/cases/public/components/all_cases/all_cases_list.test.tsx index a6b3683688c3f..3b1985a9be22e 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/all_cases/all_cases_list.test.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/all_cases/all_cases_list.test.tsx @@ -151,7 +151,10 @@ describe('AllCasesListGeneric', () => { beforeAll(() => { patchGetComputedStyle(); mockKibana(); - const actionTypeRegistry = useKibanaMock().services.triggersActionsUi.actionTypeRegistry; + const { + triggersActionsUi: { actionTypeRegistry }, + } = useKibanaMock().services; + registerConnectorsToMockActionRegistry(actionTypeRegistry, connectorsMock); }); @@ -193,6 +196,10 @@ describe('AllCasesListGeneric', () => { (await screen.findAllByTestId('case-user-profile-avatar-damaged_raccoon'))[0] ).toHaveTextContent('DR'); + const incrementalIdTextElements = screen.getAllByTestId('cases-incremental-id-text'); + expect(incrementalIdTextElements).toHaveLength(1); + expect(incrementalIdTextElements[0]).toHaveTextContent('#1'); + expect((await screen.findAllByTestId('case-table-column-tags-coke'))[0]).toHaveAttribute( 'title', useGetCasesMockState.data.cases[0].tags[0] @@ -515,7 +522,6 @@ describe('AllCasesListGeneric', () => { expect(useGetCasesMock).toHaveBeenLastCalledWith({ filterOptions: { ...DEFAULT_FILTER_OPTIONS, - searchFields: ['title', 'description'], category: ['twix'], }, queryParams: DEFAULT_QUERY_PARAMS, diff --git a/x-pack/platform/plugins/shared/cases/public/components/all_cases/table_filters.test.tsx b/x-pack/platform/plugins/shared/cases/public/components/all_cases/table_filters.test.tsx index c67597a517419..35191a1f211bf 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/all_cases/table_filters.test.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/all_cases/table_filters.test.tsx @@ -166,6 +166,7 @@ describe('CasesTableFilters ', () => { "searchFields": Array [ "title", "description", + "incremental_id.text", ], "severity": Array [], "status": Array [], @@ -267,6 +268,7 @@ describe('CasesTableFilters ', () => { "searchFields": Array [ "title", "description", + "incremental_id.text", ], "severity": Array [], "status": Array [], diff --git a/x-pack/platform/plugins/shared/cases/public/components/all_cases/use_cases_columns.test.tsx b/x-pack/platform/plugins/shared/cases/public/components/all_cases/use_cases_columns.test.tsx index 5ee31597f3177..ae4e514313a27 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/all_cases/use_cases_columns.test.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/all_cases/use_cases_columns.test.tsx @@ -6,7 +6,6 @@ */ import React from 'react'; -import { mount } from 'enzyme'; import { licensingMock } from '@kbn/licensing-plugin/public/mocks'; import type { GetCasesColumn } from './use_cases_columns'; @@ -630,70 +629,56 @@ describe('useCasesColumns ', () => { describe('ExternalServiceColumn ', () => { it('Not pushed render', () => { - const wrapper = mount( - - - + renderWithTestingProviders( + ); - expect( - wrapper.find(`[data-test-subj="case-table-column-external-notPushed"]`).last().exists() - ).toBeTruthy(); + expect(screen.getByTestId('case-table-column-external-notPushed')).toBeInTheDocument(); }); it('Up to date', () => { - const wrapper = mount( - - - + renderWithTestingProviders( + ); - expect( - wrapper.find(`[data-test-subj="case-table-column-external-upToDate"]`).last().exists() - ).toBeTruthy(); + expect(screen.getByTestId('case-table-column-external-upToDate')).toBeInTheDocument(); }); it('Needs update', () => { - const wrapper = mount( - - - + renderWithTestingProviders( + ); - expect( - wrapper.find(`[data-test-subj="case-table-column-external-requiresUpdate"]`).last().exists() - ).toBeTruthy(); + expect(screen.getByTestId('case-table-column-external-requiresUpdate')).toBeInTheDocument(); }); it('it does not throw when accessing the icon if the connector type is not registered', () => { // If the component throws the test will fail expect(() => - mount( - - - + renderWithTestingProviders( + ) ).not.toThrowError(); }); diff --git a/x-pack/platform/plugins/shared/cases/public/components/all_cases/use_cases_columns.tsx b/x-pack/platform/plugins/shared/cases/public/components/all_cases/use_cases_columns.tsx index dc68ffbb35309..567eae545f24f 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/all_cases/use_cases_columns.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/all_cases/use_cases_columns.tsx @@ -40,6 +40,7 @@ import { SeverityHealth } from '../severity/config'; import { AssigneesColumn } from './assignees_column'; import { builderMap as customFieldsBuilderMap } from '../custom_fields/builder'; import { useGetCaseConfiguration } from '../../containers/configure/use_get_case_configuration'; +import { IncrementalIdText } from '../incremental_id'; type CasesColumns = | EuiTableActionsColumnType @@ -112,9 +113,14 @@ export const useCasesColumns = ({ const caseDetailsLinkComponent = isSelectorView ? ( theCase.title ) : ( - - - +
+ + + + {typeof theCase.incrementalId === 'number' ? ( + + ) : null} +
); return caseDetailsLinkComponent; diff --git a/x-pack/platform/plugins/shared/cases/public/components/all_cases/utils/all_cases_url_state_deserializer.test.ts b/x-pack/platform/plugins/shared/cases/public/components/all_cases/utils/all_cases_url_state_deserializer.test.ts index 0b46456204dd2..33d115e58b05b 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/all_cases/utils/all_cases_url_state_deserializer.test.ts +++ b/x-pack/platform/plugins/shared/cases/public/components/all_cases/utils/all_cases_url_state_deserializer.test.ts @@ -31,6 +31,7 @@ describe('allCasesUrlStateDeserializer', () => { "searchFields": Array [ "title", "description", + "incremental_id.text", ], "severity": Array [], "status": Array [], diff --git a/x-pack/platform/plugins/shared/cases/public/components/all_cases/utils/stringify_url_params.test.tsx b/x-pack/platform/plugins/shared/cases/public/components/all_cases/utils/stringify_url_params.test.tsx index 4f67764260bb3..9ced4895657bf 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/all_cases/utils/stringify_url_params.test.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/all_cases/utils/stringify_url_params.test.tsx @@ -90,7 +90,7 @@ describe('stringifyUrlParams', () => { }; expect(stringifyUrlParams(urlParams)).toMatchInlineSnapshot( - `"cases=(assignees:!(),category:!(),customFields:(my_field:!(foo,bar)),owner:!(),page:1,perPage:10,reporters:!(),search:'',searchFields:!(title,description),severity:!(),sortField:createdAt,sortOrder:desc,status:!(),tags:!())"` + `"cases=(assignees:!(),category:!(),customFields:(my_field:!(foo,bar)),owner:!(),page:1,perPage:10,reporters:!(),search:'',searchFields:!(title,description,incremental_id.text),severity:!(),sortField:createdAt,sortOrder:desc,status:!(),tags:!())"` ); }); diff --git a/x-pack/platform/plugins/shared/cases/public/components/case_view/case_view_page.tsx b/x-pack/platform/plugins/shared/cases/public/components/case_view/case_view_page.tsx index 30d34ab28f64a..db58ab670c09a 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/case_view/case_view_page.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/case_view/case_view_page.tsx @@ -102,6 +102,7 @@ export const CaseViewPage = React.memo( /> } title={caseData.title} + incrementalId={caseData.incrementalId} > { expect(screen.getByText('Test supplement')).toBeInTheDocument(); }); + it('renders the `incremental_id` when provided', () => { + renderWithTestingProviders( + + + + ); + + expect(screen.getByText('#1337')).toBeInTheDocument(); + }); + + it('does not render the `incremental_id` when not provided', () => { + renderWithTestingProviders( + + + + ); + + expect(screen.queryByTestId('cases-incremental-id-text')).not.toBeInTheDocument(); + }); + it('DOES NOT render the back link when not provided', () => { const wrapper = mount( diff --git a/x-pack/platform/plugins/shared/cases/public/components/header_page/index.tsx b/x-pack/platform/plugins/shared/cases/public/components/header_page/index.tsx index d91b8e6ffa6b9..c68f81914a305 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/header_page/index.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/header_page/index.tsx @@ -12,6 +12,7 @@ import { css } from '@emotion/react'; import { Title } from './title'; import { useCasesContext } from '../cases_context/use_cases_context'; +import { IncrementalIdText } from '../incremental_id'; interface HeaderProps { border?: boolean; @@ -22,6 +23,7 @@ export interface HeaderPageProps extends HeaderProps { children?: React.ReactNode; title: string | React.ReactNode; titleNode?: React.ReactElement; + incrementalId?: number | null; 'data-test-subj'?: string; } @@ -43,6 +45,7 @@ const HeaderPageComponent: React.FC = ({ isLoading, title, titleNode, + incrementalId, 'data-test-subj': dataTestSubj, }) => { const { releasePhase } = useCasesContext(); @@ -50,7 +53,7 @@ const HeaderPageComponent: React.FC = ({ return (
- + = ({ {border && isLoading && } - {children && ( = ({ )} + + {typeof incrementalId === 'number' && ( + + + + )} +
); }; diff --git a/x-pack/platform/plugins/shared/cases/public/components/incremental_id/index.test.tsx b/x-pack/platform/plugins/shared/cases/public/components/incremental_id/index.test.tsx new file mode 100644 index 0000000000000..cd3336f35c367 --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/public/components/incremental_id/index.test.tsx @@ -0,0 +1,18 @@ +/* + * 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 { renderWithTestingProviders } from '../../common/mock'; +import { screen } from '@testing-library/react'; +import { IncrementalIdText } from '.'; + +describe('IncrementalIdText', () => { + it('renders the incremental id', () => { + renderWithTestingProviders(); + expect(screen.getByTestId('cases-incremental-id-text')).toHaveTextContent('#1337'); + }); +}); diff --git a/x-pack/platform/plugins/shared/cases/public/components/incremental_id/index.tsx b/x-pack/platform/plugins/shared/cases/public/components/incremental_id/index.tsx new file mode 100644 index 0000000000000..69134fcb36a49 --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/public/components/incremental_id/index.tsx @@ -0,0 +1,24 @@ +/* + * 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 { EuiText } from '@elastic/eui'; +import React from 'react'; +import { css } from '@emotion/react'; + +export const IncrementalIdText = React.memo<{ incrementalId: number }>(({ incrementalId }) => ( + + {'#'} + {incrementalId} + +)); +IncrementalIdText.displayName = 'IncrementalIdText'; diff --git a/x-pack/platform/plugins/shared/cases/public/components/use_breadcrumbs/index.test.tsx b/x-pack/platform/plugins/shared/cases/public/components/use_breadcrumbs/index.test.tsx index b494bd1d81839..2848f8de76227 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/use_breadcrumbs/index.test.tsx +++ b/x-pack/platform/plugins/shared/cases/public/components/use_breadcrumbs/index.test.tsx @@ -26,6 +26,9 @@ jest.mock('../../common/lib/kibana', () => { KibanaServices: { ...originalModule.KibanaServices, get: () => mockGetKibanaServices(), + getConfig: () => ({ + incrementalId: { enabled: true }, + }), }, useNavigation: jest.fn().mockReturnValue({ getAppUrl: jest.fn((params?: { deepLinkId: string }) => params?.deepLinkId ?? '/test'), diff --git a/x-pack/platform/plugins/shared/cases/public/containers/constants.ts b/x-pack/platform/plugins/shared/cases/public/containers/constants.ts index 5c5158204374f..dde62c3f464f2 100644 --- a/x-pack/platform/plugins/shared/cases/public/containers/constants.ts +++ b/x-pack/platform/plugins/shared/cases/public/containers/constants.ts @@ -77,7 +77,7 @@ export const inferenceKeys = { getConnectors: () => ['get-inference-connectors'] as const, }; -const DEFAULT_SEARCH_FIELDS = ['title', 'description']; +const DEFAULT_SEARCH_FIELDS = ['title', 'description', 'incremental_id.text']; // TODO: Remove reporters. Move searchFields to API. export const DEFAULT_FILTER_OPTIONS: FilterOptions = { diff --git a/x-pack/platform/plugins/shared/cases/public/containers/mock.ts b/x-pack/platform/plugins/shared/cases/public/containers/mock.ts index 9065033892b4a..eeb5d20b706a7 100644 --- a/x-pack/platform/plugins/shared/cases/public/containers/mock.ts +++ b/x-pack/platform/plugins/shared/cases/public/containers/mock.ts @@ -490,8 +490,15 @@ export const cases: CasesUI = [ comments: [], status: CaseStatuses['in-progress'], severity: CaseSeverity.MEDIUM, + incrementalId: 1, + }, + { + ...pushedCase, + updatedAt: laterTime, + id: '2', + totalComment: 0, + comments: [], }, - { ...pushedCase, updatedAt: laterTime, id: '2', totalComment: 0, comments: [] }, { ...basicCase, id: '3', totalComment: 0, comments: [] }, { ...basicCase, id: '4', totalComment: 0, comments: [] }, caseWithAlerts, @@ -659,6 +666,7 @@ export const casesSnake: Cases = [ { ...pushedCaseSnake, id: '1', + incremental_id: 1, totalComment: 0, comments: [], status: CaseStatuses['in-progress'], diff --git a/x-pack/platform/plugins/shared/cases/public/containers/use_get_cases.test.tsx b/x-pack/platform/plugins/shared/cases/public/containers/use_get_cases.test.tsx index fa6af9ad0dd7e..9e2713cc70b73 100644 --- a/x-pack/platform/plugins/shared/cases/public/containers/use_get_cases.test.tsx +++ b/x-pack/platform/plugins/shared/cases/public/containers/use_get_cases.test.tsx @@ -149,4 +149,50 @@ describe('useGetCases', () => { signal: abortCtrl.signal, }); }); + + it('should change search and searchFields for incremental id searches', async () => { + const spyOnGetCases = jest.spyOn(api, 'getCases'); + + renderHook(() => useGetCases({ filterOptions: { search: '#123' } }), { + wrapper: (props) => , + }); + + await waitFor(() => { + expect(spyOnGetCases).toHaveBeenCalled(); + }); + + expect(spyOnGetCases).toBeCalledWith({ + filterOptions: { + ...DEFAULT_FILTER_OPTIONS, + search: '123', + searchFields: ['incremental_id.text'], + owner: ['securitySolution'], + }, + queryParams: DEFAULT_QUERY_PARAMS, + signal: abortCtrl.signal, + }); + }); + + it('should change search and searchFields when incremental id and title are provided', async () => { + const spyOnGetCases = jest.spyOn(api, 'getCases'); + + renderHook(() => useGetCases({ filterOptions: { search: 'test #123' } }), { + wrapper: (props) => , + }); + + await waitFor(() => { + expect(spyOnGetCases).toHaveBeenCalled(); + }); + + expect(spyOnGetCases).toBeCalledWith({ + filterOptions: { + ...DEFAULT_FILTER_OPTIONS, + search: 'test #123', + searchFields: ['title', 'description', 'incremental_id.text'], + owner: ['securitySolution'], + }, + queryParams: DEFAULT_QUERY_PARAMS, + signal: abortCtrl.signal, + }); + }); }); diff --git a/x-pack/platform/plugins/shared/cases/public/containers/use_get_cases.tsx b/x-pack/platform/plugins/shared/cases/public/containers/use_get_cases.tsx index 327f1a99cbe9b..0dc692ec78fba 100644 --- a/x-pack/platform/plugins/shared/cases/public/containers/use_get_cases.tsx +++ b/x-pack/platform/plugins/shared/cases/public/containers/use_get_cases.tsx @@ -16,6 +16,7 @@ import type { ServerError } from '../types'; import { useCasesContext } from '../components/cases_context/use_cases_context'; import { useAvailableCasesOwners } from '../components/app/use_available_owners'; import { getAllPermissionsExceptFrom } from '../utils/permissions'; +import { getIncrementalIdSearchOverrides } from './utils'; export const initialData: CasesFindResponseUI = { cases: [], @@ -45,6 +46,9 @@ export const useGetCases = ( ? { owner: params.filterOptions.owner } : { owner: initialOwner }; + // overrides for incremental_id search + const overrides = getIncrementalIdSearchOverrides(params.filterOptions?.search ?? ''); + return useQuery( casesQueriesKeys.cases(params), ({ signal }) => { @@ -53,6 +57,7 @@ export const useGetCases = ( ...DEFAULT_FILTER_OPTIONS, ...(params.filterOptions ?? {}), ...ownerFilter, + ...overrides, }, queryParams: { ...DEFAULT_QUERY_PARAMS, diff --git a/x-pack/platform/plugins/shared/cases/public/containers/utils.test.ts b/x-pack/platform/plugins/shared/cases/public/containers/utils.test.ts index bb784686df6f1..1d6a950118635 100644 --- a/x-pack/platform/plugins/shared/cases/public/containers/utils.test.ts +++ b/x-pack/platform/plugins/shared/cases/public/containers/utils.test.ts @@ -12,6 +12,7 @@ import { constructAssigneesFilter, constructReportersFilter, constructCustomFieldsFilter, + getIncrementalIdSearchOverrides, } from './utils'; import type { CaseUI } from './types'; @@ -219,4 +220,35 @@ describe('utils', () => { }); }); }); + + describe('getIncrementalIdSearchOverrides', () => { + it('returns an empty object if the search is not an incremental id search', () => { + const shouldReturnEmpty = [ + '', + ' ', + 'test', + '123', + 'abc', + '#abc', + '##123', + '##123##', + '#123 abc', + ]; + + shouldReturnEmpty.forEach((search) => { + expect(getIncrementalIdSearchOverrides(search)).toEqual({}); + }); + }); + + it('returns the correct overrides for an incremental id search', () => { + expect(getIncrementalIdSearchOverrides('#123')).toEqual({ + searchFields: ['incremental_id.text'], + search: '123', + }); + expect(getIncrementalIdSearchOverrides(' #123 ')).toEqual({ + searchFields: ['incremental_id.text'], + search: '123', + }); + }); + }); }); diff --git a/x-pack/platform/plugins/shared/cases/public/containers/utils.ts b/x-pack/platform/plugins/shared/cases/public/containers/utils.ts index 06e8b891479f5..8fbe4d8365526 100644 --- a/x-pack/platform/plugins/shared/cases/public/containers/utils.ts +++ b/x-pack/platform/plugins/shared/cases/public/containers/utils.ts @@ -234,3 +234,22 @@ export const constructCustomFieldsFilter = ( } : {}; }; + +export const getIncrementalIdSearchOverrides = (search: string) => { + const incrementalIdRegEx = /^#(\d{1,50})\s*$/; + // overrides for incremental_id search + let overrides: Partial = {}; + let trimmedSearch = search?.trim(); + const isIncrementalIdSearch = incrementalIdRegEx.test(trimmedSearch ?? ''); + if (trimmedSearch && isIncrementalIdSearch) { + // extract the number portion of the inc id search: #123 -> 123 + trimmedSearch = incrementalIdRegEx.exec(trimmedSearch)?.[1] ?? trimmedSearch; + // search only in `incremental_id` since types with `title` + // and `description` don't overlap + overrides = { + searchFields: ['incremental_id.text'], + search: trimmedSearch, + }; + } + return overrides; +}; diff --git a/x-pack/platform/plugins/shared/cases/public/plugin.test.ts b/x-pack/platform/plugins/shared/cases/public/plugin.test.ts index 3208addce1a14..7f37691055bd2 100644 --- a/x-pack/platform/plugins/shared/cases/public/plugin.test.ts +++ b/x-pack/platform/plugins/shared/cases/public/plugin.test.ts @@ -30,6 +30,7 @@ function getConfig(overrides = {}) { markdownPlugins: { lens: true }, files: { maxSize: 1, allowedMimeTypes: ALLOWED_MIME_TYPES }, stack: { enabled: true }, + incrementalId: { enabled: true }, ...overrides, }; } diff --git a/x-pack/platform/plugins/shared/cases/scripts/cases_generator.ts b/x-pack/platform/plugins/shared/cases/scripts/cases_generator.ts index 40fe0538d3e10..3ebfdbd2f047e 100644 --- a/x-pack/platform/plugins/shared/cases/scripts/cases_generator.ts +++ b/x-pack/platform/plugins/shared/cases/scripts/cases_generator.ts @@ -155,12 +155,19 @@ const generateCases = async ({ `Creating ${cases.length} cases in ${space ? `space: ${space}` : 'default space'}` ); const path = `${space ? `/s/${space}` : ''}/api/cases`; + const concurrency = 100; await pMap( cases, - (newCase) => { + (newCase, index) => { + if (index % concurrency === 0) { + const caseCount = cases.length; + console.info( + `CREATING CASES ${index + 1} to ${Math.min(index + concurrency, caseCount)}` + ); + } return makeRequest({ url: kibana, path, newCase, username, password, apiKey, ssl }); }, - { concurrency: 100 } + { concurrency } ); } catch (error) { toolingLogger.error(error); diff --git a/x-pack/platform/plugins/shared/cases/server/client/cases/search.test.ts b/x-pack/platform/plugins/shared/cases/server/client/cases/search.test.ts index 0bf0eb6d61320..410e02d0d9e65 100644 --- a/x-pack/platform/plugins/shared/cases/server/client/cases/search.test.ts +++ b/x-pack/platform/plugins/shared/cases/server/client/cases/search.test.ts @@ -154,7 +154,7 @@ describe('search', () => { // @ts-expect-error foo is an invalid field search({ ...findRequest, foo: 'bar' }, clientArgs) ).rejects.toThrowErrorMatchingInlineSnapshot( - `"Failed to find cases: {\\"search\\":\\"sample_text\\",\\"searchFields\\":[\\"title\\",\\"description\\"],\\"severity\\":\\"low\\",\\"assignees\\":[],\\"reporters\\":[],\\"status\\":\\"open\\",\\"tags\\":[],\\"owner\\":[],\\"sortField\\":\\"createdAt\\",\\"sortOrder\\":\\"desc\\",\\"customFields\\":{},\\"foo\\":\\"bar\\"}: Error: invalid keys \\"foo\\""` + `"Failed to find cases: {\\"search\\":\\"sample_text\\",\\"searchFields\\":[\\"title\\",\\"description\\",\\"incremental_id.text\\"],\\"severity\\":\\"low\\",\\"assignees\\":[],\\"reporters\\":[],\\"status\\":\\"open\\",\\"tags\\":[],\\"owner\\":[],\\"sortField\\":\\"createdAt\\",\\"sortOrder\\":\\"desc\\",\\"customFields\\":{},\\"foo\\":\\"bar\\"}: Error: invalid keys \\"foo\\""` ); }); diff --git a/x-pack/platform/plugins/shared/cases/server/client/mocks.ts b/x-pack/platform/plugins/shared/cases/server/client/mocks.ts index 6d55effe02574..052426db92170 100644 --- a/x-pack/platform/plugins/shared/cases/server/client/mocks.ts +++ b/x-pack/platform/plugins/shared/cases/server/client/mocks.ts @@ -261,7 +261,7 @@ export const createCasesClientMockSearchRequest = ( overwrites?: CasesSearchRequest ): CasesSearchRequest => ({ search: '', - searchFields: ['title', 'description'], + searchFields: ['title', 'description', 'incremental_id.text'], severity: CaseSeverity.LOW, assignees: [], reporters: [], diff --git a/x-pack/platform/plugins/shared/cases/server/common/types/id_incrementer.ts b/x-pack/platform/plugins/shared/cases/server/common/types/id_incrementer.ts new file mode 100644 index 0000000000000..fb5b407178331 --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/server/common/types/id_incrementer.ts @@ -0,0 +1,21 @@ +/* + * 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 { SavedObject } from '@kbn/core-saved-objects-server'; +import { CaseIdIncrementerAttributesRt } from '../../../common/types/domain/incremental_id/latest'; + +export interface CaseIdIncrementerPersistedAttributes { + '@timestamp': number; + last_id: number; + updated_at: number; +} + +export type CaseIdIncrementerTransformedAttributes = CaseIdIncrementerPersistedAttributes; + +export const CaseIdIncrementerTransformedAttributesRt = CaseIdIncrementerAttributesRt; + +export type CaseIdIncrementerSavedObject = SavedObject; diff --git a/x-pack/platform/plugins/shared/cases/server/config.test.ts b/x-pack/platform/plugins/shared/cases/server/config.test.ts index f64285e5ad42e..32440cefb02d9 100644 --- a/x-pack/platform/plugins/shared/cases/server/config.test.ts +++ b/x-pack/platform/plugins/shared/cases/server/config.test.ts @@ -109,6 +109,11 @@ describe('config validation', () => { "application/pdf", ], }, + "incrementalId": Object { + "enabled": false, + "taskIntervalMinutes": 10, + "taskStartDelayMinutes": 10, + }, "markdownPlugins": Object { "lens": true, }, diff --git a/x-pack/platform/plugins/shared/cases/server/config.ts b/x-pack/platform/plugins/shared/cases/server/config.ts index 0af9e63ba5ffe..0684cdb4f3930 100644 --- a/x-pack/platform/plugins/shared/cases/server/config.ts +++ b/x-pack/platform/plugins/shared/cases/server/config.ts @@ -8,6 +8,10 @@ import type { TypeOf } from '@kbn/config-schema'; import { schema, offeringBasedSchema } from '@kbn/config-schema'; import { ALLOWED_MIME_TYPES } from '../common/constants/mime_types'; +import { + DEFAULT_TASK_INTERVAL_MINUTES, + DEFAULT_TASK_START_DELAY_MINUTES, +} from '../common/constants/incremental_id'; export const ConfigSchema = schema.object({ markdownPlugins: schema.object({ @@ -23,6 +27,26 @@ export const ConfigSchema = schema.object({ stack: schema.object({ enabled: schema.boolean({ defaultValue: true }), }), + incrementalId: schema.object({ + /** + * Whether the incremental id service should be enabled + */ + enabled: schema.boolean({ defaultValue: false }), + /** + * The interval that the task should be scheduled at + */ + taskIntervalMinutes: schema.number({ + defaultValue: DEFAULT_TASK_INTERVAL_MINUTES, + min: 5, + }), + /** + * The initial delay the task will be started with + */ + taskStartDelayMinutes: schema.number({ + defaultValue: DEFAULT_TASK_START_DELAY_MINUTES, + min: 1, + }), + }), analytics: schema.object({ index: schema.object({ enabled: offeringBasedSchema({ diff --git a/x-pack/platform/plugins/shared/cases/server/index.ts b/x-pack/platform/plugins/shared/cases/server/index.ts index c9c163c5aa1f1..de9d0d0c56901 100644 --- a/x-pack/platform/plugins/shared/cases/server/index.ts +++ b/x-pack/platform/plugins/shared/cases/server/index.ts @@ -17,6 +17,9 @@ export const config: PluginConfigDescriptor = { files: { maxSize: true, allowedMimeTypes: true }, stack: { enabled: true }, unsafe: { enableCaseSummary: true }, + incrementalId: { + enabled: true, + }, }, deprecations: ({ renameFromRoot }) => [ renameFromRoot('xpack.case.enabled', 'xpack.cases.enabled', { level: 'critical' }), diff --git a/x-pack/platform/plugins/shared/cases/server/mocks.ts b/x-pack/platform/plugins/shared/cases/server/mocks.ts index 3aca8d0b994d1..e6b8c80885ae6 100644 --- a/x-pack/platform/plugins/shared/cases/server/mocks.ts +++ b/x-pack/platform/plugins/shared/cases/server/mocks.ts @@ -763,6 +763,11 @@ export const mockCasesContract = (): CasesServerStart => ({ enabled: true, }, }, + incrementalId: { + enabled: true, + taskIntervalMinutes: 10, + taskStartDelayMinutes: 10, + }, }, }); diff --git a/x-pack/platform/plugins/shared/cases/server/plugin.test.ts b/x-pack/platform/plugins/shared/cases/server/plugin.test.ts index fe33e352c8ebd..3ed1196181b09 100644 --- a/x-pack/platform/plugins/shared/cases/server/plugin.test.ts +++ b/x-pack/platform/plugins/shared/cases/server/plugin.test.ts @@ -23,12 +23,13 @@ import type { ConfigType } from './config'; import { ALLOWED_MIME_TYPES } from '../common/constants/mime_types'; import type { CasesServerSetupDependencies, CasesServerStartDependencies } from './types'; -function getConfig(overrides = {}) { +function getConfig(overrides: Partial = {}): ConfigType { return { enabled: true, markdownPlugins: { lens: true }, files: { maxSize: 1, allowedMimeTypes: ALLOWED_MIME_TYPES }, stack: { enabled: true }, + incrementalId: { enabled: true, taskIntervalMinutes: 10, taskStartDelayMinutes: 10 }, analytics: { index: { enabled: true } }, ...overrides, }; @@ -216,6 +217,11 @@ describe('Cases Plugin', () => { ], "maxSize": 1, }, + "incrementalId": Object { + "enabled": true, + "taskIntervalMinutes": 10, + "taskStartDelayMinutes": 10, + }, "markdownPlugins": Object { "lens": true, }, diff --git a/x-pack/platform/plugins/shared/cases/server/plugin.ts b/x-pack/platform/plugins/shared/cases/server/plugin.ts index 07de86e9e8d1d..c7ae5fa01cc62 100644 --- a/x-pack/platform/plugins/shared/cases/server/plugin.ts +++ b/x-pack/platform/plugins/shared/cases/server/plugin.ts @@ -52,6 +52,7 @@ import { registerConnectorTypes } from './connectors'; import { registerSavedObjects } from './saved_object_types'; import type { ServerlessProjectType } from '../common/constants/types'; +import { IncrementalIdTaskManager } from './tasks/incremental_id/incremental_id_task_manager'; import { createCasesAnalyticsIndexes, registerCasesAnalyticsIndexesTasks } from './cases_analytics'; import { scheduleCAISchedulerTask } from './cases_analytics/tasks/scheduler_task'; @@ -73,6 +74,7 @@ export class CasePlugin private persistableStateAttachmentTypeRegistry: PersistableStateAttachmentTypeRegistry; private externalReferenceAttachmentTypeRegistry: ExternalReferenceAttachmentTypeRegistry; private userProfileService: UserProfileService; + private incrementalIdTaskManager?: IncrementalIdTaskManager; private readonly isServerless: boolean; constructor(private readonly initializerContext: PluginInitializerContext) { @@ -135,14 +137,25 @@ export class CasePlugin }) ); - if (plugins.taskManager && plugins.usageCollection) { - createCasesTelemetry({ - core, - taskManager: plugins.taskManager, - usageCollection: plugins.usageCollection, - logger: this.logger, - kibanaVersion: this.kibanaVersion, - }); + if (plugins.taskManager) { + if (plugins.usageCollection) { + createCasesTelemetry({ + core, + taskManager: plugins.taskManager, + usageCollection: plugins.usageCollection, + logger: this.logger, + kibanaVersion: this.kibanaVersion, + }); + } + + if (this.caseConfig.incrementalId.enabled) { + this.incrementalIdTaskManager = new IncrementalIdTaskManager( + plugins.taskManager, + this.caseConfig.incrementalId, + this.logger, + plugins.usageCollection + ); + } } const router = core.http.createRouter(); @@ -208,6 +221,9 @@ export class CasePlugin if (plugins.taskManager) { scheduleCasesTelemetryTask(plugins.taskManager, this.logger); + if (this.caseConfig.incrementalId.enabled) { + void this.incrementalIdTaskManager?.setupIncrementIdTask(plugins.taskManager, core); + } if (this.caseConfig.analytics.index?.enabled) { const internalSavedObjectsRepository = core.savedObjects.createInternalRepository([ CASE_SAVED_OBJECT, diff --git a/x-pack/platform/plugins/shared/cases/server/services/incremental_id/index.test.ts b/x-pack/platform/plugins/shared/cases/server/services/incremental_id/index.test.ts new file mode 100644 index 0000000000000..aecdae15fb075 --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/server/services/incremental_id/index.test.ts @@ -0,0 +1,354 @@ +/* + * 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 { CasesIncrementalIdService } from '.'; +import { CASE_SAVED_OBJECT } from '../../../common/constants'; +import { savedObjectsClientMock } from '@kbn/core/server/mocks'; +import { loggerMock } from '@kbn/logging-mocks'; + +describe('CasesIncrementalIdService', () => { + const savedObjectsClient = savedObjectsClientMock.create(); + const mockLogger = loggerMock.create(); + let service: CasesIncrementalIdService; + + beforeEach(() => { + service = new CasesIncrementalIdService(savedObjectsClient, mockLogger); + }); + + describe('getLastAppliedIdForSpace', () => { + it('should return the last applied id for the space', async () => { + const LAST_APPLIED_ID = 101; + const spaceId = 'spaceId'; + savedObjectsClient.find.mockResolvedValue({ + total: 1, + saved_objects: [ + // @ts-expect-error: SO client types are not correct + { + attributes: { + incremental_id: LAST_APPLIED_ID, + }, + }, + ], + }); + + const result = await service.getLastAppliedIdForSpace(spaceId); + + expect(result).toEqual(LAST_APPLIED_ID); + expect(savedObjectsClient.find).toHaveBeenCalledWith({ + filter: CasesIncrementalIdService.incrementalIdExistsFilter, + type: CASE_SAVED_OBJECT, + perPage: 1, + page: 1, + namespaces: [spaceId], + sortField: 'incremental_id', + sortOrder: 'desc', + }); + }); + + it('should return 0 if no cases could be found', async () => { + const spaceId = 'spaceId'; + // @ts-expect-error: SO client types are not correct + savedObjectsClient.find.mockResolvedValue({ + total: 0, + }); + + const result = await service.getLastAppliedIdForSpace(spaceId); + + expect(result).toEqual(0); + }); + + it('should return 0 if the case is missing incremental_id', async () => { + const spaceId = 'spaceId'; + savedObjectsClient.find.mockResolvedValue({ + total: 1, + saved_objects: [ + // @ts-expect-error: SO client types are not correct + { + attributes: {}, + }, + ], + }); + + const result = await service.getLastAppliedIdForSpace(spaceId); + + expect(result).toEqual(0); + }); + }); + + describe('getOrCreateCaseIdIncrementerSo', () => { + it('should return the incrementer SO when the incremental IDs match', async () => { + const lastId = 100; + const incIdSo = { attributes: { last_id: lastId } }; + service.getLastAppliedIdForSpace = jest.fn().mockReturnValue(lastId); + service.getCaseIdIncrementerSo = jest.fn().mockReturnValue({ + total: 1, + saved_objects: [incIdSo], + }); + const result = await service.getOrCreateCaseIdIncrementerSo('random'); + expect(result).toStrictEqual(incIdSo); + }); + + it('should return the incrementer SO even if `last_id` is 0', async () => { + const lastId = 0; + const incIdSo = { attributes: { last_id: lastId } }; + service.getLastAppliedIdForSpace = jest.fn().mockReturnValue(lastId); + service.getCaseIdIncrementerSo = jest.fn().mockReturnValue({ + total: 1, + saved_objects: [incIdSo], + }); + const result = await service.getOrCreateCaseIdIncrementerSo('random'); + expect(result).toStrictEqual(incIdSo); + }); + + it('should increase and persist `last_id` in case the last applied ID to a case is higher than in the inc ID SO', async () => { + const incIdLastId = 100; + const lastAppliedId = 5610; + const incIdSo = { attributes: { last_id: incIdLastId } }; + service.getLastAppliedIdForSpace = jest.fn().mockReturnValue(lastAppliedId); + service.getCaseIdIncrementerSo = jest.fn().mockReturnValue({ + total: 1, + saved_objects: [incIdSo], + }); + service.incrementCounterSO = jest.fn().mockImplementation(service.incrementCounterSO); + const result = await service.getOrCreateCaseIdIncrementerSo('random'); + expect(result.attributes.last_id).toBe(lastAppliedId); + expect(service.incrementCounterSO).toHaveBeenCalledWith(incIdSo, lastAppliedId, 'random'); + }); + + it('should not increase `last_id` and not persist in case the last applied ID to a case is lower than in the inc ID SO', async () => { + const incIdLastId = 200; + const lastAppliedId = 100; + const incIdSo = { attributes: { last_id: incIdLastId } }; + service.getLastAppliedIdForSpace = jest.fn().mockReturnValue(lastAppliedId); + service.getCaseIdIncrementerSo = jest.fn().mockReturnValue({ + total: 1, + saved_objects: [incIdSo], + }); + service.incrementCounterSO = jest.fn().mockImplementation(service.incrementCounterSO); + const result = await service.getOrCreateCaseIdIncrementerSo('random'); + expect(result.attributes.last_id).toBe(incIdLastId); + expect(service.incrementCounterSO).not.toHaveBeenCalled(); + }); + + it('should initiate the resolution of multiple inc ID SOs', async () => { + const lastId = 100; + const incIdSo = { attributes: { last_id: lastId } }; + service.getLastAppliedIdForSpace = jest.fn().mockReturnValue(lastId); + service.getCaseIdIncrementerSo = jest.fn().mockReturnValue({ + total: 2, + saved_objects: [incIdSo, incIdSo], + }); + service.resolveMultipleIncrementerSO = jest.fn(); + await service.getOrCreateCaseIdIncrementerSo('random'); + expect(service.resolveMultipleIncrementerSO).toHaveBeenCalled(); + }); + }); + + describe('resolveMultipleIncrementerSO', () => { + it('should return the correct incrementer SO', async () => { + const so1 = { attributes: { last_id: 10 } }; + const so2 = { attributes: { last_id: 100 } }; + const so3 = { attributes: { last_id: 1000 } }; + const incrementerSOs = [so1, so2, so3]; + + // @ts-expect-error: SO client types are not correct + const result = await service.resolveMultipleIncrementerSO(incrementerSOs, 20, 'default'); + + expect(savedObjectsClient.bulkDelete).toHaveBeenCalledWith([so1, so2]); + expect(result.attributes.last_id).toBe(so3.attributes.last_id); + }); + + it('should return the correct incrementer SO when SOs come in a random order', async () => { + const so1 = { attributes: { last_id: 10 } }; + const so2 = { attributes: { last_id: 100 } }; + const so3 = { attributes: { last_id: 1000 } }; + const incrementerSOs = [so1, so3, so2]; + + // @ts-expect-error: SO client types are not correct + const result = await service.resolveMultipleIncrementerSO(incrementerSOs, 20, 'default'); + + expect(savedObjectsClient.bulkDelete).toHaveBeenCalledWith([so1, so2]); + expect(result.attributes.last_id).toBe(so3.attributes.last_id); + }); + + it("should update the incrementer SO's count when `lastAppliedId` is bigger than it's `last_id`", async () => { + const so1 = { attributes: { last_id: 10 } }; + const so2 = { attributes: { last_id: 100 } }; + const so3 = { attributes: { last_id: 1000 } }; + const incrementerSOs = [so3, so1, so2]; + + service.incrementCounterSO = jest.fn(); + + // @ts-expect-error: SO client types are not correct + await service.resolveMultipleIncrementerSO(incrementerSOs, 20000, 'default'); + + expect(service.incrementCounterSO).toHaveBeenCalledWith(so3, 20000, 'default'); + }); + + it('should create a new incrementer SO when no max could be found', async () => { + const incrementerSOs: unknown = []; + + service.createCaseIdIncrementerSo = jest.fn(); + + // @ts-expect-error: SO client types are not correct + await service.resolveMultipleIncrementerSO(incrementerSOs, 20000, 'default'); + + expect(service.createCaseIdIncrementerSo).toHaveBeenCalledWith('default', 20000); + }); + }); + + describe('incrementCaseIds', () => { + function getTestCases() { + return [ + { attributes: {}, namespaces: ['default'] }, + { attributes: {}, namespaces: ['second-life'] }, + { attributes: {}, namespaces: ['second-life'] }, + { attributes: {}, namespaces: ['default'] }, + { attributes: {}, namespaces: ['second-life'] }, + ]; + } + let cases: ReturnType = []; + let defaultIncIdSo = { attributes: { last_id: 100 } }; + let secondLifeIncIdSo = { attributes: { last_id: 10 } }; + + beforeEach(() => { + defaultIncIdSo = { attributes: { last_id: 100 } }; + secondLifeIncIdSo = { attributes: { last_id: 10 } }; + cases = getTestCases(); + service.getOrCreateCaseIdIncrementerSo = jest.fn().mockImplementation((namespace) => { + switch (namespace) { + case 'default': + return defaultIncIdSo; + case 'second-life': + return secondLifeIncIdSo; + } + }); + // mock out persistence, their logic is tested individually + service.applyIncrementalIdToCaseSo = jest.fn().mockResolvedValue(null); + service.incrementCounterSO = jest.fn().mockResolvedValue(null); + }); + + it('should increment the incremental case ids and inc id SOs correctly', async () => { + // @ts-expect-error: case SO types are not correct + await service.incrementCaseIds(cases); + + // These calls need to be tested in order + expect(service.applyIncrementalIdToCaseSo).toHaveBeenNthCalledWith( + 1, + cases[0], + 101, + 'default' + ); + expect(service.applyIncrementalIdToCaseSo).toHaveBeenNthCalledWith( + 2, + cases[1], + 11, + 'second-life' + ); + expect(service.applyIncrementalIdToCaseSo).toHaveBeenNthCalledWith( + 3, + cases[2], + 12, + 'second-life' + ); + expect(service.applyIncrementalIdToCaseSo).toHaveBeenNthCalledWith( + 4, + cases[3], + 102, + 'default' + ); + expect(service.applyIncrementalIdToCaseSo).toHaveBeenNthCalledWith( + 5, + cases[4], + 13, + 'second-life' + ); + + // For these, the order of calls does not matter + expect(service.incrementCounterSO).toHaveBeenCalledWith(defaultIncIdSo, 102, 'default'); + expect(service.incrementCounterSO).toHaveBeenCalledWith(secondLifeIncIdSo, 13, 'second-life'); + }); + + it('should not start processing when the service was stopped', async () => { + service.stopService(); + // @ts-expect-error: case SO types are not correct + await service.incrementCaseIds(cases); + expect(service.applyIncrementalIdToCaseSo).not.toHaveBeenCalled(); + expect(service.incrementCounterSO).not.toHaveBeenCalled(); + }); + + it('should stop processing when service was stopped mid-processing', async () => { + // This test simulates the service being stopped while it's processing. + // Each `mockImplementationOnce` represents the processing step of one of the cases. + // The first resolves, the second one is started, then the service is stopped and it resolves. + // The third one is not reached. + service.applyIncrementalIdToCaseSo = jest + .fn() + .mockImplementationOnce(() => Promise.resolve(null)) + .mockImplementationOnce(() => { + // the service is stopped before this case could be written + service.stopService(); + return Promise.resolve(); + }) + .mockImplementationOnce(() => Promise.resolve(null)); + + // @ts-expect-error: case SO types are not correct + await service.incrementCaseIds([cases[0], cases[1], cases[2]]); + // The service was stopped asynchronously while the second case was writing. + // Therefore it should have stopped, and not processed the third case. + expect(service.applyIncrementalIdToCaseSo).toHaveBeenCalledTimes(3); + expect(service.applyIncrementalIdToCaseSo).toHaveBeenNthCalledWith( + 1, + cases[0], + 101, + 'default' + ); + expect(service.applyIncrementalIdToCaseSo).toHaveBeenNthCalledWith( + 2, + cases[1], + 11, + 'second-life' + ); + // The third call is to reset the incremental ID of the previous write + expect(service.applyIncrementalIdToCaseSo).toHaveBeenNthCalledWith( + 3, + cases[1], + null, + 'second-life' + ); + // The service is stopped and increment counter SOs are not updated + expect(service.incrementCounterSO).not.toHaveBeenCalled(); + }); + + it('should skip processing a case when it has no namespace attached', async () => { + // @ts-expect-error: case SO types are not correct + await service.incrementCaseIds([cases[0], cases[1], {}, cases[2]]); + + // called 3 times, even though 4 SOs have been passed + expect(service.applyIncrementalIdToCaseSo).toHaveBeenCalledTimes(3); + }); + + it('should stop processing when it was not possible to get or create an incrementer SO', async () => { + service.getOrCreateCaseIdIncrementerSo = jest.fn().mockRejectedValue(null); + + // @ts-expect-error: case SO types are not correct + await service.incrementCaseIds(cases); + + expect(service.applyIncrementalIdToCaseSo).not.toHaveBeenCalled(); + }); + + it('should stop processing when it was not possible to increment a case id', async () => { + service.applyIncrementalIdToCaseSo = jest.fn().mockRejectedValue(null); + + // @ts-expect-error: case SO types are not correct + await service.incrementCaseIds(cases); + + // Only called once, when it rejected + expect(service.applyIncrementalIdToCaseSo).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/cases/server/services/incremental_id/index.ts b/x-pack/platform/plugins/shared/cases/server/services/incremental_id/index.ts new file mode 100644 index 0000000000000..e797ad2ad66bf --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/server/services/incremental_id/index.ts @@ -0,0 +1,411 @@ +/* + * 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 { + SavedObjectsFindOptions, + SavedObjectsFindResult, + SavedObject, + SavedObjectsClientContract, + Logger, +} from '@kbn/core/server'; +import pRetry from 'p-retry'; +import { buildNode as buildWildcardNode } from '@kbn/es-query/src/kuery/node_types/wildcard'; +import { fromKueryExpression, nodeBuilder } from '@kbn/es-query'; +import { CASE_SAVED_OBJECT, CASE_ID_INCREMENTER_SAVED_OBJECT } from '../../../common/constants'; +import type { CasePersistedAttributes } from '../../common/types/case'; +import type { + CaseIdIncrementerPersistedAttributes, + CaseIdIncrementerSavedObject, +} from '../../common/types/id_incrementer'; + +type GetCasesParameters = Pick< + SavedObjectsFindOptions, + 'sortField' | 'sortOrder' | 'perPage' | 'page' | 'filter' | 'namespaces' +>; + +export class CasesIncrementalIdService { + static incrementalIdExistsFilter = nodeBuilder.is( + `${CASE_SAVED_OBJECT}.attributes.incremental_id`, + buildWildcardNode('*') + ); + static incrementalIdMissingFilter = fromKueryExpression( + `not ${CASE_SAVED_OBJECT}.attributes.incremental_id: *` + ); + private isStopped = false; + + constructor( + private internalSavedObjectsClient: SavedObjectsClientContract, + private logger: Logger + ) { + this.logger = logger.get('incremental_id_service'); + this.logger.debug('Cases incremental ID service initialized'); + } + + public stopService() { + this.isStopped = true; + } + public startService() { + this.isStopped = false; + } + + public async getCasesWithoutIncrementalId(parameters: Omit = {}) { + return this.getCases({ + ...parameters, + filter: CasesIncrementalIdService.incrementalIdMissingFilter, + }); + } + + public async getCases({ + filter, + perPage = 1000, + page = 1, + sortOrder = 'asc', + sortField = 'created_at', + namespaces = ['*'], + }: GetCasesParameters) { + try { + const savedCases = await this.internalSavedObjectsClient.find({ + type: CASE_SAVED_OBJECT, + sortField, + sortOrder, + perPage, + page, + filter, + namespaces, + }); + return savedCases; + } catch (error) { + this.logger.error(error); + throw error; + } + } + + /** + * Get the latest applied ID for a given space. + * Uses the actually applied numerical ids on cases in the space. + */ + public async getLastAppliedIdForSpace(namespace: string) { + try { + const casesResponse = await this.getCases({ + filter: CasesIncrementalIdService.incrementalIdExistsFilter, + namespaces: [namespace], + sortField: 'incremental_id', + sortOrder: 'desc', + perPage: 1, // We only need the most recent incremental id value + page: 1, + }); + + if (casesResponse.total === 0) { + this.logger.debug(`No cases found with incremental id in ${namespace}`); + return 0; + } + + const mostRecentIncrementalId = casesResponse.saved_objects[0].attributes.incremental_id; + this.logger.debug( + `getLastAppliedIdForSpace (from cases): Most recent incremental id in ${namespace}: ${mostRecentIncrementalId}` + ); + + if (mostRecentIncrementalId === undefined || mostRecentIncrementalId === null) { + return 0; + } + + return mostRecentIncrementalId; + } catch (error) { + this.logger.error(error); + throw error; + } + } + + /** + * Increments the case ids for the given cases. + * @param casesWithoutIncrementalId The cases we want to apply IDs to + * @returns The amount of processed cases. + */ + public async incrementCaseIds( + casesWithoutIncrementalId: Array> + ): Promise { + let countProcessedCases = 0; + /** In-memory cache of the incremental ID SO changes that we will need to apply */ + const incIdSoCache: Map> = new Map(); + + let hasAppliedAnId = false; + + for (let index = 0; index < casesWithoutIncrementalId.length && !this.isStopped; index++) { + try { + const caseSo = casesWithoutIncrementalId[index]; + const namespaceOfCase = caseSo.namespaces?.[0]; + if (!namespaceOfCase) { + this.logger.error(`Case ${caseSo.id} has no namespace assigned. Skipping it.`); + // eslint-disable-next-line no-continue + continue; + } + + // Get the incremental id SO from the cache or fetch it + let incIdSo = incIdSoCache.get(namespaceOfCase); + if (!incIdSo) { + this.logger.debug( + `Don't have incrementer in cache, fetching it: namespace ${namespaceOfCase}` + ); + incIdSo = await this.getOrCreateCaseIdIncrementerSo(namespaceOfCase); + this.logger.debug( + `Fetched incrementer SO for ${namespaceOfCase}: ${JSON.stringify(incIdSo)}` + ); + incIdSoCache.set(namespaceOfCase, incIdSo); + } + + // Increase the inc id + const newId = incIdSo.attributes.last_id + 1; + // Apply the new ID to the case + await this.applyIncrementalIdToCaseSo(caseSo, newId, namespaceOfCase); + + if (this.isStopped) { + // The service was stopped while the last write was happening, + // we need to reset the previous incremental id, in order to avoid + // double-assigned id. + this.logger.warn('Need to reset incremental case id because service was stopped'); + await this.applyIncrementalIdToCaseSo(caseSo, null, namespaceOfCase); + } else { + // Apply the new ID to the local incrementer SO, it will persist later + incIdSo.attributes.last_id = newId; + hasAppliedAnId = true; + countProcessedCases++; + } + } catch (error) { + this.logger.error(`ID incrementing paused due to error: ${error}`); + break; + } + } + + // If changes have been made, apply the changes to the counters + // These are done in sequence, since we cannot guarantee that `incIdSoCache` is small. + // It might have hundreds/thousands of SO objects cached that need updating. + if (hasAppliedAnId && !this.isStopped) { + for (const [namespace, incIdSo] of incIdSoCache) { + await this.incrementCounterSO(incIdSo, incIdSo.attributes.last_id, namespace); + } + } + + return countProcessedCases; + } + + getCaseIdIncrementerSo(namespace: string) { + return this.internalSavedObjectsClient.find({ + type: CASE_ID_INCREMENTER_SAVED_OBJECT, + namespaces: [namespace], + }); + } + + /** + * Gets or creates the case id incrementer SO for the given namespace + * @param namespace The namespace of the case id incrementor so + */ + async getOrCreateCaseIdIncrementerSo( + namespace: string + ): Promise> { + try { + const [latestAppliedId, incrementerResponse] = await Promise.all([ + // Get the latest applied id by looking at the case saved objects + await this.getLastAppliedIdForSpace(namespace), + // Get the case id incrementer saved object + await this.getCaseIdIncrementerSo(namespace), + ]); + this.logger.debug(`Latest applied ID to a case for ${namespace}: ${latestAppliedId}`); + + const actualLatestId = latestAppliedId || 0; + + const incrementerSO = incrementerResponse?.saved_objects[0]; + + // We should not have multiple incrementer SO's per namespace, but if we do, let's resolve that + if (incrementerResponse.total > 1) { + this.logger.error( + `Only 1 incrementer should exist, but multiple incrementers found in ${namespace}. Resolving to max incrementer.` + ); + return this.resolveMultipleIncrementerSO( + incrementerResponse.saved_objects, + actualLatestId, + namespace + ); + } + + // Only one incrementer SO exists + if (incrementerResponse.total === 1) { + // If we have matching incremental ids, we're good + const idsMatch = actualLatestId === incrementerSO.attributes.last_id; + if (idsMatch || incrementerSO.attributes.last_id >= actualLatestId) { + this.logger.debug( + `Incrementer found for ${namespace} with matching or bigger id. No changes needed.` + ); + return incrementerSO; + } else { + // Otherwise, we're updating the incrementer SO to the highest value + this.logger.debug( + `Incrementer found for ${namespace} with id ${incrementerSO.attributes.last_id}. Updating to ${actualLatestId}.` + ); + return this.incrementCounterSO(incrementerSO, actualLatestId, namespace); + } + } else { + // At this point we assume that no incrementer SO exists + this.logger.debug(`No incrementer found for ${namespace}. Creating a new one.`); + return this.createCaseIdIncrementerSo(namespace); + } + } catch (error) { + throw new Error(`Unable to use an existing incrementer: ${error}`); + } + } + + /** + * Resolves the situation when multiple incrementer SOs exists + */ + public async resolveMultipleIncrementerSO( + incrementerQueryResponse: Array>, + latestAppliedId: number, + namespace: string + ) { + // Find the incrementer with the highest ID and the incrementers to delete + const { incrementerWithHighestId, incrementersToDelete } = incrementerQueryResponse.reduce( + (result, currIncrementer) => { + if (result.incrementerWithHighestId === currIncrementer) { + // don't do anything if we're comparing the same objects + return result; + } else { + // the current incrementer has a higher value, it becomes the new highest one + // the previous highest one is scheduled for deletion + if ( + currIncrementer.attributes.last_id > result.incrementerWithHighestId.attributes.last_id + ) { + result.incrementersToDelete.push(result.incrementerWithHighestId); + result.incrementerWithHighestId = currIncrementer; + } else { + // the current incrementer is not higher than the highest one, we're deleting it + result.incrementersToDelete.push(currIncrementer); + } + return result; + } + }, + { + incrementerWithHighestId: incrementerQueryResponse[0], + incrementersToDelete: [] as Array< + SavedObjectsFindResult + >, + } + ); + + try { + await this.internalSavedObjectsClient.bulkDelete(incrementersToDelete); + } catch (e) { + this.logger.debug('Could not delete all duplicate incrementers.'); + this.logger.error(e); + } + + // If a max incrementer exists, update it with the max value found + if (incrementerWithHighestId) { + if (incrementerWithHighestId.attributes.last_id >= latestAppliedId) { + return incrementerWithHighestId; + } else { + return this.incrementCounterSO(incrementerWithHighestId, latestAppliedId, namespace); + } + } else { + this.logger.debug( + `ResolveMultipleIncrementers: No incrementer found for ${namespace}. Creating a new one.` + ); + // If there is no max incrementer, create a new one + return this.createCaseIdIncrementerSo(namespace, latestAppliedId); + } + } + + /** + * Creates a case id incrementer SO for the given namespace + * @param namespace The namespace for the newly created case id incrementer SO + */ + public async createCaseIdIncrementerSo(namespace: string, lastId = 0) { + try { + const currentTime = new Date().getTime(); + const intializedIncrementalIdSo = + await this.internalSavedObjectsClient.create( + CASE_ID_INCREMENTER_SAVED_OBJECT, + { + last_id: lastId, + '@timestamp': currentTime, + updated_at: currentTime, + }, + { + namespace, + } + ); + return intializedIncrementalIdSo; + } catch (error) { + this.logger.error(`Unable to create incrementer due to error: ${error}`); + throw error; + } + } + + public async incrementCounterSO( + incrementerSo: CaseIdIncrementerSavedObject, + lastAppliedId: number, + namespace: string + ): Promise> { + try { + const updatedAttributes = { + last_id: lastAppliedId, + updated_at: new Date().getTime(), + }; + await this.internalSavedObjectsClient.update( + CASE_ID_INCREMENTER_SAVED_OBJECT, + incrementerSo.id, + updatedAttributes, + { + namespace, + } + ); + + // Manually updating the SO here because `SavedObjectsClient.update` + // returns a type with a `Partial` of the SO's attributes. + return { + ...incrementerSo, + attributes: { + ...incrementerSo.attributes, + ...updatedAttributes, + }, + }; + } catch (error) { + this.logger.error(`Unable to update incrementer due to error: ${error}`); + throw error; + } + } + + public async applyIncrementalIdToCaseSo( + currentCaseSo: SavedObjectsFindResult, + newIncrementalId: number | null, + namespace: string + ) { + // We shouldn't have to worry about version conflicts, as we're not modifying any existing fields + // just applying a new field + const updateCase = async () => { + await this.internalSavedObjectsClient.update( + CASE_SAVED_OBJECT, + currentCaseSo.id, + { incremental_id: newIncrementalId }, + { namespace } + ); + }; + + try { + await pRetry(updateCase, { + maxTimeout: 3000, + retries: 3, + factor: 2, + onFailedAttempt: (error) => { + this.logger.warn( + `Attempt ${error.attemptNumber} failed. There are ${error.retriesLeft} retries left.` + ); + }, + }); + } catch (err) { + this.logger.error(`Failed to apply incremental id ${newIncrementalId}`); + throw err; + } + } +} diff --git a/x-pack/platform/plugins/shared/cases/server/tasks/incremental_id/incremental_id_task_manager.ts b/x-pack/platform/plugins/shared/cases/server/tasks/incremental_id/incremental_id_task_manager.ts new file mode 100644 index 0000000000000..9f85757645499 --- /dev/null +++ b/x-pack/platform/plugins/shared/cases/server/tasks/incremental_id/incremental_id_task_manager.ts @@ -0,0 +1,169 @@ +/* + * 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 { SavedObjectsClient, type CoreStart, type Logger } from '@kbn/core/server'; +import { + TaskStatus, + type TaskManagerSetupContract, + type TaskManagerStartContract, +} from '@kbn/task-manager-plugin/server'; +import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server'; +import type { IUsageCounter } from '@kbn/usage-collection-plugin/server/usage_counters/usage_counter'; +import { CASE_SAVED_OBJECT, CASE_ID_INCREMENTER_SAVED_OBJECT } from '../../../common/constants'; +import { CasesIncrementalIdService } from '../../services/incremental_id'; +import type { ConfigType } from '../../config'; + +export const CASES_INCREMENTAL_ID_SYNC_TASK_TYPE = 'cases_incremental_id_assignment'; +export const CASES_INCREMENTAL_ID_SYNC_TASK_ID = `cases:${CASES_INCREMENTAL_ID_SYNC_TASK_TYPE}`; + +export const CasesIncrementIdTaskVersion = '1.0.0'; + +export class IncrementalIdTaskManager { + private config: ConfigType['incrementalId']; + private logger: Logger; + private internalSavedObjectsClient?: SavedObjectsClient; + private taskManager?: TaskManagerStartContract; + private successErrorUsageCounter?: IUsageCounter; + constructor( + taskManager: TaskManagerSetupContract, + config: ConfigType['incrementalId'], + logger: Logger, + usageCollection?: UsageCollectionSetup + ) { + this.config = config; + this.logger = logger.get('incremental_id_task'); + this.logger.info('Registering Case Incremental ID Task Manager'); + + if (usageCollection) { + this.successErrorUsageCounter = usageCollection?.createUsageCounter('CasesIncrementalId'); + } + + taskManager.registerTaskDefinitions({ + [CASES_INCREMENTAL_ID_SYNC_TASK_TYPE]: { + title: 'Cases Numerical ID assignment', + description: 'Applying incremental numeric ids to cases', + timeout: '10m', + createTaskRunner: () => { + if (!this.internalSavedObjectsClient) { + throw new Error('Missing internal saved objects client.'); + } + const casesIncrementService = new CasesIncrementalIdService( + this.internalSavedObjectsClient, + this.logger + ); + return { + run: async () => { + const initializedTime = new Date().toISOString(); + const startTime = performance.now(); + this.logger.debug(`Increment id task started at: ${initializedTime}`); + + casesIncrementService.startService(); + + // Fetch all cases without an incremental id + const casesWithoutIncrementalIdResponse = + await casesIncrementService.getCasesWithoutIncrementalId(); + const { saved_objects: casesWithoutIncrementalId } = + casesWithoutIncrementalIdResponse; + + this.logger.debug( + `${casesWithoutIncrementalId.length} cases without incremental ids` + ); + + try { + // Increment the case ids + const processedAmount = await casesIncrementService.incrementCaseIds( + casesWithoutIncrementalId + ); + this.logger.debug( + `Applied incremental ids to ${processedAmount} out of ${casesWithoutIncrementalId.length} cases` + ); + + const endTime = performance.now(); + this.logger.debug( + `Task terminated ${CASES_INCREMENTAL_ID_SYNC_TASK_ID}. Task run took ${ + endTime - startTime + }ms [ started: ${initializedTime}, ended: ${new Date().toISOString()} ]` + ); + this.successErrorUsageCounter?.incrementCounter({ + counterName: 'incrementIdTaskSuccess', + incrementBy: 1, + }); + } catch (_) { + this.successErrorUsageCounter?.incrementCounter({ + counterName: 'incrementIdTaskError', + incrementBy: 1, + }); + } + }, + cancel: async () => { + casesIncrementService.stopService(); + this.logger.debug(`${CASES_INCREMENTAL_ID_SYNC_TASK_ID} task run was canceled`); + }, + }; + }, + }, + }); + } + + public async setupIncrementIdTask(taskManager: TaskManagerStartContract, core: CoreStart) { + this.taskManager = taskManager; + + // Instantiate saved objects client + const internalSavedObjectsRepository = core.savedObjects.createInternalRepository([ + CASE_SAVED_OBJECT, + CASE_ID_INCREMENTER_SAVED_OBJECT, + ]); + this.internalSavedObjectsClient = new SavedObjectsClient(internalSavedObjectsRepository); + + try { + const taskDoc = await this.taskManager.get(CASES_INCREMENTAL_ID_SYNC_TASK_ID); + const scheduledToRunInTheFuture = taskDoc.runAt.getTime() >= new Date().getTime(); + const running = + taskDoc.status === TaskStatus.Claiming || taskDoc.status === TaskStatus.Running; + if (scheduledToRunInTheFuture || running) { + this.logger.info( + `${CASES_INCREMENTAL_ID_SYNC_TASK_ID} is already ${ + scheduledToRunInTheFuture + ? `scheduled (time: ${taskDoc.runAt})` + : `running (status: ${taskDoc.status})` + }. No need to schedule it again.` + ); + return; + } + } catch (e) { + this.logger.warn( + `Could not check status of ${CASES_INCREMENTAL_ID_SYNC_TASK_ID}, will continue scheduling it.` + ); + } + + this.taskManager + .ensureScheduled({ + id: CASES_INCREMENTAL_ID_SYNC_TASK_ID, + taskType: CASES_INCREMENTAL_ID_SYNC_TASK_TYPE, + // start delayed to give the system some time to start up properly + runAt: new Date(new Date().getTime() + this.config.taskStartDelayMinutes * 60 * 1000), + schedule: { + interval: `${this.config.taskIntervalMinutes}m`, + }, + params: {}, + state: {}, + scope: ['cases'], + }) + .then( + (taskInstance) => { + this.logger.info( + `${CASES_INCREMENTAL_ID_SYNC_TASK_ID} scheduled with interval ${taskInstance.schedule?.interval}` + ); + }, + (e) => { + this.logger.error( + `Error scheduling task: ${CASES_INCREMENTAL_ID_SYNC_TASK_ID}: ${e}`, + e?.message ?? e + ); + } + ); + } +} diff --git a/x-pack/platform/test/cases_api_integration/security_and_spaces/tests/common/cases/find_cases.ts b/x-pack/platform/test/cases_api_integration/security_and_spaces/tests/common/cases/find_cases.ts index 66b4be547f22c..08de792711425 100644 --- a/x-pack/platform/test/cases_api_integration/security_and_spaces/tests/common/cases/find_cases.ts +++ b/x-pack/platform/test/cases_api_integration/security_and_spaces/tests/common/cases/find_cases.ts @@ -392,6 +392,7 @@ export default ({ getService }: FtrProviderContext): void => { }); describe('search and searchField', () => { + const searchFields = ['title', 'description', 'incremental_id.text']; beforeEach(async () => { await createCase(supertest, postCaseReq); }); @@ -403,7 +404,7 @@ export default ({ getService }: FtrProviderContext): void => { it('should successfully find a case when using valid searchFields', async () => { const cases = await findCases({ supertest, - query: { searchFields: ['title', 'description'], search: 'Issue' }, + query: { searchFields, search: 'Issue' }, }); expect(cases.total).to.be(1); @@ -423,7 +424,7 @@ export default ({ getService }: FtrProviderContext): void => { const cases = await findCases({ supertest, - query: { searchFields: ['title', 'description'], search: caseWithId.id }, + query: { searchFields, search: caseWithId.id }, }); expect(cases.total).to.be(1); @@ -436,7 +437,7 @@ export default ({ getService }: FtrProviderContext): void => { const cases = await findCases({ supertest, - query: { searchFields: ['title', 'description'], search: uuid }, + query: { searchFields, search: uuid }, }); expect(cases.total).to.be(1); @@ -449,7 +450,7 @@ export default ({ getService }: FtrProviderContext): void => { const cases = await findCases({ supertest, - query: { searchFields: ['title', 'description'], search: uuid }, + query: { searchFields, search: uuid }, }); expect(cases.total).to.be(1); diff --git a/x-pack/platform/test/functional_with_es_ssl/apps/cases/group2/list_view.ts b/x-pack/platform/test/functional_with_es_ssl/apps/cases/group2/list_view.ts index bf7370bbdb410..425ee19df3ffb 100644 --- a/x-pack/platform/test/functional_with_es_ssl/apps/cases/group2/list_view.ts +++ b/x-pack/platform/test/functional_with_es_ssl/apps/cases/group2/list_view.ts @@ -538,7 +538,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { const lsState = { filterOptions: { search: '', - searchFields: ['title', 'description'], + searchFields: ['title', 'description', 'incremental_id.text'], severity: [], assignees: [], reporters: [], @@ -692,7 +692,7 @@ export default ({ getPageObject, getService }: FtrProviderContext) => { queryParams: { page: 1, perPage: 10, sortField: 'createdAt', sortOrder: 'desc' }, filterOptions: { search: theCase.title, - searchFields: ['title', 'description'], + searchFields: ['title', 'description', 'incremental_id.text'], severity: [theCase.severity], assignees: [profiles[0].uid], reporters: [], diff --git a/x-pack/platform/test/security_functional/screenshots/failure/security app - login selector Basic functionality can login with SSO preserving -a74b45be36349681d865ed32ccfb8d8aeb818194637cf8b888cfc16ee76c6ad9.png b/x-pack/platform/test/security_functional/screenshots/failure/security app - login selector Basic functionality can login with SSO preserving -a74b45be36349681d865ed32ccfb8d8aeb818194637cf8b888cfc16ee76c6ad9.png deleted file mode 100644 index c58d69b434329..0000000000000 Binary files a/x-pack/platform/test/security_functional/screenshots/failure/security app - login selector Basic functionality can login with SSO preserving -a74b45be36349681d865ed32ccfb8d8aeb818194637cf8b888cfc16ee76c6ad9.png and /dev/null differ