diff --git a/docs/user/dashboard/vega-reference.asciidoc b/docs/user/dashboard/vega-reference.asciidoc index 2c961dca44474..88fd870fefa74 100644 --- a/docs/user/dashboard/vega-reference.asciidoc +++ b/docs/user/dashboard/vega-reference.asciidoc @@ -251,9 +251,14 @@ experimental[] To enable *Maps*, the graph must specify `type=map` in the host c "longitude": -74, // default 0 "zoom": 7, // default 2 - // defaults to "default". Use false to disable base layer. + // Defaults to 'true', disables the base map layer. "mapStyle": false, + // When 'mapStyle' is 'undefined' or 'true', sets the EMS-layer for the map. + // May either be: "road_map", "road_map_desaturated", "dark_map". + // If 'emsTileServiceId' is 'undefined', it falls back to the auto-switch-dark-light behavior. + "emsTileServiceId": "road_map", + // default 0 "minZoom": 5, @@ -261,7 +266,7 @@ experimental[] To enable *Maps*, the graph must specify `type=map` in the host c // or 25 when base is disabled "maxZoom": 13, - // defaults to true, shows +/- buttons to zoom in/out + // Defaults to 'true', shows +/- buttons to zoom in/out "zoomControl": false, // Defaults to 'false', disables mouse wheel zoom. If set to diff --git a/packages/kbn-std/src/promise.test.ts b/packages/kbn-std/src/promise.test.ts index f7c119acd0c7a..bf4f3951d5850 100644 --- a/packages/kbn-std/src/promise.test.ts +++ b/packages/kbn-std/src/promise.test.ts @@ -12,40 +12,36 @@ const delay = (ms: number, resolveValue?: any) => new Promise((resolve) => setTimeout(resolve, ms, resolveValue)); describe('withTimeout', () => { - it('resolves with a promise value if resolved in given timeout', async () => { + it('resolves with a promise value and "timedout: false" if resolved in given timeout', async () => { await expect( withTimeout({ promise: delay(10, 'value'), - timeout: 200, - errorMessage: 'error-message', + timeoutMs: 200, }) - ).resolves.toBe('value'); + ).resolves.toStrictEqual({ value: 'value', timedout: false }); }); - it('rejects with errorMessage if not resolved in given time', async () => { + it('resolves with "timedout: false" if not resolved in given time', async () => { await expect( withTimeout({ promise: delay(200, 'value'), - timeout: 10, - errorMessage: 'error-message', + timeoutMs: 10, }) - ).rejects.toMatchInlineSnapshot(`[Error: error-message]`); + ).resolves.toStrictEqual({ timedout: true }); await expect( withTimeout({ promise: new Promise((i) => i), - timeout: 10, - errorMessage: 'error-message', + timeoutMs: 10, }) - ).rejects.toMatchInlineSnapshot(`[Error: error-message]`); + ).resolves.toStrictEqual({ timedout: true }); }); it('does not swallow promise error', async () => { await expect( withTimeout({ promise: Promise.reject(new Error('from-promise')), - timeout: 10, - errorMessage: 'error-message', + timeoutMs: 10, }) ).rejects.toMatchInlineSnapshot(`[Error: from-promise]`); }); diff --git a/packages/kbn-std/src/promise.ts b/packages/kbn-std/src/promise.ts index 9d8f7703c026d..9209c2ce372c6 100644 --- a/packages/kbn-std/src/promise.ts +++ b/packages/kbn-std/src/promise.ts @@ -6,19 +6,26 @@ * Side Public License, v 1. */ -export function withTimeout({ +export async function withTimeout({ promise, - timeout, - errorMessage, + timeoutMs, }: { promise: Promise; - timeout: number; - errorMessage: string; -}) { - return Promise.race([ - promise, - new Promise((resolve, reject) => setTimeout(() => reject(new Error(errorMessage)), timeout)), - ]) as Promise; + timeoutMs: number; +}): Promise<{ timedout: true } | { timedout: false; value: T }> { + let timeout: NodeJS.Timeout | undefined; + try { + return (await Promise.race([ + promise.then((v) => ({ value: v, timedout: false })), + new Promise((resolve) => { + timeout = setTimeout(() => resolve({ timedout: true }), timeoutMs); + }), + ])) as Promise<{ timedout: true } | { timedout: false; value: T }>; + } finally { + if (timeout !== undefined) { + clearTimeout(timeout); + } + } } export function isPromise(maybePromise: T | Promise): maybePromise is Promise { diff --git a/src/core/public/plugins/plugins_service.ts b/src/core/public/plugins/plugins_service.ts index 57fbe4cbecd12..230a675b4cda6 100644 --- a/src/core/public/plugins/plugins_service.ts +++ b/src/core/public/plugins/plugins_service.ts @@ -111,11 +111,18 @@ export class PluginsService implements CoreService { `); }); }); + +describe('stop', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + it('waits for 30 sec to finish "stop" and move on to the next plugin.', async () => { + const [plugin1, plugin2] = [createPlugin('timeout-stop-1'), createPlugin('timeout-stop-2')].map( + (plugin, index) => { + jest.spyOn(plugin, 'setup').mockResolvedValue(`setup-as-${index}`); + jest.spyOn(plugin, 'start').mockResolvedValue(`started-as-${index}`); + pluginsSystem.addPlugin(plugin); + return plugin; + } + ); + + const stopSpy1 = jest + .spyOn(plugin1, 'stop') + .mockImplementationOnce(() => new Promise((resolve) => resolve)); + const stopSpy2 = jest.spyOn(plugin2, 'stop').mockImplementationOnce(() => Promise.resolve()); + + mockCreatePluginSetupContext.mockImplementation(() => ({})); + + await pluginsSystem.setupPlugins(setupDeps); + const stopPromise = pluginsSystem.stopPlugins(); + + jest.runAllTimers(); + await stopPromise; + expect(stopSpy1).toHaveBeenCalledTimes(1); + expect(stopSpy2).toHaveBeenCalledTimes(1); + + expect(loggingSystemMock.collect(logger).warn.flat()).toEqual( + expect.arrayContaining([ + `"timeout-stop-1" plugin didn't stop in 30sec., move on to the next.`, + ]) + ); + }); +}); diff --git a/src/core/server/plugins/plugins_system.ts b/src/core/server/plugins/plugins_system.ts index b7b8c297ea571..0244254838fab 100644 --- a/src/core/server/plugins/plugins_system.ts +++ b/src/core/server/plugins/plugins_system.ts @@ -105,11 +105,18 @@ export class PluginsSystem { `Plugin ${pluginName} is using asynchronous setup lifecycle. Asynchronous plugins support will be removed in a later version.` ); } - contract = await withTimeout({ + const contractMaybe = await withTimeout({ promise: contractOrPromise, - timeout: 10 * Sec, - errorMessage: `Setup lifecycle of "${pluginName}" plugin wasn't completed in 10sec. Consider disabling the plugin and re-start.`, + timeoutMs: 10 * Sec, }); + + if (contractMaybe.timedout) { + throw new Error( + `Setup lifecycle of "${pluginName}" plugin wasn't completed in 10sec. Consider disabling the plugin and re-start.` + ); + } else { + contract = contractMaybe.value; + } } else { contract = contractOrPromise; } @@ -154,11 +161,18 @@ export class PluginsSystem { `Plugin ${pluginName} is using asynchronous start lifecycle. Asynchronous plugins support will be removed in a later version.` ); } - contract = await withTimeout({ + const contractMaybe = await withTimeout({ promise: contractOrPromise, - timeout: 10 * Sec, - errorMessage: `Start lifecycle of "${pluginName}" plugin wasn't completed in 10sec. Consider disabling the plugin and re-start.`, + timeoutMs: 10 * Sec, }); + + if (contractMaybe.timedout) { + throw new Error( + `Start lifecycle of "${pluginName}" plugin wasn't completed in 10sec. Consider disabling the plugin and re-start.` + ); + } else { + contract = contractMaybe.value; + } } else { contract = contractOrPromise; } @@ -181,7 +195,15 @@ export class PluginsSystem { const pluginName = this.satupPlugins.pop()!; this.log.debug(`Stopping plugin "${pluginName}"...`); - await this.plugins.get(pluginName)!.stop(); + + const resultMaybe = await withTimeout({ + promise: this.plugins.get(pluginName)!.stop(), + timeoutMs: 30 * Sec, + }); + + if (resultMaybe?.timedout) { + this.log.warn(`"${pluginName}" plugin didn't stop in 30sec., move on to the next.`); + } } } diff --git a/src/plugins/data/public/search/session/sessions_client.ts b/src/plugins/data/public/search/session/sessions_client.ts index dcfc529f99b2b..1742db9d033bd 100644 --- a/src/plugins/data/public/search/session/sessions_client.ts +++ b/src/plugins/data/public/search/session/sessions_client.ts @@ -68,9 +68,9 @@ export class SessionsClient { }); } - public extend(sessionId: string, keepAlive: string): Promise { + public extend(sessionId: string, expires: string): Promise { return this.http!.post(`/internal/session/${encodeURIComponent(sessionId)}/_extend`, { - body: JSON.stringify({ keepAlive }), + body: JSON.stringify({ expires }), }); } diff --git a/src/plugins/data/server/search/search_service.test.ts b/src/plugins/data/server/search/search_service.test.ts index d6589e88085a0..192c133c94a04 100644 --- a/src/plugins/data/server/search/search_service.test.ts +++ b/src/plugins/data/server/search/search_service.test.ts @@ -7,7 +7,7 @@ */ import type { MockedKeys } from '@kbn/utility-types/jest'; -import { CoreSetup, CoreStart } from '../../../../core/server'; +import { CoreSetup, CoreStart, SavedObject } from '../../../../core/server'; import { coreMock } from '../../../../core/server/mocks'; import { DataPluginStart } from '../plugin'; @@ -86,13 +86,22 @@ describe('Search service', () => { describe('asScopedProvider', () => { let mockScopedClient: IScopedSearchClient; let searcPluginStart: ISearchStart>; - let mockStrategy: jest.Mocked; + let mockStrategy: any; + let mockStrategyNoCancel: jest.Mocked; let mockSessionService: ISearchSessionService; let mockSessionClient: jest.Mocked; const sessionId = '1234'; beforeEach(() => { - mockStrategy = { search: jest.fn().mockReturnValue(of({})) }; + mockStrategy = { + search: jest.fn().mockReturnValue(of({})), + cancel: jest.fn(), + extend: jest.fn(), + }; + + mockStrategyNoCancel = { + search: jest.fn().mockReturnValue(of({})), + }; mockSessionClient = createSearchSessionsClientMock(); mockSessionService = { @@ -104,6 +113,7 @@ describe('Search service', () => { expressions: expressionsPluginMock.createSetupContract(), }); pluginSetup.registerSearchStrategy('es', mockStrategy); + pluginSetup.registerSearchStrategy('nocancel', mockStrategyNoCancel); pluginSetup.__enhance({ defaultStrategy: 'es', sessionService: mockSessionService, @@ -123,7 +133,7 @@ describe('Search service', () => { it('searches using the original request if not restoring, trackId is not called if there is no id in the response', async () => { const searchRequest = { params: {} }; const options = { sessionId, isStored: false, isRestore: false }; - mockSessionClient.trackId = jest.fn(); + mockSessionClient.trackId = jest.fn().mockResolvedValue(undefined); mockStrategy.search.mockReturnValue( of({ @@ -165,10 +175,27 @@ describe('Search service', () => { expect(request).toStrictEqual({ ...searchRequest, id: 'my_id' }); }); + it('does not fail if `trackId` throws', async () => { + const searchRequest = { params: {} }; + const options = { sessionId, isStored: false, isRestore: false }; + mockSessionClient.trackId = jest.fn().mockRejectedValue(undefined); + + mockStrategy.search.mockReturnValue( + of({ + id: 'my_id', + rawResponse: {} as any, + }) + ); + + await mockScopedClient.search(searchRequest, options).toPromise(); + + expect(mockSessionClient.trackId).toBeCalledTimes(1); + }); + it('calls `trackId` for every response, if the response contains an `id` and not restoring', async () => { const searchRequest = { params: {} }; const options = { sessionId, isStored: false, isRestore: false }; - mockSessionClient.trackId = jest.fn(); + mockSessionClient.trackId = jest.fn().mockResolvedValue(undefined); mockStrategy.search.mockReturnValue( of( @@ -195,7 +222,7 @@ describe('Search service', () => { const searchRequest = { params: {} }; const options = { sessionId, isStored: true, isRestore: true }; mockSessionClient.getId = jest.fn().mockResolvedValueOnce('my_id'); - mockSessionClient.trackId = jest.fn(); + mockSessionClient.trackId = jest.fn().mockResolvedValue(undefined); await mockScopedClient.search(searchRequest, options).toPromise(); @@ -206,12 +233,258 @@ describe('Search service', () => { const searchRequest = { params: {} }; const options = {}; mockSessionClient.getId = jest.fn().mockResolvedValueOnce('my_id'); - mockSessionClient.trackId = jest.fn(); + mockSessionClient.trackId = jest.fn().mockResolvedValue(undefined); await mockScopedClient.search(searchRequest, options).toPromise(); expect(mockSessionClient.trackId).not.toBeCalled(); }); }); + + describe('cancelSession', () => { + const mockSavedObject: SavedObject = { + id: 'd7170a35-7e2c-48d6-8dec-9a056721b489', + type: 'search-session', + attributes: { + name: 'my_name', + appId: 'my_app_id', + urlGeneratorId: 'my_url_generator_id', + idMapping: {}, + }, + references: [], + }; + + it('cancels a saved object with no search ids', async () => { + mockSessionClient.getSearchIdMapping = jest + .fn() + .mockResolvedValue(new Map()); + mockSessionClient.cancel = jest.fn().mockResolvedValue(mockSavedObject); + const cancelSpy = jest.spyOn(mockScopedClient, 'cancel'); + + await mockScopedClient.cancelSession('123'); + + expect(mockSessionClient.cancel).toHaveBeenCalledTimes(1); + expect(cancelSpy).not.toHaveBeenCalled(); + }); + + it('cancels a saved object and search ids', async () => { + const mockMap = new Map(); + mockMap.set('abc', 'es'); + mockSessionClient.getSearchIdMapping = jest.fn().mockResolvedValue(mockMap); + mockStrategy.cancel = jest.fn(); + mockSessionClient.cancel = jest.fn().mockResolvedValue(mockSavedObject); + + await mockScopedClient.cancelSession('123'); + + expect(mockSessionClient.cancel).toHaveBeenCalledTimes(1); + + const [searchId, options] = mockStrategy.cancel.mock.calls[0]; + expect(mockStrategy.cancel).toHaveBeenCalledTimes(1); + expect(searchId).toBe('abc'); + expect(options).toHaveProperty('strategy', 'es'); + }); + + it('cancels a saved object with some strategies that dont support cancellation, dont throw an error', async () => { + const mockMap = new Map(); + mockMap.set('abc', 'nocancel'); + mockMap.set('def', 'es'); + mockSessionClient.getSearchIdMapping = jest.fn().mockResolvedValue(mockMap); + mockStrategy.cancel = jest.fn(); + mockSessionClient.cancel = jest.fn().mockResolvedValue(mockSavedObject); + + await mockScopedClient.cancelSession('123'); + + expect(mockSessionClient.cancel).toHaveBeenCalledTimes(1); + + const [searchId, options] = mockStrategy.cancel.mock.calls[0]; + expect(mockStrategy.cancel).toHaveBeenCalledTimes(1); + expect(searchId).toBe('def'); + expect(options).toHaveProperty('strategy', 'es'); + }); + + it('cancels a saved object with some strategies that dont exist, dont throw an error', async () => { + const mockMap = new Map(); + mockMap.set('abc', 'notsupported'); + mockMap.set('def', 'es'); + mockSessionClient.getSearchIdMapping = jest.fn().mockResolvedValue(mockMap); + mockStrategy.cancel = jest.fn(); + mockSessionClient.cancel = jest.fn().mockResolvedValue(mockSavedObject); + + await mockScopedClient.cancelSession('123'); + + expect(mockSessionClient.cancel).toHaveBeenCalledTimes(1); + + const [searchId, options] = mockStrategy.cancel.mock.calls[0]; + expect(mockStrategy.cancel).toHaveBeenCalledTimes(1); + expect(searchId).toBe('def'); + expect(options).toHaveProperty('strategy', 'es'); + }); + }); + + describe('deleteSession', () => { + const mockSavedObject: SavedObject = { + id: 'd7170a35-7e2c-48d6-8dec-9a056721b489', + type: 'search-session', + attributes: { + name: 'my_name', + appId: 'my_app_id', + urlGeneratorId: 'my_url_generator_id', + idMapping: {}, + }, + references: [], + }; + + it('deletes a saved object with no search ids', async () => { + mockSessionClient.getSearchIdMapping = jest + .fn() + .mockResolvedValue(new Map()); + mockSessionClient.delete = jest.fn().mockResolvedValue(mockSavedObject); + const cancelSpy = jest.spyOn(mockScopedClient, 'cancel'); + + await mockScopedClient.deleteSession('123'); + + expect(mockSessionClient.delete).toHaveBeenCalledTimes(1); + expect(cancelSpy).not.toHaveBeenCalled(); + }); + + it('deletes a saved object and search ids', async () => { + const mockMap = new Map(); + mockMap.set('abc', 'es'); + mockSessionClient.getSearchIdMapping = jest.fn().mockResolvedValue(mockMap); + mockSessionClient.delete = jest.fn().mockResolvedValue(mockSavedObject); + mockStrategy.cancel = jest.fn(); + + await mockScopedClient.deleteSession('123'); + + expect(mockSessionClient.delete).toHaveBeenCalledTimes(1); + + const [searchId, options] = mockStrategy.cancel.mock.calls[0]; + expect(mockStrategy.cancel).toHaveBeenCalledTimes(1); + expect(searchId).toBe('abc'); + expect(options).toHaveProperty('strategy', 'es'); + }); + + it('deletes a saved object with some strategies that dont support cancellation, dont throw an error', async () => { + const mockMap = new Map(); + mockMap.set('abc', 'nocancel'); + mockMap.set('def', 'es'); + mockSessionClient.getSearchIdMapping = jest.fn().mockResolvedValue(mockMap); + mockSessionClient.delete = jest.fn().mockResolvedValue(mockSavedObject); + mockStrategy.cancel = jest.fn(); + + await mockScopedClient.deleteSession('123'); + + expect(mockSessionClient.delete).toHaveBeenCalledTimes(1); + + const [searchId, options] = mockStrategy.cancel.mock.calls[0]; + expect(mockStrategy.cancel).toHaveBeenCalledTimes(1); + expect(searchId).toBe('def'); + expect(options).toHaveProperty('strategy', 'es'); + }); + + it('deletes a saved object with some strategies that dont exist, dont throw an error', async () => { + const mockMap = new Map(); + mockMap.set('abc', 'notsupported'); + mockMap.set('def', 'es'); + mockSessionClient.getSearchIdMapping = jest.fn().mockResolvedValue(mockMap); + mockStrategy.cancel = jest.fn(); + mockSessionClient.delete = jest.fn().mockResolvedValue(mockSavedObject); + + await mockScopedClient.deleteSession('123'); + + expect(mockSessionClient.delete).toHaveBeenCalledTimes(1); + + const [searchId, options] = mockStrategy.cancel.mock.calls[0]; + expect(mockStrategy.cancel).toHaveBeenCalledTimes(1); + expect(searchId).toBe('def'); + expect(options).toHaveProperty('strategy', 'es'); + }); + }); + + describe('extendSession', () => { + const mockSavedObject: SavedObject = { + id: 'd7170a35-7e2c-48d6-8dec-9a056721b489', + type: 'search-session', + attributes: { + name: 'my_name', + appId: 'my_app_id', + urlGeneratorId: 'my_url_generator_id', + idMapping: {}, + }, + references: [], + }; + + it('extends a saved object with no search ids', async () => { + mockSessionClient.getSearchIdMapping = jest + .fn() + .mockResolvedValue(new Map()); + mockSessionClient.extend = jest.fn().mockResolvedValue(mockSavedObject); + mockStrategy.extend = jest.fn(); + + await mockScopedClient.extendSession('123', new Date('2020-01-01')); + + expect(mockSessionClient.extend).toHaveBeenCalledTimes(1); + expect(mockStrategy.extend).not.toHaveBeenCalled(); + }); + + it('extends a saved object and search ids', async () => { + const mockMap = new Map(); + mockMap.set('abc', 'es'); + mockSessionClient.getSearchIdMapping = jest.fn().mockResolvedValue(mockMap); + mockSessionClient.extend = jest.fn().mockResolvedValue(mockSavedObject); + mockStrategy.extend = jest.fn(); + + await mockScopedClient.extendSession('123', new Date('2020-01-01')); + + expect(mockSessionClient.extend).toHaveBeenCalledTimes(1); + expect(mockStrategy.extend).toHaveBeenCalledTimes(1); + const [searchId, keepAlive, options] = mockStrategy.extend.mock.calls[0]; + expect(searchId).toBe('abc'); + expect(keepAlive).toContain('ms'); + expect(options).toHaveProperty('strategy', 'es'); + }); + + it('doesnt extend the saved object with some strategies that dont support cancellation, throws an error', async () => { + const mockMap = new Map(); + mockMap.set('abc', 'nocancel'); + mockMap.set('def', 'es'); + mockSessionClient.getSearchIdMapping = jest.fn().mockResolvedValue(mockMap); + mockSessionClient.extend = jest.fn().mockResolvedValue(mockSavedObject); + mockStrategy.extend = jest.fn().mockResolvedValue({}); + + const extendRes = mockScopedClient.extendSession('123', new Date('2020-01-01')); + + await expect(extendRes).rejects.toThrowError( + 'Failed to extend the expiration of some searches' + ); + + expect(mockSessionClient.extend).not.toHaveBeenCalled(); + const [searchId, keepAlive, options] = mockStrategy.extend.mock.calls[0]; + expect(searchId).toBe('def'); + expect(keepAlive).toContain('ms'); + expect(options).toHaveProperty('strategy', 'es'); + }); + + it('doesnt extend the saved object with some strategies that dont exist, throws an error', async () => { + const mockMap = new Map(); + mockMap.set('abc', 'notsupported'); + mockMap.set('def', 'es'); + mockSessionClient.getSearchIdMapping = jest.fn().mockResolvedValue(mockMap); + mockSessionClient.extend = jest.fn().mockResolvedValue(mockSavedObject); + mockStrategy.extend = jest.fn().mockResolvedValue({}); + + const extendRes = mockScopedClient.extendSession('123', new Date('2020-01-01')); + + await expect(extendRes).rejects.toThrowError( + 'Failed to extend the expiration of some searches' + ); + + expect(mockSessionClient.extend).not.toHaveBeenCalled(); + const [searchId, keepAlive, options] = mockStrategy.extend.mock.calls[0]; + expect(searchId).toBe('def'); + expect(keepAlive).toContain('ms'); + expect(options).toHaveProperty('strategy', 'es'); + }); + }); }); }); diff --git a/src/plugins/data/server/search/search_service.ts b/src/plugins/data/server/search/search_service.ts index ce0771a1e9df8..6ece8ff945468 100644 --- a/src/plugins/data/server/search/search_service.ts +++ b/src/plugins/data/server/search/search_service.ts @@ -275,7 +275,10 @@ export class SearchService implements Plugin { switchMap((searchRequest) => strategy.search(searchRequest, options, deps)), tap((response) => { if (!options.sessionId || !response.id || options.isRestore) return; - deps.searchSessionsClient.trackId(request, response.id, options); + // intentionally swallow tracking error, as it shouldn't fail the search + deps.searchSessionsClient.trackId(request, response.id, options).catch((trackErr) => { + this.logger.error(trackErr); + }); }) ); } catch (e) { @@ -283,7 +286,11 @@ export class SearchService implements Plugin { } }; - private cancel = (deps: SearchStrategyDependencies, id: string, options: ISearchOptions = {}) => { + private cancel = async ( + deps: SearchStrategyDependencies, + id: string, + options: ISearchOptions = {} + ) => { const strategy = this.getSearchStrategy(options.strategy); if (!strategy.cancel) { throw new KbnServerError( @@ -294,7 +301,7 @@ export class SearchService implements Plugin { return strategy.cancel(id, options, deps); }; - private extend = ( + private extend = async ( deps: SearchStrategyDependencies, id: string, keepAlive: string, @@ -309,25 +316,26 @@ export class SearchService implements Plugin { private cancelSessionSearches = async (deps: SearchStrategyDependencies, sessionId: string) => { const searchIdMapping = await deps.searchSessionsClient.getSearchIdMapping(sessionId); - - for (const [searchId, strategyName] of searchIdMapping.entries()) { - const searchOptions = { - sessionId, - strategy: strategyName, - isStored: true, - }; - this.cancel(deps, searchId, searchOptions); - } + await Promise.allSettled( + Array.from(searchIdMapping).map(([searchId, strategyName]) => { + const searchOptions = { + sessionId, + strategy: strategyName, + isStored: true, + }; + return this.cancel(deps, searchId, searchOptions); + }) + ); }; private cancelSession = async (deps: SearchStrategyDependencies, sessionId: string) => { const response = await deps.searchSessionsClient.cancel(sessionId); - this.cancelSessionSearches(deps, sessionId); + await this.cancelSessionSearches(deps, sessionId); return response; }; private deleteSession = async (deps: SearchStrategyDependencies, sessionId: string) => { - this.cancelSessionSearches(deps, sessionId); + await this.cancelSessionSearches(deps, sessionId); return deps.searchSessionsClient.delete(sessionId); }; @@ -339,13 +347,19 @@ export class SearchService implements Plugin { const searchIdMapping = await deps.searchSessionsClient.getSearchIdMapping(sessionId); const keepAlive = `${moment(expires).diff(moment())}ms`; - for (const [searchId, strategyName] of searchIdMapping.entries()) { - const searchOptions = { - sessionId, - strategy: strategyName, - isStored: true, - }; - await this.extend(deps, searchId, keepAlive, searchOptions); + const result = await Promise.allSettled( + Array.from(searchIdMapping).map(([searchId, strategyName]) => { + const searchOptions = { + sessionId, + strategy: strategyName, + isStored: true, + }; + return this.extend(deps, searchId, keepAlive, searchOptions); + }) + ); + + if (result.some((extRes) => extRes.status === 'rejected')) { + throw new Error('Failed to extend the expiration of some searches'); } return deps.searchSessionsClient.extend(sessionId, expires); diff --git a/src/plugins/vis_type_vega/public/data_model/vega_parser.test.js b/src/plugins/vis_type_vega/public/data_model/vega_parser.test.js index 1948792d55a83..f33c2bfc27630 100644 --- a/src/plugins/vis_type_vega/public/data_model/vega_parser.test.js +++ b/src/plugins/vis_type_vega/public/data_model/vega_parser.test.js @@ -279,7 +279,7 @@ describe('VegaParser._parseMapConfig', () => { delayRepaint: true, latitude: 0, longitude: 0, - mapStyle: 'default', + mapStyle: true, zoomControl: true, scrollWheelZoom: false, }, @@ -288,52 +288,47 @@ describe('VegaParser._parseMapConfig', () => { ); test( - 'filled', + 'emsTileServiceId', check( { - delayRepaint: true, - latitude: 0, - longitude: 0, - mapStyle: 'default', - zoomControl: true, - scrollWheelZoom: false, - maxBounds: [1, 2, 3, 4], + mapStyle: true, + emsTileServiceId: 'dark_map', }, { delayRepaint: true, latitude: 0, longitude: 0, - mapStyle: 'default', + mapStyle: true, + emsTileServiceId: 'dark_map', zoomControl: true, scrollWheelZoom: false, - maxBounds: [1, 2, 3, 4], }, 0 ) ); test( - 'warnings', + 'filled', check( { delayRepaint: true, latitude: 0, longitude: 0, - zoom: 'abc', // ignored - mapStyle: 'abc', - zoomControl: 'abc', - scrollWheelZoom: 'abc', - maxBounds: [2, 3, 4], + mapStyle: true, + zoomControl: true, + scrollWheelZoom: false, + maxBounds: [1, 2, 3, 4], }, { delayRepaint: true, latitude: 0, longitude: 0, - mapStyle: 'default', + mapStyle: true, zoomControl: true, scrollWheelZoom: false, + maxBounds: [1, 2, 3, 4], }, - 5 + 0 ) ); }); diff --git a/src/plugins/vis_type_vega/public/data_model/vega_parser.ts b/src/plugins/vis_type_vega/public/data_model/vega_parser.ts index e97418581a42f..d3647b35a5b94 100644 --- a/src/plugins/vis_type_vega/public/data_model/vega_parser.ts +++ b/src/plugins/vis_type_vega/public/data_model/vega_parser.ts @@ -465,21 +465,10 @@ The URL is an identifier only. Kibana and your browser will never access this UR validate(`minZoom`, true); validate(`maxZoom`, true); - // `false` is a valid value - res.mapStyle = this._config?.mapStyle === undefined ? `default` : this._config.mapStyle; - if (res.mapStyle !== `default` && res.mapStyle !== false) { - this._onWarning( - i18n.translate('visTypeVega.vegaParser.mapStyleValueTypeWarningMessage', { - defaultMessage: - '{mapStyleConfigName} may either be {mapStyleConfigFirstAllowedValue} or {mapStyleConfigSecondAllowedValue}', - values: { - mapStyleConfigName: 'config.kibana.mapStyle', - mapStyleConfigFirstAllowedValue: 'false', - mapStyleConfigSecondAllowedValue: '"default"', - }, - }) - ); - res.mapStyle = `default`; + this._parseBool('mapStyle', res, true); + + if (res.mapStyle) { + res.emsTileServiceId = this._config?.emsTileServiceId; } this._parseBool('zoomControl', res, true); diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/view.ts b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/view.ts index 4c155d6b5ea88..c2112659a50ae 100644 --- a/src/plugins/vis_type_vega/public/vega_view/vega_map_view/view.ts +++ b/src/plugins/vis_type_vega/public/vega_view/vega_map_view/view.ts @@ -52,12 +52,14 @@ async function updateVegaView(mapBoxInstance: Map, vegaView: View) { export class VegaMapView extends VegaBaseView { private mapServiceSettings: MapServiceSettings = getMapServiceSettings(); - private mapStyle = this.getMapStyle(); + private emsTileLayer = this.getEmsTileLayer(); - private getMapStyle() { - const { mapStyle } = this._parser.mapConfig; + private getEmsTileLayer() { + const { mapStyle, emsTileServiceId } = this._parser.mapConfig; - return mapStyle === 'default' ? this.mapServiceSettings.defaultTmsLayer() : mapStyle; + if (mapStyle) { + return emsTileServiceId ?? this.mapServiceSettings.defaultTmsLayer(); + } } private get shouldShowZoomControl() { @@ -83,14 +85,14 @@ export class VegaMapView extends VegaBaseView { maxZoom: defaultMapConfig.maxZoom, }; - if (this.mapStyle && this.mapStyle !== userConfiguredLayerId) { - const tmsService = await this.mapServiceSettings.getTmsService(this.mapStyle); + if (this.emsTileLayer && this.emsTileLayer !== userConfiguredLayerId) { + const tmsService = await this.mapServiceSettings.getTmsService(this.emsTileLayer); if (!tmsService) { this.onWarn( i18n.translate('visTypeVega.mapView.mapStyleNotFoundWarningMessage', { defaultMessage: '{mapStyleParam} was not found', - values: { mapStyleParam: `"mapStyle":${this.mapStyle}` }, + values: { mapStyleParam: `"emsTileServiceId":${this.emsTileLayer}` }, }) ); return; @@ -138,7 +140,7 @@ export class VegaMapView extends VegaBaseView { } private initLayers(mapBoxInstance: Map, vegaView: View) { - const shouldShowUserConfiguredLayer = this.mapStyle === userConfiguredLayerId; + const shouldShowUserConfiguredLayer = this.emsTileLayer === userConfiguredLayerId; if (shouldShowUserConfiguredLayer) { const { url, options } = this.mapServiceSettings.config.tilemap; diff --git a/test/api_integration/services/supertest.ts b/test/api_integration/services/supertest.ts index 1257a934da8be..a0268b78cb151 100644 --- a/test/api_integration/services/supertest.ts +++ b/test/api_integration/services/supertest.ts @@ -19,6 +19,14 @@ export function KibanaSupertestProvider({ getService }: FtrProviderContext) { export function ElasticsearchSupertestProvider({ getService }: FtrProviderContext) { const config = getService('config'); - const elasticSearchServerUrl = formatUrl(config.get('servers.elasticsearch')); - return supertestAsPromised(elasticSearchServerUrl); + const esServerConfig = config.get('servers.elasticsearch'); + const elasticSearchServerUrl = formatUrl(esServerConfig); + + let agentOptions = {}; + if ('certificateAuthorities' in esServerConfig) { + agentOptions = { ca: esServerConfig!.certificateAuthorities }; + } + + // @ts-ignore - supertestAsPromised doesn't like the agentOptions, but still passes it correctly to supertest + return supertestAsPromised.agent(elasticSearchServerUrl, agentOptions); } diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts index 7ad6ec337bca1..662b1ce46a07b 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.test.ts @@ -16,6 +16,7 @@ describe('api', () => { beforeEach(() => { externalService = externalServiceMock.create(); + jest.clearAllMocks(); }); describe('create incident', () => { @@ -26,6 +27,7 @@ describe('api', () => { params, secrets: {}, logger: mockedLogger, + commentFieldKey: 'comments', }); expect(res).toEqual({ @@ -57,6 +59,7 @@ describe('api', () => { params, secrets: {}, logger: mockedLogger, + commentFieldKey: 'comments', }); expect(res).toEqual({ @@ -77,6 +80,7 @@ describe('api', () => { params, secrets: { username: 'elastic', password: 'elastic' }, logger: mockedLogger, + commentFieldKey: 'comments', }); expect(externalService.createIncident).toHaveBeenCalledWith({ @@ -99,6 +103,7 @@ describe('api', () => { params, secrets: {}, logger: mockedLogger, + commentFieldKey: 'comments', }); expect(externalService.updateIncident).toHaveBeenCalledTimes(2); expect(externalService.updateIncident).toHaveBeenNthCalledWith(1, { @@ -125,6 +130,41 @@ describe('api', () => { incidentId: 'incident-1', }); }); + + test('it post comments to different comment field key', async () => { + const params = { ...apiParams, incident: { ...apiParams.incident, externalId: null } }; + await api.pushToService({ + externalService, + params, + secrets: {}, + logger: mockedLogger, + commentFieldKey: 'work_notes', + }); + expect(externalService.updateIncident).toHaveBeenCalledTimes(2); + expect(externalService.updateIncident).toHaveBeenNthCalledWith(1, { + incident: { + severity: '1', + urgency: '2', + impact: '3', + work_notes: 'A comment', + description: 'Incident description', + short_description: 'Incident title', + }, + incidentId: 'incident-1', + }); + + expect(externalService.updateIncident).toHaveBeenNthCalledWith(2, { + incident: { + severity: '1', + urgency: '2', + impact: '3', + work_notes: 'Another comment', + description: 'Incident description', + short_description: 'Incident title', + }, + incidentId: 'incident-1', + }); + }); }); describe('update incident', () => { @@ -134,6 +174,7 @@ describe('api', () => { params: apiParams, secrets: {}, logger: mockedLogger, + commentFieldKey: 'comments', }); expect(res).toEqual({ @@ -161,6 +202,7 @@ describe('api', () => { params, secrets: {}, logger: mockedLogger, + commentFieldKey: 'comments', }); expect(res).toEqual({ @@ -178,6 +220,7 @@ describe('api', () => { params, secrets: {}, logger: mockedLogger, + commentFieldKey: 'comments', }); expect(externalService.updateIncident).toHaveBeenCalledWith({ @@ -200,6 +243,7 @@ describe('api', () => { params, secrets: {}, logger: mockedLogger, + commentFieldKey: 'comments', }); expect(externalService.updateIncident).toHaveBeenCalledTimes(3); expect(externalService.updateIncident).toHaveBeenNthCalledWith(1, { @@ -225,6 +269,40 @@ describe('api', () => { incidentId: 'incident-2', }); }); + + test('it post comments to different comment field key', async () => { + const params = { ...apiParams }; + await api.pushToService({ + externalService, + params, + secrets: {}, + logger: mockedLogger, + commentFieldKey: 'work_notes', + }); + expect(externalService.updateIncident).toHaveBeenCalledTimes(3); + expect(externalService.updateIncident).toHaveBeenNthCalledWith(1, { + incident: { + severity: '1', + urgency: '2', + impact: '3', + description: 'Incident description', + short_description: 'Incident title', + }, + incidentId: 'incident-3', + }); + + expect(externalService.updateIncident).toHaveBeenNthCalledWith(2, { + incident: { + severity: '1', + urgency: '2', + impact: '3', + work_notes: 'A comment', + description: 'Incident description', + short_description: 'Incident title', + }, + incidentId: 'incident-2', + }); + }); }); describe('getFields', () => { diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.ts index 3aa1e50dc2aeb..4120c07c32303 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/api.ts @@ -25,6 +25,7 @@ const pushToServiceHandler = async ({ externalService, params, secrets, + commentFieldKey, }: PushToServiceApiHandlerArgs): Promise => { const { comments } = params; let res: PushToServiceResponse; @@ -53,7 +54,7 @@ const pushToServiceHandler = async ({ incidentId: res.id, incident: { ...incident, - comments: currentComment.comment, + [commentFieldKey]: currentComment.comment, }, }); res.comments = [ diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.test.ts new file mode 100644 index 0000000000000..e7e2b2bc4118e --- /dev/null +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.test.ts @@ -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 { actionsMock } from '../../mocks'; +import { createActionTypeRegistry } from '../index.test'; +import { + ServiceNowPublicConfigurationType, + ServiceNowSecretConfigurationType, + ExecutorParams, + PushToServiceResponse, +} from './types'; +import { + ServiceNowActionType, + ServiceNowITSMActionTypeId, + ServiceNowSIRActionTypeId, + ServiceNowActionTypeExecutorOptions, +} from '.'; +import { api } from './api'; + +jest.mock('./api', () => ({ + api: { + getChoices: jest.fn(), + getFields: jest.fn(), + getIncident: jest.fn(), + handshake: jest.fn(), + pushToService: jest.fn(), + }, +})); + +const services = actionsMock.createServices(); + +describe('ServiceNow', () => { + const config = { apiUrl: 'https://instance.com' }; + const secrets = { username: 'username', password: 'password' }; + const params = { + subAction: 'pushToService', + subActionParams: { + incident: { + short_description: 'An incident', + description: 'This is serious', + }, + }, + }; + + beforeEach(() => { + (api.pushToService as jest.Mock).mockResolvedValue({ id: 'some-id' }); + }); + + describe('ServiceNow ITSM', () => { + let actionType: ServiceNowActionType; + + beforeAll(() => { + const { actionTypeRegistry } = createActionTypeRegistry(); + actionType = actionTypeRegistry.get< + ServiceNowPublicConfigurationType, + ServiceNowSecretConfigurationType, + ExecutorParams, + PushToServiceResponse | {} + >(ServiceNowITSMActionTypeId); + }); + + describe('execute()', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('it pass the correct comment field key', async () => { + const actionId = 'some-action-id'; + const executorOptions = ({ + actionId, + config, + secrets, + params, + services, + } as unknown) as ServiceNowActionTypeExecutorOptions; + await actionType.executor(executorOptions); + expect((api.pushToService as jest.Mock).mock.calls[0][0].commentFieldKey).toBe('comments'); + }); + }); + }); + + describe('ServiceNow SIR', () => { + let actionType: ServiceNowActionType; + + beforeAll(() => { + const { actionTypeRegistry } = createActionTypeRegistry(); + actionType = actionTypeRegistry.get< + ServiceNowPublicConfigurationType, + ServiceNowSecretConfigurationType, + ExecutorParams, + PushToServiceResponse | {} + >(ServiceNowSIRActionTypeId); + }); + + describe('execute()', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('it pass the correct comment field key', async () => { + const actionId = 'some-action-id'; + const executorOptions = ({ + actionId, + config, + secrets, + params, + services, + } as unknown) as ServiceNowActionTypeExecutorOptions; + await actionType.executor(executorOptions); + expect((api.pushToService as jest.Mock).mock.calls[0][0].commentFieldKey).toBe( + 'work_notes' + ); + }); + }); + }); +}); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts index cf9cef3c776c7..f6be7c90820a2 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/index.ts @@ -47,15 +47,21 @@ const serviceNowSIRTable = 'sn_si_incident'; export const ServiceNowITSMActionTypeId = '.servicenow'; export const ServiceNowSIRActionTypeId = '.servicenow-sir'; -// action type definition -export function getServiceNowITSMActionType( - params: GetActionTypeParams -): ActionType< +export type ServiceNowActionType = ActionType< ServiceNowPublicConfigurationType, ServiceNowSecretConfigurationType, ExecutorParams, PushToServiceResponse | {} -> { +>; + +export type ServiceNowActionTypeExecutorOptions = ActionTypeExecutorOptions< + ServiceNowPublicConfigurationType, + ServiceNowSecretConfigurationType, + ExecutorParams +>; + +// action type definition +export function getServiceNowITSMActionType(params: GetActionTypeParams): ServiceNowActionType { const { logger, configurationUtilities } = params; return { id: ServiceNowITSMActionTypeId, @@ -74,14 +80,7 @@ export function getServiceNowITSMActionType( }; } -export function getServiceNowSIRActionType( - params: GetActionTypeParams -): ActionType< - ServiceNowPublicConfigurationType, - ServiceNowSecretConfigurationType, - ExecutorParams, - PushToServiceResponse | {} -> { +export function getServiceNowSIRActionType(params: GetActionTypeParams): ServiceNowActionType { const { logger, configurationUtilities } = params; return { id: ServiceNowSIRActionTypeId, @@ -96,7 +95,12 @@ export function getServiceNowSIRActionType( }), params: ExecutorParamsSchemaSIR, }, - executor: curry(executor)({ logger, configurationUtilities, table: serviceNowSIRTable }), + executor: curry(executor)({ + logger, + configurationUtilities, + table: serviceNowSIRTable, + commentFieldKey: 'work_notes', + }), }; } @@ -107,12 +111,14 @@ async function executor( logger, configurationUtilities, table, - }: { logger: Logger; configurationUtilities: ActionsConfigurationUtilities; table: string }, - execOptions: ActionTypeExecutorOptions< - ServiceNowPublicConfigurationType, - ServiceNowSecretConfigurationType, - ExecutorParams - > + commentFieldKey = 'comments', + }: { + logger: Logger; + configurationUtilities: ActionsConfigurationUtilities; + table: string; + commentFieldKey?: string; + }, + execOptions: ServiceNowActionTypeExecutorOptions ): Promise> { const { actionId, config, params, secrets } = execOptions; const { subAction, subActionParams } = params; @@ -147,6 +153,7 @@ async function executor( params: pushToServiceParams, secrets, logger, + commentFieldKey, }); logger.debug(`response push to service for incident id: ${data.id}`); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/translations.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/translations.ts index 2110e9425fe6c..b46e118a7235f 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/translations.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/translations.ts @@ -16,7 +16,7 @@ export const SERVICENOW_ITSM = i18n.translate('xpack.actions.builtin.serviceNowI }); export const SERVICENOW_SIR = i18n.translate('xpack.actions.builtin.serviceNowSIRTitle', { - defaultMessage: 'ServiceNow SIR', + defaultMessage: 'ServiceNow SecOps', }); export const ALLOWED_HOSTS_ERROR = (message: string) => diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts index 8de3f911106c0..1c0b2c9c62eee 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/types.ts @@ -121,6 +121,7 @@ export interface PushToServiceApiHandlerArgs extends ExternalServiceApiHandlerAr params: PushToServiceApiParams; secrets: Record; logger: Logger; + commentFieldKey: string; } export interface GetIncidentApiHandlerArgs extends ExternalServiceApiHandlerArgs { diff --git a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.test.ts b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.test.ts index 741f282b169ed..addd7391d782d 100644 --- a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.test.ts +++ b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.test.ts @@ -69,10 +69,10 @@ describe('createApmEventClient', () => { incomingRequest.on('abort', () => { setTimeout(() => { resolve(undefined); - }, 0); + }, 100); }); incomingRequest.abort(); - }, 50); + }, 100); }); expect(abort).toHaveBeenCalled(); diff --git a/x-pack/plugins/case/common/api/cases/case.ts b/x-pack/plugins/case/common/api/cases/case.ts index cd13b10846f12..bebd261fb7b9b 100644 --- a/x-pack/plugins/case/common/api/cases/case.ts +++ b/x-pack/plugins/case/common/api/cases/case.ts @@ -80,8 +80,6 @@ export const CasePostRequestRt = rt.type({ settings: SettingsRt, }); -export const CaseExternalServiceRequestRt = CaseExternalServiceBasicRt; - export const CasesFindRequestRt = rt.partial({ tags: rt.union([rt.array(rt.string), rt.string]), status: CaseStatusRt, @@ -126,6 +124,31 @@ export const CasePatchRequestRt = rt.intersection([ export const CasesPatchRequestRt = rt.type({ cases: rt.array(CasePatchRequestRt) }); export const CasesResponseRt = rt.array(CaseResponseRt); +export const CasePushRequestParamsRt = rt.type({ + case_id: rt.string, + connector_id: rt.string, +}); + +export const ExternalServiceResponseRt = rt.intersection([ + rt.type({ + title: rt.string, + id: rt.string, + pushedDate: rt.string, + url: rt.string, + }), + rt.partial({ + comments: rt.array( + rt.intersection([ + rt.type({ + commentId: rt.string, + pushedDate: rt.string, + }), + rt.partial({ externalCommentId: rt.string }), + ]) + ), + }), +]); + export type CaseAttributes = rt.TypeOf; export type CasePostRequest = rt.TypeOf; export type CaseResponse = rt.TypeOf; @@ -133,8 +156,8 @@ export type CasesResponse = rt.TypeOf; export type CasesFindResponse = rt.TypeOf; export type CasePatchRequest = rt.TypeOf; export type CasesPatchRequest = rt.TypeOf; -export type CaseExternalServiceRequest = rt.TypeOf; export type CaseFullExternalService = rt.TypeOf; +export type ExternalServiceResponse = rt.TypeOf; export type ESCaseAttributes = Omit & { connector: ESCaseConnector }; export type ESCasePatchRequest = Omit & { diff --git a/x-pack/plugins/case/common/api/cases/comment.ts b/x-pack/plugins/case/common/api/cases/comment.ts index 0670526e0df9c..7c9b31f496e54 100644 --- a/x-pack/plugins/case/common/api/cases/comment.ts +++ b/x-pack/plugins/case/common/api/cases/comment.ts @@ -45,6 +45,14 @@ export const CommentResponseRt = rt.intersection([ }), ]); +export const CommentResponseTypeAlertsRt = rt.intersection([ + AttributesTypeAlertsRt, + rt.type({ + id: rt.string, + version: rt.string, + }), +]); + export const AllCommentsResponseRT = rt.array(CommentResponseRt); export const CommentPatchRequestRt = rt.intersection([ @@ -84,6 +92,7 @@ export const AllCommentsResponseRt = rt.array(CommentResponseRt); export type CommentAttributes = rt.TypeOf; export type CommentRequest = rt.TypeOf; export type CommentResponse = rt.TypeOf; +export type CommentResponseAlertsType = rt.TypeOf; export type AllCommentsResponse = rt.TypeOf; export type CommentsResponse = rt.TypeOf; export type CommentPatchRequest = rt.TypeOf; diff --git a/x-pack/plugins/case/common/api/cases/configure.ts b/x-pack/plugins/case/common/api/cases/configure.ts index cb3a8b68082dc..b5a89efde1767 100644 --- a/x-pack/plugins/case/common/api/cases/configure.ts +++ b/x-pack/plugins/case/common/api/cases/configure.ts @@ -7,13 +7,9 @@ import * as rt from 'io-ts'; -import { ActionResult, ActionType } from '../../../../actions/common'; import { UserRT } from '../user'; import { CaseConnectorRt, ConnectorMappingsRt, ESCaseConnector } from '../connectors'; -export type ActionConnector = ActionResult; -export type ActionTypeConnector = ActionType; - // TODO: we will need to add this type rt.literal('close-by-third-party') const ClosureTypeRT = rt.union([rt.literal('close-by-user'), rt.literal('close-by-pushing')]); diff --git a/x-pack/plugins/case/common/api/connectors/index.ts b/x-pack/plugins/case/common/api/connectors/index.ts index 5fead4c8bd9c5..f9b7c8b12c2cd 100644 --- a/x-pack/plugins/case/common/api/connectors/index.ts +++ b/x-pack/plugins/case/common/api/connectors/index.ts @@ -7,25 +7,34 @@ import * as rt from 'io-ts'; +import { ActionResult, ActionType } from '../../../../actions/common'; import { JiraFieldsRT } from './jira'; import { ResilientFieldsRT } from './resilient'; -import { ServiceNowFieldsRT } from './servicenow'; +import { ServiceNowITSMFieldsRT } from './servicenow_itsm'; +import { ServiceNowSIRFieldsRT } from './servicenow_sir'; export * from './jira'; -export * from './servicenow'; +export * from './servicenow_itsm'; +export * from './servicenow_sir'; export * from './resilient'; export * from './mappings'; +export type ActionConnector = ActionResult; +export type ActionTypeConnector = ActionType; + export const ConnectorFieldsRt = rt.union([ JiraFieldsRT, ResilientFieldsRT, - ServiceNowFieldsRT, + ServiceNowITSMFieldsRT, + ServiceNowSIRFieldsRT, rt.null, ]); + export enum ConnectorTypes { jira = '.jira', resilient = '.resilient', - servicenow = '.servicenow', + serviceNowITSM = '.servicenow', + serviceNowSIR = '.servicenow-sir', none = '.none', } @@ -39,9 +48,14 @@ const ConnectorResillientTypeFieldsRt = rt.type({ fields: rt.union([ResilientFieldsRT, rt.null]), }); -const ConnectorServiceNowTypeFieldsRt = rt.type({ - type: rt.literal(ConnectorTypes.servicenow), - fields: rt.union([ServiceNowFieldsRT, rt.null]), +const ConnectorServiceNowITSMTypeFieldsRt = rt.type({ + type: rt.literal(ConnectorTypes.serviceNowITSM), + fields: rt.union([ServiceNowITSMFieldsRT, rt.null]), +}); + +const ConnectorServiceNowSIRTypeFieldsRt = rt.type({ + type: rt.literal(ConnectorTypes.serviceNowSIR), + fields: rt.union([ServiceNowSIRFieldsRT, rt.null]), }); const ConnectorNoneTypeFieldsRt = rt.type({ @@ -52,7 +66,8 @@ const ConnectorNoneTypeFieldsRt = rt.type({ export const ConnectorTypeFieldsRt = rt.union([ ConnectorJiraTypeFieldsRt, ConnectorResillientTypeFieldsRt, - ConnectorServiceNowTypeFieldsRt, + ConnectorServiceNowITSMTypeFieldsRt, + ConnectorServiceNowSIRTypeFieldsRt, ConnectorNoneTypeFieldsRt, ]); @@ -66,6 +81,12 @@ export const CaseConnectorRt = rt.intersection([ export type CaseConnector = rt.TypeOf; export type ConnectorTypeFields = rt.TypeOf; +export type ConnectorJiraTypeFields = rt.TypeOf; +export type ConnectorResillientTypeFields = rt.TypeOf; +export type ConnectorServiceNowITSMTypeFields = rt.TypeOf< + typeof ConnectorServiceNowITSMTypeFieldsRt +>; +export type ConnectorServiceNowSIRTypeFields = rt.TypeOf; // we need to change these types back and forth for storing in ES (arrays overwrite, objects merge) export type ConnectorFields = rt.TypeOf; diff --git a/x-pack/plugins/case/common/api/connectors/mappings.ts b/x-pack/plugins/case/common/api/connectors/mappings.ts index 38e3434f0e7a8..3d2013af47688 100644 --- a/x-pack/plugins/case/common/api/connectors/mappings.ts +++ b/x-pack/plugins/case/common/api/connectors/mappings.ts @@ -5,42 +5,7 @@ * 2.0. */ -/* eslint-disable @kbn/eslint/no-restricted-paths */ - import * as rt from 'io-ts'; -import { - PushToServiceApiParams as JiraPushToServiceApiParams, - Incident as JiraIncident, -} from '../../../../actions/server/builtin_action_types/jira/types'; -import { - PushToServiceApiParams as ResilientPushToServiceApiParams, - Incident as ResilientIncident, -} from '../../../../actions/server/builtin_action_types/resilient/types'; -import { - PushToServiceApiParamsITSM as ServiceNowITSMPushToServiceApiParams, - ServiceNowITSMIncident, -} from '../../../../actions/server/builtin_action_types/servicenow/types'; -import { ResilientFieldsRT } from './resilient'; -import { ServiceNowFieldsRT } from './servicenow'; -import { JiraFieldsRT } from './jira'; - -// Formerly imported from security_solution -export interface ElasticUser { - readonly email?: string | null; - readonly fullName?: string | null; - readonly username?: string | null; -} - -export { - JiraPushToServiceApiParams, - ResilientPushToServiceApiParams, - ServiceNowITSMPushToServiceApiParams, -}; -export type Incident = JiraIncident | ResilientIncident | ServiceNowITSMIncident; -export type PushToServiceApiParams = - | JiraPushToServiceApiParams - | ResilientPushToServiceApiParams - | ServiceNowITSMPushToServiceApiParams; const ActionTypeRT = rt.union([ rt.literal('append'), @@ -52,6 +17,7 @@ const CaseFieldRT = rt.union([ rt.literal('description'), rt.literal('comments'), ]); + const ThirdPartyFieldRT = rt.union([rt.string, rt.literal('not_mapped')]); export type ActionType = rt.TypeOf; export type CaseField = rt.TypeOf; @@ -62,9 +28,11 @@ export const ConnectorMappingsAttributesRT = rt.type({ source: CaseFieldRT, target: ThirdPartyFieldRT, }); + export const ConnectorMappingsRt = rt.type({ mappings: rt.array(ConnectorMappingsAttributesRT), }); + export type ConnectorMappingsAttributes = rt.TypeOf; export type ConnectorMappings = rt.TypeOf; @@ -76,125 +44,12 @@ const ConnectorFieldRt = rt.type({ required: rt.boolean, type: FieldTypeRT, }); + export type ConnectorField = rt.TypeOf; -export const ConnectorRequestParamsRt = rt.type({ - connector_id: rt.string, -}); -export const GetFieldsRequestQueryRt = rt.type({ - connector_type: rt.string, -}); + const GetFieldsResponseRt = rt.type({ defaultMappings: rt.array(ConnectorMappingsAttributesRT), fields: rt.array(ConnectorFieldRt), }); -export type GetFieldsResponse = rt.TypeOf; - -export type ExternalServiceParams = Record; - -export interface PipedField { - actionType: string; - key: string; - pipes: string[]; - value: string; -} -export interface PrepareFieldsForTransformArgs { - defaultPipes: string[]; - mappings: ConnectorMappingsAttributes[]; - params: ServiceConnectorCaseParams; -} -export interface EntityInformation { - createdAt: string; - createdBy: ElasticUser; - updatedAt: string | null; - updatedBy: ElasticUser | null; -} -export interface TransformerArgs { - date?: string; - previousValue?: string; - user?: string; - value: string; -} - -export type Transformer = (args: TransformerArgs) => TransformerArgs; -export interface TransformFieldsArgs { - currentIncident?: S; - fields: PipedField[]; - params: P; -} - -export const ServiceConnectorUserParams = rt.type({ - fullName: rt.union([rt.string, rt.null]), - username: rt.string, -}); - -export const ServiceConnectorCommentParamsRt = rt.type({ - commentId: rt.string, - comment: rt.string, - createdAt: rt.string, - createdBy: ServiceConnectorUserParams, - updatedAt: rt.union([rt.string, rt.null]), - updatedBy: rt.union([ServiceConnectorUserParams, rt.null]), -}); -export const ServiceConnectorBasicCaseParamsRt = rt.type({ - comments: rt.union([rt.array(ServiceConnectorCommentParamsRt), rt.null]), - createdAt: rt.string, - createdBy: ServiceConnectorUserParams, - description: rt.union([rt.string, rt.null]), - externalId: rt.union([rt.string, rt.null]), - savedObjectId: rt.string, - title: rt.string, - updatedAt: rt.union([rt.string, rt.null]), - updatedBy: rt.union([ServiceConnectorUserParams, rt.null]), -}); - -export const ConnectorPartialFieldsRt = rt.partial({ - ...JiraFieldsRT.props, - ...ResilientFieldsRT.props, - ...ServiceNowFieldsRT.props, -}); - -export const ServiceConnectorCaseParamsRt = rt.intersection([ - ServiceConnectorBasicCaseParamsRt, - ConnectorPartialFieldsRt, -]); -export const ServiceConnectorCaseResponseRt = rt.intersection([ - rt.type({ - title: rt.string, - id: rt.string, - pushedDate: rt.string, - url: rt.string, - }), - rt.partial({ - comments: rt.array( - rt.intersection([ - rt.type({ - commentId: rt.string, - pushedDate: rt.string, - }), - rt.partial({ externalCommentId: rt.string }), - ]) - ), - }), -]); -export type ServiceConnectorBasicCaseParams = rt.TypeOf; -export type ServiceConnectorCaseParams = rt.TypeOf; -export type ServiceConnectorCaseResponse = rt.TypeOf; -export type ServiceConnectorCommentParams = rt.TypeOf; - -export const PostPushRequestRt = rt.type({ - connector_type: rt.string, - params: ServiceConnectorCaseParamsRt, -}); - -export type PostPushRequest = rt.TypeOf; - -export interface SimpleComment { - comment: string; - commentId: string; -} - -export interface MapIncident { - incident: ExternalServiceParams; - comments: SimpleComment[]; -} +export type GetFieldsResponse = rt.TypeOf; diff --git a/x-pack/plugins/case/common/api/connectors/servicenow.ts b/x-pack/plugins/case/common/api/connectors/servicenow_itsm.ts similarity index 76% rename from x-pack/plugins/case/common/api/connectors/servicenow.ts rename to x-pack/plugins/case/common/api/connectors/servicenow_itsm.ts index fc4e8f9aa09a3..2e86a26971aaa 100644 --- a/x-pack/plugins/case/common/api/connectors/servicenow.ts +++ b/x-pack/plugins/case/common/api/connectors/servicenow_itsm.ts @@ -7,10 +7,10 @@ import * as rt from 'io-ts'; -export const ServiceNowFieldsRT = rt.type({ +export const ServiceNowITSMFieldsRT = rt.type({ impact: rt.union([rt.string, rt.null]), severity: rt.union([rt.string, rt.null]), urgency: rt.union([rt.string, rt.null]), }); -export type ServiceNowFieldsType = rt.TypeOf; +export type ServiceNowITSMFieldsType = rt.TypeOf; diff --git a/x-pack/plugins/case/common/api/connectors/servicenow_sir.ts b/x-pack/plugins/case/common/api/connectors/servicenow_sir.ts new file mode 100644 index 0000000000000..749abdea87437 --- /dev/null +++ b/x-pack/plugins/case/common/api/connectors/servicenow_sir.ts @@ -0,0 +1,20 @@ +/* + * 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 ServiceNowSIRFieldsRT = rt.type({ + category: rt.union([rt.string, rt.null]), + destIp: rt.union([rt.boolean, rt.null]), + malwareHash: rt.union([rt.boolean, rt.null]), + malwareUrl: rt.union([rt.boolean, rt.null]), + priority: rt.union([rt.string, rt.null]), + sourceIp: rt.union([rt.boolean, rt.null]), + subcategory: rt.union([rt.string, rt.null]), +}); + +export type ServiceNowSIRFieldsType = rt.TypeOf; diff --git a/x-pack/plugins/case/common/api/helpers.ts b/x-pack/plugins/case/common/api/helpers.ts index f9de74f45de46..24c4756a1596b 100644 --- a/x-pack/plugins/case/common/api/helpers.ts +++ b/x-pack/plugins/case/common/api/helpers.ts @@ -10,7 +10,7 @@ import { CASE_COMMENTS_URL, CASE_USER_ACTIONS_URL, CASE_COMMENT_DETAILS_URL, - CASE_CONFIGURE_PUSH_URL, + CASE_PUSH_URL, } from '../constants'; export const getCaseDetailsUrl = (id: string): string => { @@ -28,6 +28,6 @@ export const getCaseCommentDetailsUrl = (caseId: string, commentId: string): str export const getCaseUserActionUrl = (id: string): string => { return CASE_USER_ACTIONS_URL.replace('{case_id}', id); }; -export const getCaseConfigurePushUrl = (id: string): string => { - return CASE_CONFIGURE_PUSH_URL.replace('{connector_id}', id); +export const getCasePushUrl = (caseId: string, connectorId: string): string => { + return CASE_PUSH_URL.replace('{case_id}', caseId).replace('{connector_id}', connectorId); }; diff --git a/x-pack/plugins/case/common/constants.ts b/x-pack/plugins/case/common/constants.ts index 231ff9ef2dc4d..92dd2312f1ecf 100644 --- a/x-pack/plugins/case/common/constants.ts +++ b/x-pack/plugins/case/common/constants.ts @@ -15,10 +15,9 @@ export const CASES_URL = '/api/cases'; export const CASE_DETAILS_URL = `${CASES_URL}/{case_id}`; export const CASE_CONFIGURE_URL = `${CASES_URL}/configure`; export const CASE_CONFIGURE_CONNECTORS_URL = `${CASE_CONFIGURE_URL}/connectors`; -export const CASE_CONFIGURE_CONNECTOR_DETAILS_URL = `${CASE_CONFIGURE_CONNECTORS_URL}/{connector_id}`; -export const CASE_CONFIGURE_PUSH_URL = `${CASE_CONFIGURE_CONNECTOR_DETAILS_URL}/push`; export const CASE_COMMENTS_URL = `${CASE_DETAILS_URL}/comments`; export const CASE_COMMENT_DETAILS_URL = `${CASE_DETAILS_URL}/comments/{comment_id}`; +export const CASE_PUSH_URL = `${CASE_DETAILS_URL}/connector/{connector_id}/_push`; export const CASE_REPORTERS_URL = `${CASES_URL}/reporters`; export const CASE_STATUS_URL = `${CASES_URL}/status`; export const CASE_TAGS_URL = `${CASES_URL}/tags`; @@ -30,12 +29,14 @@ export const CASE_USER_ACTIONS_URL = `${CASE_DETAILS_URL}/user_actions`; export const ACTION_URL = '/api/actions'; export const ACTION_TYPES_URL = '/api/actions/list_action_types'; -export const SERVICENOW_ACTION_TYPE_ID = '.servicenow'; +export const SERVICENOW_ITSM_ACTION_TYPE_ID = '.servicenow'; +export const SERVICENOW_SIR_ACTION_TYPE_ID = '.servicenow-sir'; export const JIRA_ACTION_TYPE_ID = '.jira'; export const RESILIENT_ACTION_TYPE_ID = '.resilient'; export const SUPPORTED_CONNECTORS = [ - SERVICENOW_ACTION_TYPE_ID, + SERVICENOW_ITSM_ACTION_TYPE_ID, + SERVICENOW_SIR_ACTION_TYPE_ID, JIRA_ACTION_TYPE_ID, RESILIENT_ACTION_TYPE_ID, ]; diff --git a/x-pack/plugins/case/server/client/alerts/get.ts b/x-pack/plugins/case/server/client/alerts/get.ts new file mode 100644 index 0000000000000..718dd327aa08c --- /dev/null +++ b/x-pack/plugins/case/server/client/alerts/get.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 Boom from '@hapi/boom'; +import { CaseClientGetAlerts, CaseClientFactoryArguments } from '../types'; +import { CaseClientGetAlertsResponse } from './types'; + +export const get = ({ alertsService, request, context }: CaseClientFactoryArguments) => async ({ + ids, +}: CaseClientGetAlerts): Promise => { + const securitySolutionClient = context?.securitySolution?.getAppClient(); + if (securitySolutionClient == null) { + throw Boom.notFound('securitySolutionClient client have not been found'); + } + + if (ids.length === 0) { + return []; + } + + const index = securitySolutionClient.getSignalsIndex(); + const alerts = await alertsService.getAlerts({ ids, index, request }); + return alerts.hits.hits.map((alert) => ({ + id: alert._id, + index: alert._index, + ...alert._source, + })); +}; diff --git a/x-pack/plugins/case/server/client/alerts/types.ts b/x-pack/plugins/case/server/client/alerts/types.ts new file mode 100644 index 0000000000000..7b9d4a8856f48 --- /dev/null +++ b/x-pack/plugins/case/server/client/alerts/types.ts @@ -0,0 +1,19 @@ +/* + * 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. + */ + +interface Alert { + id: string; + index: string; + destination?: { + ip: string; + }; + source?: { + ip: string; + }; +} + +export type CaseClientGetAlertsResponse = Alert[]; diff --git a/x-pack/plugins/case/server/client/cases/get.ts b/x-pack/plugins/case/server/client/cases/get.ts new file mode 100644 index 0000000000000..c1901ccaae511 --- /dev/null +++ b/x-pack/plugins/case/server/client/cases/get.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 { flattenCaseSavedObject } from '../../routes/api/utils'; +import { CaseResponseRt, CaseResponse } from '../../../common/api'; +import { CaseClientGet, CaseClientFactoryArguments } from '../types'; + +export const get = ({ savedObjectsClient, caseService }: CaseClientFactoryArguments) => async ({ + id, + includeComments = false, +}: CaseClientGet): Promise => { + const theCase = await caseService.getCase({ + client: savedObjectsClient, + caseId: id, + }); + + if (!includeComments) { + return CaseResponseRt.encode( + flattenCaseSavedObject({ + savedObject: theCase, + }) + ); + } + + const theComments = await caseService.getAllCaseComments({ + client: savedObjectsClient, + caseId: id, + options: { + sortField: 'created_at', + sortOrder: 'asc', + }, + }); + + return CaseResponseRt.encode( + flattenCaseSavedObject({ + savedObject: theCase, + comments: theComments.saved_objects, + totalComment: theComments.total, + }) + ); +}; diff --git a/x-pack/plugins/case/server/client/cases/mock.ts b/x-pack/plugins/case/server/client/cases/mock.ts new file mode 100644 index 0000000000000..57e2d4373a52b --- /dev/null +++ b/x-pack/plugins/case/server/client/cases/mock.ts @@ -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 { + CommentResponse, + CommentType, + ConnectorMappingsAttributes, + CaseUserActionsResponse, +} from '../../../common/api'; + +import { BasicParams } from './types'; + +export const updateUser = { + updated_at: '2020-03-13T08:34:53.450Z', + updated_by: { full_name: 'Another User', username: 'another', email: 'elastic@elastic.co' }, +}; + +const entity = { + createdAt: '2020-03-13T08:34:53.450Z', + createdBy: { full_name: 'Elastic User', username: 'elastic', email: 'elastic@elastic.co' }, + updatedAt: null, + updatedBy: null, +}; + +export const comment: CommentResponse = { + id: 'mock-comment-1', + comment: 'Wow, good luck catching that bad meanie!', + type: CommentType.user as const, + created_at: '2019-11-25T21:55:00.177Z', + created_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + pushed_at: null, + pushed_by: null, + updated_at: '2019-11-25T21:55:00.177Z', + updated_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + version: 'WzEsMV0=', +}; + +export const commentAlert: CommentResponse = { + id: 'mock-comment-1', + alertId: 'alert-id-1', + index: 'alert-index-1', + type: CommentType.alert as const, + created_at: '2019-11-25T21:55:00.177Z', + created_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + pushed_at: null, + pushed_by: null, + updated_at: '2019-11-25T21:55:00.177Z', + updated_by: { + full_name: 'elastic', + email: 'testemail@elastic.co', + username: 'elastic', + }, + version: 'WzEsMV0=', +}; + +export const defaultPipes = ['informationCreated']; +export const basicParams: BasicParams = { + description: 'a description', + title: 'a title', + ...entity, +}; + +export const mappings: ConnectorMappingsAttributes[] = [ + { + source: 'title', + target: 'short_description', + action_type: 'overwrite', + }, + { + source: 'description', + target: 'description', + action_type: 'append', + }, + { + source: 'comments', + target: 'comments', + action_type: 'append', + }, +]; + +export const userActions: CaseUserActionsResponse = [ + { + action_field: ['description', 'status', 'tags', 'title', 'connector', 'settings'], + action: 'create', + action_at: '2021-02-03T17:41:03.771Z', + action_by: { + email: 'elastic@elastic.co', + full_name: 'Elastic', + username: 'elastic', + }, + new_value: + '{"title":"Case SIR","tags":["sir"],"description":"testing sir","connector":{"id":"456","name":"ServiceNow SN","type":".servicenow-sir","fields":{"category":"Denial of Service","destIp":true,"malwareHash":true,"malwareUrl":true,"priority":"2","sourceIp":true,"subcategory":"45"}},"settings":{"syncAlerts":true}}', + old_value: null, + action_id: 'fd830c60-6646-11eb-a291-51bf6b175a53', + case_id: 'fcdedd20-6646-11eb-a291-51bf6b175a53', + comment_id: null, + }, + { + action_field: ['pushed'], + action: 'push-to-service', + action_at: '2021-02-03T17:41:26.108Z', + action_by: { + email: 'elastic@elastic.co', + full_name: 'Elastic', + username: 'elastic', + }, + new_value: + '{"pushed_at":"2021-02-03T17:41:26.108Z","pushed_by":{"username":"elastic","full_name":"Elastic","email":"elastic@elastic.co"},"connector_id":"456","connector_name":"ServiceNow SN","external_id":"external-id","external_title":"SIR0010037","external_url":"https://dev92273.service-now.com/nav_to.do?uri=sn_si_incident.do?sys_id=external-id"}', + old_value: null, + action_id: '0a801750-6647-11eb-a291-51bf6b175a53', + case_id: 'fcdedd20-6646-11eb-a291-51bf6b175a53', + comment_id: null, + }, + { + action_field: ['comment'], + action: 'create', + action_at: '2021-02-03T17:44:21.067Z', + action_by: { + email: 'elastic@elastic.co', + full_name: 'Elastic', + username: 'elastic', + }, + new_value: '{"type":"alert","alertId":"alert-id-1","index":".siem-signals-default-000008"}', + old_value: null, + action_id: '7373eb60-6647-11eb-a291-51bf6b175a53', + case_id: 'fcdedd20-6646-11eb-a291-51bf6b175a53', + comment_id: 'comment-alert-1', + }, + { + action_field: ['comment'], + action: 'create', + action_at: '2021-02-03T17:44:33.078Z', + action_by: { + email: 'elastic@elastic.co', + full_name: 'Elastic', + username: 'elastic', + }, + new_value: '{"type":"alert","alertId":"alert-id-2","index":".siem-signals-default-000008"}', + old_value: null, + action_id: '7abc6410-6647-11eb-a291-51bf6b175a53', + case_id: 'fcdedd20-6646-11eb-a291-51bf6b175a53', + comment_id: 'comment-alert-2', + }, + { + action_field: ['pushed'], + action: 'push-to-service', + action_at: '2021-02-03T17:45:29.400Z', + action_by: { + email: 'elastic@elastic.co', + full_name: 'Elastic', + username: 'elastic', + }, + new_value: + '{"pushed_at":"2021-02-03T17:45:29.400Z","pushed_by":{"username":"elastic","full_name":"Elastic","email":"elastic@elastic.co"},"connector_id":"456","connector_name":"ServiceNow SN","external_id":"external-id","external_title":"SIR0010037","external_url":"https://dev92273.service-now.com/nav_to.do?uri=sn_si_incident.do?sys_id=external-id"}', + old_value: null, + action_id: '9b91d8f0-6647-11eb-a291-51bf6b175a53', + case_id: 'fcdedd20-6646-11eb-a291-51bf6b175a53', + comment_id: null, + }, + { + action_field: ['comment'], + action: 'create', + action_at: '2021-02-03T17:48:30.616Z', + action_by: { + email: 'elastic@elastic.co', + full_name: 'Elastic', + username: 'elastic', + }, + new_value: '{"comment":"a comment!","type":"user"}', + old_value: null, + action_id: '0818e5e0-6648-11eb-a291-51bf6b175a53', + case_id: 'fcdedd20-6646-11eb-a291-51bf6b175a53', + comment_id: 'comment-user-1', + }, +]; diff --git a/x-pack/plugins/case/server/client/cases/push.ts b/x-pack/plugins/case/server/client/cases/push.ts new file mode 100644 index 0000000000000..f329fb4d00d07 --- /dev/null +++ b/x-pack/plugins/case/server/client/cases/push.ts @@ -0,0 +1,266 @@ +/* + * 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 Boom, { isBoom, Boom as BoomType } from '@hapi/boom'; + +import { SavedObjectsBulkUpdateResponse, SavedObjectsUpdateResponse } from 'kibana/server'; +import { flattenCaseSavedObject } from '../../routes/api/utils'; + +import { + ActionConnector, + CaseResponseRt, + CaseResponse, + CaseStatuses, + ExternalServiceResponse, + ESCaseAttributes, + CommentAttributes, +} from '../../../common/api'; +import { buildCaseUserActionItem } from '../../services/user_actions/helpers'; + +import { CaseClientPush, CaseClientFactoryArguments } from '../types'; +import { createIncident, getCommentContextFromAttributes, isCommentAlertType } from './utils'; + +const createError = (e: Error | BoomType, message: string): Error | BoomType => { + if (isBoom(e)) { + e.message = message; + e.output.payload.message = message; + return e; + } + + return Error(message); +}; + +export const push = ({ + savedObjectsClient, + caseService, + caseConfigureService, + userActionService, + request, + response, +}: CaseClientFactoryArguments) => async ({ + actionsClient, + caseClient, + caseId, + connectorId, +}: CaseClientPush): Promise => { + /* Start of push to external service */ + let theCase; + let connector; + let userActions; + let alerts; + let connectorMappings; + let externalServiceIncident; + + try { + [theCase, connector, userActions] = await Promise.all([ + caseClient.get({ id: caseId, includeComments: true }), + actionsClient.get({ id: connectorId }), + caseClient.getUserActions({ caseId }), + ]); + } catch (e) { + const message = `Error getting case and/or connector and/or user actions: ${e.message}`; + throw createError(e, message); + } + + // We need to change the logic when we support subcases + if (theCase?.status === CaseStatuses.closed) { + throw Boom.conflict( + `This case ${theCase.title} is closed. You can not pushed if the case is closed.` + ); + } + + try { + alerts = await caseClient.getAlerts({ + ids: theCase?.comments?.filter(isCommentAlertType).map((comment) => comment.alertId) ?? [], + }); + } catch (e) { + throw new Error(`Error getting alerts for case with id ${theCase.id}: ${e.message}`); + } + + try { + connectorMappings = await caseClient.getMappings({ + actionsClient, + caseClient, + connectorId: connector.id, + connectorType: connector.actionTypeId, + }); + } catch (e) { + const message = `Error getting mapping for connector with id ${connector.id}: ${e.message}`; + throw createError(e, message); + } + + try { + externalServiceIncident = await createIncident({ + actionsClient, + theCase, + userActions, + connector: connector as ActionConnector, + mappings: connectorMappings, + alerts, + }); + } catch (e) { + const message = `Error creating incident for case with id ${theCase.id}: ${e.message}`; + throw createError(e, message); + } + + const pushRes = await actionsClient.execute({ + actionId: connector?.id ?? '', + params: { + subAction: 'pushToService', + subActionParams: externalServiceIncident, + }, + }); + + if (pushRes.status === 'error') { + throw Boom.failedDependency( + pushRes.serviceMessage ?? pushRes.message ?? 'Error pushing to service' + ); + } + + /* End of push to external service */ + + /* Start of update case with push information */ + let user; + let myCase; + let myCaseConfigure; + let comments; + + try { + [user, myCase, myCaseConfigure, comments] = await Promise.all([ + caseService.getUser({ request, response }), + caseService.getCase({ + client: savedObjectsClient, + caseId, + }), + caseConfigureService.find({ client: savedObjectsClient }), + caseService.getAllCaseComments({ + client: savedObjectsClient, + caseId, + options: { + fields: [], + page: 1, + perPage: theCase?.totalComment ?? 0, + }, + }), + ]); + } catch (e) { + const message = `Error getting user and/or case and/or case configuration and/or case comments: ${e.message}`; + throw createError(e, message); + } + + // eslint-disable-next-line @typescript-eslint/naming-convention + const { username, full_name, email } = user; + const pushedDate = new Date().toISOString(); + const externalServiceResponse = pushRes.data as ExternalServiceResponse; + + const externalService = { + pushed_at: pushedDate, + pushed_by: { username, full_name, email }, + connector_id: connector.id, + connector_name: connector.name, + external_id: externalServiceResponse.id, + external_title: externalServiceResponse.title, + external_url: externalServiceResponse.url, + }; + + let updatedCase: SavedObjectsUpdateResponse; + let updatedComments: SavedObjectsBulkUpdateResponse; + + try { + [updatedCase, updatedComments] = await Promise.all([ + caseService.patchCase({ + client: savedObjectsClient, + caseId, + updatedAttributes: { + ...(myCaseConfigure.total > 0 && + myCaseConfigure.saved_objects[0].attributes.closure_type === 'close-by-pushing' + ? { + status: CaseStatuses.closed, + closed_at: pushedDate, + closed_by: { email, full_name, username }, + } + : {}), + external_service: externalService, + updated_at: pushedDate, + updated_by: { username, full_name, email }, + }, + version: myCase.version, + }), + + caseService.patchComments({ + client: savedObjectsClient, + comments: comments.saved_objects + .filter((comment) => comment.attributes.pushed_at == null) + .map((comment) => ({ + commentId: comment.id, + updatedAttributes: { + pushed_at: pushedDate, + pushed_by: { username, full_name, email }, + }, + version: comment.version, + })), + }), + + userActionService.postUserActions({ + client: savedObjectsClient, + actions: [ + ...(myCaseConfigure.total > 0 && + myCaseConfigure.saved_objects[0].attributes.closure_type === 'close-by-pushing' + ? [ + buildCaseUserActionItem({ + action: 'update', + actionAt: pushedDate, + actionBy: { username, full_name, email }, + caseId, + fields: ['status'], + newValue: CaseStatuses.closed, + oldValue: myCase.attributes.status, + }), + ] + : []), + buildCaseUserActionItem({ + action: 'push-to-service', + actionAt: pushedDate, + actionBy: { username, full_name, email }, + caseId, + fields: ['pushed'], + newValue: JSON.stringify(externalService), + }), + ], + }), + ]); + } catch (e) { + const message = `Error updating case and/or comments and/or creating user action: ${e.message}`; + throw createError(e, message); + } + /* End of update case with push information */ + + return CaseResponseRt.encode( + flattenCaseSavedObject({ + savedObject: { + ...myCase, + ...updatedCase, + attributes: { ...myCase.attributes, ...updatedCase?.attributes }, + references: myCase.references, + }, + comments: comments.saved_objects.map((origComment) => { + const updatedComment = updatedComments.saved_objects.find((c) => c.id === origComment.id); + return { + ...origComment, + ...updatedComment, + attributes: { + ...origComment.attributes, + ...updatedComment?.attributes, + ...getCommentContextFromAttributes(origComment.attributes), + }, + version: updatedComment?.version ?? origComment.version, + references: origComment?.references ?? [], + }; + }), + }) + ); +}; diff --git a/x-pack/plugins/case/server/client/cases/types.ts b/x-pack/plugins/case/server/client/cases/types.ts new file mode 100644 index 0000000000000..f1d56e7132bd1 --- /dev/null +++ b/x-pack/plugins/case/server/client/cases/types.ts @@ -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. + */ + +/* eslint-disable @kbn/eslint/no-restricted-paths */ +import { + PushToServiceApiParams as JiraPushToServiceApiParams, + Incident as JiraIncident, +} from '../../../../actions/server/builtin_action_types/jira/types'; +import { + PushToServiceApiParams as ResilientPushToServiceApiParams, + Incident as ResilientIncident, +} from '../../../../actions/server/builtin_action_types/resilient/types'; +import { + PushToServiceApiParamsITSM as ServiceNowITSMPushToServiceApiParams, + PushToServiceApiParamsSIR as ServiceNowSIRPushToServiceApiParams, + ServiceNowITSMIncident, +} from '../../../../actions/server/builtin_action_types/servicenow/types'; +import { CaseResponse, ConnectorMappingsAttributes } from '../../../common/api'; + +export type Incident = JiraIncident | ResilientIncident | ServiceNowITSMIncident; +export type PushToServiceApiParams = + | JiraPushToServiceApiParams + | ResilientPushToServiceApiParams + | ServiceNowITSMPushToServiceApiParams + | ServiceNowSIRPushToServiceApiParams; + +export type ExternalServiceParams = Record; + +export interface BasicParams { + title: CaseResponse['title']; + description: CaseResponse['description']; + createdAt: CaseResponse['created_at']; + createdBy: CaseResponse['created_by']; + updatedAt: CaseResponse['updated_at']; + updatedBy: CaseResponse['updated_by']; +} + +export interface PipedField { + actionType: string; + key: string; + pipes: string[]; + value: string; +} +export interface PrepareFieldsForTransformArgs { + defaultPipes: string[]; + mappings: ConnectorMappingsAttributes[]; + params: { title: string; description: string }; +} +export interface EntityInformation { + createdAt: CaseResponse['created_at']; + createdBy: CaseResponse['created_by']; + updatedAt: CaseResponse['updated_at']; + updatedBy: CaseResponse['updated_by']; +} +export interface TransformerArgs { + date?: string; + previousValue?: string; + user?: string; + value: string; +} + +export type Transformer = (args: TransformerArgs) => TransformerArgs; +export interface TransformFieldsArgs { + currentIncident?: S; + fields: PipedField[]; + params: P; +} + +export interface ExternalServiceComment { + comment: string; + commentId: string; +} + +export interface MapIncident { + incident: ExternalServiceParams; + comments: ExternalServiceComment[]; +} diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/utils.test.ts b/x-pack/plugins/case/server/client/cases/utils.test.ts similarity index 52% rename from x-pack/plugins/case/server/routes/api/cases/configure/utils.test.ts rename to x-pack/plugins/case/server/client/cases/utils.test.ts index 5114703c60963..dca2c34602678 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/utils.test.ts +++ b/x-pack/plugins/case/server/client/cases/utils.test.ts @@ -5,34 +5,45 @@ * 2.0. */ +import { actionsClientMock } from '../../../../actions/server/actions_client.mock'; +import { flattenCaseSavedObject } from '../../routes/api/utils'; +import { mockCases } from '../../routes/api/__fixtures__'; + +import { BasicParams, ExternalServiceParams, Incident } from './types'; +import { + comment as commentObj, + mappings, + defaultPipes, + basicParams, + userActions, + commentAlert, +} from './mock'; + import { - mapIncident, + createIncident, + getLatestPushInfo, prepareFieldsForTransformation, - serviceFormatter, transformComments, transformers, transformFields, } from './utils'; -import { comment as commentObj, mappings, defaultPipes, params, updateUser } from './mock'; -import { - ConnectorTypes, - ExternalServiceParams, - Incident, - ServiceConnectorCaseParams, -} from '../../../../../common/api/connectors'; -import { actionsClientMock } from '../../../../../../actions/server/actions_client.mock'; -import { mappings as mappingsMock } from '../../../../client/configure/mock'; -const formatComment = { commentId: commentObj.commentId, comment: commentObj.comment }; -const serviceNowParams = params[ConnectorTypes.servicenow] as ServiceConnectorCaseParams; -describe('api/cases/configure/utils', () => { +const formatComment = { + commentId: commentObj.id, + comment: 'Wow, good luck catching that bad meanie!', +}; + +const params = { ...basicParams }; + +describe('utils', () => { describe('prepareFieldsForTransformation', () => { test('prepare fields with defaults', () => { const res = prepareFieldsForTransformation({ defaultPipes, - params: serviceNowParams, + params, mappings, }); + expect(res).toEqual([ { actionType: 'overwrite', @@ -53,8 +64,9 @@ describe('api/cases/configure/utils', () => { const res = prepareFieldsForTransformation({ defaultPipes: ['myTestPipe'], mappings, - params: serviceNowParams, + params, }); + expect(res).toEqual([ { actionType: 'overwrite', @@ -71,16 +83,17 @@ describe('api/cases/configure/utils', () => { ]); }); }); + describe('transformFields', () => { test('transform fields for creation correctly', () => { const fields = prepareFieldsForTransformation({ defaultPipes, mappings, - params: serviceNowParams, + params, }); - const res = transformFields({ - params: serviceNowParams, + const res = transformFields({ + params, fields, }); @@ -92,18 +105,19 @@ describe('api/cases/configure/utils', () => { test('transform fields for update correctly', () => { const fields = prepareFieldsForTransformation({ - params: serviceNowParams, + params, mappings, defaultPipes: ['informationUpdated'], }); - const res = transformFields({ + const res = transformFields({ params: { - ...serviceNowParams, + ...params, updatedAt: '2020-03-15T08:34:53.450Z', updatedBy: { username: 'anotherUser', - fullName: 'Another User', + full_name: 'Another User', + email: 'elastic@elastic.co', }, }, fields, @@ -112,6 +126,7 @@ describe('api/cases/configure/utils', () => { description: 'first description (created at 2020-03-13T08:34:53.450Z by Elastic User)', }, }); + expect(res).toEqual({ short_description: 'a title (updated at 2020-03-15T08:34:53.450Z by Another User)', description: @@ -121,13 +136,13 @@ describe('api/cases/configure/utils', () => { test('add newline character to description', () => { const fields = prepareFieldsForTransformation({ - params: serviceNowParams, + params, mappings, defaultPipes: ['informationUpdated'], }); - const res = transformFields({ - params: serviceNowParams, + const res = transformFields({ + params, fields, currentIncident: { short_description: 'first title', @@ -141,13 +156,13 @@ describe('api/cases/configure/utils', () => { const fields = prepareFieldsForTransformation({ defaultPipes, mappings, - params: serviceNowParams, + params, }); - const res = transformFields({ + const res = transformFields({ params: { - ...serviceNowParams, - createdBy: { fullName: '', username: 'elastic' }, + ...params, + createdBy: { full_name: '', username: 'elastic', email: 'elastic@elastic.co' }, }, fields, }); @@ -162,14 +177,14 @@ describe('api/cases/configure/utils', () => { const fields = prepareFieldsForTransformation({ defaultPipes: ['informationUpdated'], mappings, - params: serviceNowParams, + params, }); - const res = transformFields({ + const res = transformFields({ params: { - ...serviceNowParams, + ...params, updatedAt: '2020-03-15T08:34:53.450Z', - updatedBy: { username: 'anotherUser', fullName: '' }, + updatedBy: { username: 'anotherUser', full_name: '', email: 'elastic@elastic.co' }, }, fields, }); @@ -180,6 +195,7 @@ describe('api/cases/configure/utils', () => { }); }); }); + describe('transformComments', () => { test('transform creation comments', () => { const comments = [commentObj]; @@ -187,7 +203,7 @@ describe('api/cases/configure/utils', () => { expect(res).toEqual([ { ...formatComment, - comment: `${formatComment.comment} (created at ${comments[0].createdAt} by ${comments[0].createdBy.fullName})`, + comment: `${formatComment.comment} (created at ${comments[0].created_at} by ${comments[0].created_by.full_name})`, }, ]); }); @@ -196,14 +212,19 @@ describe('api/cases/configure/utils', () => { const comments = [ { ...commentObj, - ...updateUser, + updated_at: '2020-03-13T08:34:53.450Z', + updated_by: { + full_name: 'Another User', + username: 'another', + email: 'elastic@elastic.co', + }, }, ]; const res = transformComments(comments, ['informationUpdated']); expect(res).toEqual([ { ...formatComment, - comment: `${formatComment.comment} (updated at ${updateUser.updatedAt} by ${updateUser.updatedBy.fullName})`, + comment: `${formatComment.comment} (updated at ${comments[0].updated_at} by ${comments[0].updated_by.full_name})`, }, ]); }); @@ -214,19 +235,19 @@ describe('api/cases/configure/utils', () => { expect(res).toEqual([ { ...formatComment, - comment: `${formatComment.comment} (added at ${comments[0].createdAt} by ${comments[0].createdBy.fullName})`, + comment: `${formatComment.comment} (added at ${comments[0].created_at} by ${comments[0].created_by.full_name})`, }, ]); }); test('transform comments without fullname', () => { - const comments = [{ ...commentObj, createdBy: { username: commentObj.createdBy.username } }]; - // @ts-ignore testing no fullName + const comments = [{ ...commentObj, createdBy: { username: commentObj.created_by.username } }]; + // @ts-ignore testing no full_name const res = transformComments(comments, ['informationAdded']); expect(res).toEqual([ { ...formatComment, - comment: `${formatComment.comment} (added at ${comments[0].createdAt} by ${comments[0].createdBy.username})`, + comment: `${formatComment.comment} (added at ${comments[0].created_at} by ${comments[0].created_by.username})`, }, ]); }); @@ -235,15 +256,15 @@ describe('api/cases/configure/utils', () => { const comments = [ { ...commentObj, - updatedAt: '2020-04-13T08:34:53.450Z', - updatedBy: { fullName: 'Elastic2', username: 'elastic' }, + updated_at: '2020-04-13T08:34:53.450Z', + updated_by: { full_name: 'Elastic2', username: 'elastic', email: 'elastic@elastic.co' }, }, ]; const res = transformComments(comments, ['informationAdded']); expect(res).toEqual([ { ...formatComment, - comment: `${formatComment.comment} (added at ${comments[0].updatedAt} by ${comments[0].updatedBy.fullName})`, + comment: `${formatComment.comment} (added at ${comments[0].updated_at} by ${comments[0].updated_by.full_name})`, }, ]); }); @@ -252,19 +273,20 @@ describe('api/cases/configure/utils', () => { const comments = [ { ...commentObj, - updatedAt: '2020-04-13T08:34:53.450Z', - updatedBy: { fullName: '', username: 'elastic2' }, + updated_at: '2020-04-13T08:34:53.450Z', + updated_by: { full_name: '', username: 'elastic2', email: 'elastic@elastic.co' }, }, ]; const res = transformComments(comments, ['informationAdded']); expect(res).toEqual([ { ...formatComment, - comment: `${formatComment.comment} (added at ${comments[0].updatedAt} by ${comments[0].updatedBy.username})`, + comment: `${formatComment.comment} (added at ${comments[0].updated_at} by ${comments[0].updated_by.username})`, }, ]); }); }); + describe('transformers', () => { const { informationCreated, informationUpdated, informationAdded, append } = transformers; describe('informationCreated', () => { @@ -389,142 +411,291 @@ describe('api/cases/configure/utils', () => { }); }); }); - describe('mapIncident', () => { + + describe('createIncident', () => { let actionsMock = actionsClientMock.create(); - it('maps an external incident', async () => { - const res = await mapIncident( - actionsMock, - '123', - ConnectorTypes.servicenow, - mappingsMock[ConnectorTypes.servicenow], - serviceNowParams - ); + const theCase = { + ...flattenCaseSavedObject({ + savedObject: mockCases[0], + }), + comments: [commentObj], + totalComments: 1, + }; + + const connector = { + id: '456', + actionTypeId: '.jira', + name: 'Connector without isCaseOwned', + config: { + apiUrl: 'https://elastic.jira.com', + }, + isPreconfigured: false, + }; + + it('creates an external incident', async () => { + const res = await createIncident({ + actionsClient: actionsMock, + theCase, + userActions: [], + connector, + mappings, + alerts: [], + }); + expect(res).toEqual({ incident: { - description: 'a description (created at 2020-03-13T08:34:53.450Z by Elastic User)', + priority: null, + labels: ['defacement'], + issueType: null, + parent: null, + short_description: + 'Super Bad Security Issue (created at 2019-11-25T21:54:48.952Z by elastic)', + description: + 'This is a brand new case of a bad meanie defacing data (created at 2019-11-25T21:54:48.952Z by elastic)', externalId: null, - impact: '3', - severity: '1', - short_description: 'a title (created at 2020-03-13T08:34:53.450Z by Elastic User)', - urgency: '2', }, - comments: [ + comments: [], + }); + }); + + it('it creates comments correctly', async () => { + const res = await createIncident({ + actionsClient: actionsMock, + theCase: { + ...theCase, + comments: [{ ...commentObj, id: 'comment-user-1' }], + }, + userActions, + connector, + mappings, + alerts: [], + }); + + expect(res.comments).toEqual([ + { + comment: + 'Wow, good luck catching that bad meanie! (added at 2019-11-25T21:55:00.177Z by elastic)', + commentId: 'comment-user-1', + }, + ]); + }); + + it('it does NOT creates comments when mapping is nothing', async () => { + const res = await createIncident({ + actionsClient: actionsMock, + theCase: { + ...theCase, + comments: [{ ...commentObj, id: 'comment-user-1' }], + }, + userActions, + connector, + mappings: [ + mappings[0], + mappings[1], { - comment: 'first comment (added at 2020-03-13T08:34:53.450Z by Elastic User)', - commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631', + source: 'comments', + target: 'comments', + action_type: 'nothing', }, ], + alerts: [], }); + + expect(res.comments).toEqual([]); }); - it('throws error if invalid service', async () => { - await mapIncident( - actionsMock, - '123', - 'invalid', - mappingsMock[ConnectorTypes.servicenow], - serviceNowParams - ).catch((e) => { - expect(e).not.toBeNull(); - expect(e).toEqual(new Error(`Invalid service`)); + + it('it creates comments of type alert correctly', async () => { + const res = await createIncident({ + actionsClient: actionsMock, + theCase: { + ...theCase, + comments: [ + { ...commentObj, id: 'comment-user-1' }, + { ...commentAlert, id: 'comment-alert-1' }, + { ...commentAlert, id: 'comment-alert-2' }, + ], + }, + // Remove second push + userActions: userActions.filter((item, index) => index !== 4), + connector, + mappings: [ + ...mappings, + { + source: 'comments', + target: 'comments', + action_type: 'nothing', + }, + ], + alerts: [], }); + + expect(res.comments).toEqual([ + { + comment: + 'Wow, good luck catching that bad meanie! (added at 2019-11-25T21:55:00.177Z by elastic)', + commentId: 'comment-user-1', + }, + { + comment: + 'Alert with id alert-id-1 added to case (added at 2019-11-25T21:55:00.177Z by elastic)', + commentId: 'comment-alert-1', + }, + { + comment: + 'Alert with id alert-id-1 added to case (added at 2019-11-25T21:55:00.177Z by elastic)', + commentId: 'comment-alert-2', + }, + ]); }); + it('updates an existing incident', async () => { const existingIncidentData = { - description: 'fun description', - impact: '3', - severity: '3', + priority: null, + issueType: null, + parent: null, short_description: 'fun title', - urgency: '3', + description: 'fun description', }; + const execute = jest.fn().mockReturnValue(existingIncidentData); actionsMock = { ...actionsMock, execute }; - const res = await mapIncident( - actionsMock, - '123', - ConnectorTypes.servicenow, - mappingsMock[ConnectorTypes.servicenow], - { ...serviceNowParams, externalId: '123' } - ); + + const res = await createIncident({ + actionsClient: actionsMock, + theCase, + userActions, + connector, + mappings, + alerts: [], + }); + expect(res).toEqual({ incident: { - description: 'a description (updated at 2020-03-13T08:34:53.450Z by Elastic User)', - externalId: '123', - impact: '3', - severity: '1', - short_description: 'a title (updated at 2020-03-13T08:34:53.450Z by Elastic User)', - urgency: '2', + priority: null, + labels: ['defacement'], + issueType: null, + parent: null, + description: + 'fun description \r\nThis is a brand new case of a bad meanie defacing data (updated at 2019-11-25T21:54:48.952Z by elastic)', + externalId: 'external-id', + short_description: + 'Super Bad Security Issue (updated at 2019-11-25T21:54:48.952Z by elastic)', }, - comments: [ - { - comment: 'first comment (added at 2020-03-13T08:34:53.450Z by Elastic User)', - commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631', - }, - ], + comments: [], }); }); + it('throws error when existing incident throws', async () => { + expect.assertions(2); const execute = jest.fn().mockImplementation(() => { throw new Error('exception'); }); + actionsMock = { ...actionsMock, execute }; - await mapIncident( - actionsMock, - '123', - ConnectorTypes.servicenow, - mappingsMock[ConnectorTypes.servicenow], - { ...serviceNowParams, externalId: '123' } - ).catch((e) => { + createIncident({ + actionsClient: actionsMock, + theCase, + userActions, + connector, + mappings, + alerts: [], + }).catch((e) => { expect(e).not.toBeNull(); expect(e).toEqual( new Error( - `Retrieving Incident by id 123 from ServiceNow failed with exception: Error: exception` + `Retrieving Incident by id external-id from .jira failed with exception: Error: exception` ) ); }); }); - }); - const connectors = [ - { - name: ConnectorTypes.jira, - result: { - incident: { - issueType: '10003', - parent: '5002', - priority: 'Highest', - }, - thirdPartyName: 'Jira', - }, - }, - { - name: ConnectorTypes.resilient, - result: { - incident: { - incidentTypes: ['10003'], - severityCode: '1', - }, - thirdPartyName: 'Resilient', - }, - }, - { - name: ConnectorTypes.servicenow, - result: { - incident: { - impact: '3', - severity: '1', - urgency: '2', - }, - thirdPartyName: 'ServiceNow', - }, - }, - ]; - describe('serviceFormatter', () => { - connectors.forEach((c) => - it(`formats ${c.name}`, () => { - const caseParams = params[c.name] as ServiceConnectorCaseParams; - const res = serviceFormatter(c.name, caseParams); - expect(res).toEqual(c.result); - }) - ); + it('throws error if connector is not supported', async () => { + expect.assertions(2); + createIncident({ + actionsClient: actionsMock, + theCase, + userActions, + connector: { ...connector, actionTypeId: 'not-supported' }, + mappings, + alerts: [], + }).catch((e) => { + expect(e).not.toBeNull(); + expect(e).toEqual(new Error('Invalid external service')); + }); + }); + + describe('getLatestPushInfo', () => { + it('it returns the latest push information correctly', async () => { + const res = getLatestPushInfo('456', userActions); + expect(res).toEqual({ + index: 4, + pushedInfo: { + connector_id: '456', + connector_name: 'ServiceNow SN', + external_id: 'external-id', + external_title: 'SIR0010037', + external_url: + 'https://dev92273.service-now.com/nav_to.do?uri=sn_si_incident.do?sys_id=external-id', + pushed_at: '2021-02-03T17:45:29.400Z', + pushed_by: { + email: 'elastic@elastic.co', + full_name: 'Elastic', + username: 'elastic', + }, + }, + }); + }); + + it('it returns null when there are not actions', async () => { + const res = getLatestPushInfo('456', []); + expect(res).toBe(null); + }); + + it('it returns null when there are no push user action', async () => { + const res = getLatestPushInfo('456', [userActions[0]]); + expect(res).toBe(null); + }); + + it('it returns the correct push information when with multiple push on different connectors', async () => { + const res = getLatestPushInfo('456', [ + ...userActions.slice(0, 3), + { + action_field: ['pushed'], + action: 'push-to-service', + action_at: '2021-02-03T17:45:29.400Z', + action_by: { + email: 'elastic@elastic.co', + full_name: 'Elastic', + username: 'elastic', + }, + new_value: + // The connector id is 123 + '{"pushed_at":"2021-02-03T17:45:29.400Z","pushed_by":{"username":"elastic","full_name":"Elastic","email":"elastic@elastic.co"},"connector_id":"123","connector_name":"ServiceNow SN","external_id":"external-id","external_title":"SIR0010037","external_url":"https://dev92273.service-now.com/nav_to.do?uri=sn_si_incident.do?sys_id=external-id"}', + old_value: null, + action_id: '9b91d8f0-6647-11eb-a291-51bf6b175a53', + case_id: 'fcdedd20-6646-11eb-a291-51bf6b175a53', + comment_id: null, + }, + ]); + + expect(res).toEqual({ + index: 1, + pushedInfo: { + connector_id: '456', + connector_name: 'ServiceNow SN', + external_id: 'external-id', + external_title: 'SIR0010037', + external_url: + 'https://dev92273.service-now.com/nav_to.do?uri=sn_si_incident.do?sys_id=external-id', + pushed_at: '2021-02-03T17:41:26.108Z', + pushed_by: { + email: 'elastic@elastic.co', + full_name: 'Elastic', + username: 'elastic', + }, + }, + }); + }); + }); }); }); diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/utils.ts b/x-pack/plugins/case/server/client/cases/utils.ts similarity index 50% rename from x-pack/plugins/case/server/routes/api/cases/configure/utils.ts rename to x-pack/plugins/case/server/client/cases/utils.ts index 01a1a580bd78f..6974fd4ffa288 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/utils.ts +++ b/x-pack/plugins/case/server/client/cases/utils.ts @@ -8,46 +8,118 @@ import { i18n } from '@kbn/i18n'; import { flow } from 'lodash'; import { - ServiceConnectorCaseParams, - ServiceConnectorCommentParams, + ActionConnector, + CaseResponse, + CaseFullExternalService, + CaseUserActionsResponse, + CommentResponse, + CommentResponseAlertsType, + CommentType, ConnectorMappingsAttributes, ConnectorTypes, + CommentAttributes, + CommentRequestUserType, + CommentRequestAlertType, +} from '../../../common/api'; +import { ActionsClient } from '../../../../actions/server'; +import { externalServiceFormatters, FormatterConnectorTypes } from '../../connectors'; +import { CaseClientGetAlertsResponse } from '../../client/alerts/types'; +import { + BasicParams, EntityInformation, ExternalServiceParams, + ExternalServiceComment, Incident, - JiraPushToServiceApiParams, MapIncident, PipedField, PrepareFieldsForTransformArgs, PushToServiceApiParams, - ResilientPushToServiceApiParams, - ServiceNowITSMPushToServiceApiParams, - SimpleComment, Transformer, TransformerArgs, TransformFieldsArgs, -} from '../../../../../common/api'; -import { ActionsClient } from '../../../../../../actions/server'; -export const mapIncident = async ( - actionsClient: ActionsClient, +} from './types'; + +export const getLatestPushInfo = ( connectorId: string, - connectorType: string, - mappings: ConnectorMappingsAttributes[], - params: ServiceConnectorCaseParams -): Promise => { - const { comments: caseComments, externalId } = params; + userActions: CaseUserActionsResponse +): { index: number; pushedInfo: CaseFullExternalService } | null => { + for (const [index, action] of [...userActions].reverse().entries()) { + if (action.action === 'push-to-service' && action.new_value) + try { + const pushedInfo = JSON.parse(action.new_value); + if (pushedInfo.connector_id === connectorId) { + // We returned the index of the element in the userActions array. + // As we traverse the userActions in reverse we need to calculate the index of a normal traversal + return { index: userActions.length - index - 1, pushedInfo }; + } + } catch (e) { + // Silence JSON parse errors + } + } + + return null; +}; + +const isConnectorSupported = (connectorId: string): connectorId is FormatterConnectorTypes => + Object.values(ConnectorTypes).includes(connectorId as ConnectorTypes); + +const getCommentContent = (comment: CommentResponse): string => { + if (comment.type === CommentType.user) { + return comment.comment; + } else if (comment.type === CommentType.alert) { + return `Alert with id ${comment.alertId} added to case`; + } + + return ''; +}; + +interface CreateIncidentArgs { + actionsClient: ActionsClient; + theCase: CaseResponse; + userActions: CaseUserActionsResponse; + connector: ActionConnector; + mappings: ConnectorMappingsAttributes[]; + alerts: CaseClientGetAlertsResponse; +} + +export const createIncident = async ({ + actionsClient, + theCase, + userActions, + connector, + mappings, + alerts, +}: CreateIncidentArgs): Promise => { + const { + comments: caseComments, + title, + description, + created_at: createdAt, + created_by: createdBy, + updated_at: updatedAt, + updated_by: updatedBy, + } = theCase; + + if (!isConnectorSupported(connector.actionTypeId)) { + throw new Error('Invalid external service'); + } + + const params = { title, description, createdAt, createdBy, updatedAt, updatedBy }; + const latestPushInfo = getLatestPushInfo(connector.id, userActions); + const externalId = latestPushInfo?.pushedInfo?.external_id ?? null; const defaultPipes = externalId ? ['informationUpdated'] : ['informationCreated']; let currentIncident: ExternalServiceParams | undefined; - const service = serviceFormatter(connectorType, params); - if (service == null) { - throw new Error(`Invalid service`); - } - const thirdPartyName = service.thirdPartyName; - let incident: Partial = service.incident; + + const externalServiceFields = externalServiceFormatters[connector.actionTypeId].format( + theCase, + alerts + ); + let incident: Partial = { ...externalServiceFields }; + if (externalId) { try { currentIncident = ((await actionsClient.execute({ - actionId: connectorId, + actionId: connector.id, params: { subAction: 'getIncident', subActionParams: { externalId }, @@ -55,80 +127,56 @@ export const mapIncident = async ( })) as unknown) as ExternalServiceParams | undefined; } catch (ex) { throw new Error( - `Retrieving Incident by id ${externalId} from ${thirdPartyName} failed with exception: ${ex}` + `Retrieving Incident by id ${externalId} from ${connector.actionTypeId} failed with exception: ${ex}` ); } } + const fields = prepareFieldsForTransformation({ defaultPipes, mappings, params, }); - const transformedFields = transformFields< - ServiceConnectorCaseParams, - ExternalServiceParams, - Incident - >({ + + const transformedFields = transformFields({ params, fields, currentIncident, }); + incident = { ...incident, ...transformedFields, externalId }; - let comments: SimpleComment[] = []; - if (caseComments && Array.isArray(caseComments) && caseComments.length > 0) { + + const commentsIdsToBeUpdated = new Set( + userActions + .slice(latestPushInfo?.index ?? 0) + .filter( + (action, index) => + Array.isArray(action.action_field) && action.action_field[0] === 'comment' + ) + .map((action) => action.comment_id) + ); + const commentsToBeUpdated = caseComments?.filter((comment) => + commentsIdsToBeUpdated.has(comment.id) + ); + + let comments: ExternalServiceComment[] = []; + if (commentsToBeUpdated && Array.isArray(commentsToBeUpdated) && commentsToBeUpdated.length > 0) { const commentsMapping = mappings.find((m) => m.source === 'comments'); if (commentsMapping?.action_type !== 'nothing') { - comments = transformComments(caseComments, ['informationAdded']); + comments = transformComments(commentsToBeUpdated, ['informationAdded']); } } return { incident, comments }; }; -export const serviceFormatter = ( - connectorType: string, - params: unknown -): { thirdPartyName: string; incident: Partial } | null => { - switch (connectorType) { - case ConnectorTypes.jira: - const { - priority, - labels, - issueType, - parent, - } = params as JiraPushToServiceApiParams['incident']; - return { - incident: { priority, labels, issueType, parent }, - thirdPartyName: 'Jira', - }; - case ConnectorTypes.resilient: - const { incidentTypes, severityCode } = params as ResilientPushToServiceApiParams['incident']; - return { - incident: { incidentTypes, severityCode }, - thirdPartyName: 'Resilient', - }; - case ConnectorTypes.servicenow: - const { - severity, - urgency, - impact, - } = params as ServiceNowITSMPushToServiceApiParams['incident']; - return { - incident: { severity, urgency, impact }, - thirdPartyName: 'ServiceNow', - }; - default: - return null; - } -}; - export const getEntity = (entity: EntityInformation): string => (entity.updatedBy != null - ? entity.updatedBy.fullName - ? entity.updatedBy.fullName + ? entity.updatedBy.full_name + ? entity.updatedBy.full_name : entity.updatedBy.username : entity.createdBy != null - ? entity.createdBy.fullName - ? entity.createdBy.fullName + ? entity.createdBy.full_name + ? entity.createdBy.full_name : entity.createdBy.username : '') ?? ''; @@ -160,6 +208,7 @@ export const FIELD_INFORMATION = ( }); } }; + export const transformers: Record = { informationCreated: ({ value, date, user, ...rest }: TransformerArgs): TransformerArgs => ({ value: `${value} ${FIELD_INFORMATION('create', date, user)}`, @@ -178,6 +227,7 @@ export const transformers: Record = { ...rest, }), }; + export const prepareFieldsForTransformation = ({ defaultPipes, mappings, @@ -226,14 +276,46 @@ export const transformFields = < }; export const transformComments = ( - comments: ServiceConnectorCommentParams[], + comments: CaseResponse['comments'] = [], pipes: string[] -): SimpleComment[] => +): ExternalServiceComment[] => comments.map((c) => ({ comment: flow(...pipes.map((p) => transformers[p]))({ - value: c.comment, - date: c.updatedAt ?? c.createdAt, - user: getEntity(c), + value: getCommentContent(c), + date: c.updated_at ?? c.created_at, + user: getEntity({ + createdAt: c.created_at, + createdBy: c.created_by, + updatedAt: c.updated_at, + updatedBy: c.updated_by, + }), }).value, - commentId: c.commentId, + commentId: c.id, })); + +export const isCommentAlertType = ( + comment: CommentResponse +): comment is CommentResponseAlertsType => comment.type === CommentType.alert; + +export const getCommentContextFromAttributes = ( + attributes: CommentAttributes +): CommentRequestUserType | CommentRequestAlertType => { + switch (attributes.type) { + case CommentType.user: + return { + type: CommentType.user, + comment: attributes.comment, + }; + case CommentType.alert: + return { + type: CommentType.alert, + alertId: attributes.alertId, + index: attributes.index, + }; + default: + return { + type: CommentType.user, + comment: '', + }; + } +}; diff --git a/x-pack/plugins/case/server/client/configure/mock.ts b/x-pack/plugins/case/server/client/configure/mock.ts index 46df0a7ac6756..4d0c384e23e27 100644 --- a/x-pack/plugins/case/server/client/configure/mock.ts +++ b/x-pack/plugins/case/server/client/configure/mock.ts @@ -70,7 +70,7 @@ export const mappings: TestMappings = { action_type: 'append', }, ], - [ConnectorTypes.servicenow]: [ + [ConnectorTypes.serviceNowITSM]: [ { source: 'title', target: 'short_description', @@ -611,7 +611,7 @@ export const formatFieldsTestData: FormatFieldsTestData[] = [ { id: 'upon_reject', name: 'Upon reject', required: false, type: 'text' }, ], fields: serviceNowFields, - type: ConnectorTypes.servicenow, + type: ConnectorTypes.serviceNowITSM, }, ]; export const mockGetFieldsResponse = { diff --git a/x-pack/plugins/case/server/client/configure/utils.ts b/x-pack/plugins/case/server/client/configure/utils.ts index 2fc9e3d17801c..7e91c2ae5a4d7 100644 --- a/x-pack/plugins/case/server/client/configure/utils.ts +++ b/x-pack/plugins/case/server/client/configure/utils.ts @@ -70,7 +70,9 @@ export const formatFields = (theData: unknown, theType: string): ConnectorField[ return normalizeJiraFields(theData as JiraGetFieldsResponse); case ConnectorTypes.resilient: return normalizeResilientFields(theData as ResilientGetFieldsResponse); - case ConnectorTypes.servicenow: + case ConnectorTypes.serviceNowITSM: + return normalizeServiceNowFields(theData as ServiceNowGetFieldsResponse); + case ConnectorTypes.serviceNowSIR: return normalizeServiceNowFields(theData as ServiceNowGetFieldsResponse); default: return []; @@ -97,10 +99,14 @@ const getPreferredFields = (theType: string) => { } else if (theType === ConnectorTypes.resilient) { title = 'name'; description = 'description'; - } else if (theType === ConnectorTypes.servicenow) { + } else if ( + theType === ConnectorTypes.serviceNowITSM || + theType === ConnectorTypes.serviceNowSIR + ) { title = 'short_description'; description = 'description'; } + return { title, description }; }; diff --git a/x-pack/plugins/case/server/client/index.test.ts b/x-pack/plugins/case/server/client/index.test.ts index 095dc5102b720..4daa4d1c0bd8b 100644 --- a/x-pack/plugins/case/server/client/index.test.ts +++ b/x-pack/plugins/case/server/client/index.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { KibanaRequest } from 'kibana/server'; +import { KibanaRequest, kibanaResponseFactory } from '../../../../../src/core/server'; import { savedObjectsClientMock } from '../../../../../src/core/server/mocks'; import { createCaseClient } from '.'; import { @@ -17,29 +17,48 @@ import { } from '../services/mocks'; import { create } from './cases/create'; +import { get } from './cases/get'; import { update } from './cases/update'; +import { push } from './cases/push'; import { addComment } from './comments/add'; +import { getFields } from './configure/get_fields'; +import { getMappings } from './configure/get_mappings'; import { updateAlertsStatus } from './alerts/update_status'; +import { get as getUserActions } from './user_actions/get'; +import { get as getAlerts } from './alerts/get'; import type { CasesRequestHandlerContext } from '../types'; jest.mock('./cases/create'); jest.mock('./cases/update'); +jest.mock('./cases/get'); +jest.mock('./cases/push'); jest.mock('./comments/add'); jest.mock('./alerts/update_status'); +jest.mock('./alerts/get'); +jest.mock('./user_actions/get'); +jest.mock('./configure/get_fields'); +jest.mock('./configure/get_mappings'); const caseConfigureService = createConfigureServiceMock(); const alertsService = createAlertServiceMock(); const caseService = createCaseServiceMock(); const connectorMappingsService = connectorMappingsServiceMock(); const request = {} as KibanaRequest; +const response = kibanaResponseFactory; const savedObjectsClient = savedObjectsClientMock.create(); const userActionService = createUserActionServiceMock(); const context = {} as CasesRequestHandlerContext; const createMock = create as jest.Mock; +const getMock = get as jest.Mock; const updateMock = update as jest.Mock; +const pushMock = push as jest.Mock; const addCommentMock = addComment as jest.Mock; const updateAlertsStatusMock = updateAlertsStatus as jest.Mock; +const getAlertsStatusMock = getAlerts as jest.Mock; +const getFieldsMock = getFields as jest.Mock; +const getMappingsMock = getMappings as jest.Mock; +const getUserActionsMock = getUserActions as jest.Mock; describe('createCaseClient()', () => { test('it creates the client correctly', async () => { @@ -50,49 +69,34 @@ describe('createCaseClient()', () => { connectorMappingsService, context, request, + response, savedObjectsClient, userActionService, }); - expect(createMock).toHaveBeenCalledWith({ - alertsService, - caseConfigureService, - caseService, - connectorMappingsService, - request, - savedObjectsClient, - userActionService, - }); - - expect(updateMock).toHaveBeenCalledWith({ - alertsService, - caseConfigureService, - caseService, - connectorMappingsService, - request, - savedObjectsClient, - userActionService, - }); - - expect(addCommentMock).toHaveBeenCalledWith({ - alertsService, - caseConfigureService, - caseService, - connectorMappingsService, - request, - savedObjectsClient, - userActionService, - }); - - expect(updateAlertsStatusMock).toHaveBeenCalledWith({ - alertsService, - caseConfigureService, - caseService, - connectorMappingsService, - context, - request, - savedObjectsClient, - userActionService, - }); + [ + createMock, + getMock, + updateMock, + pushMock, + addCommentMock, + updateAlertsStatusMock, + getAlertsStatusMock, + getFieldsMock, + getMappingsMock, + getUserActionsMock, + ].forEach((method) => + expect(method).toHaveBeenCalledWith({ + caseConfigureService, + caseService, + connectorMappingsService, + request, + response, + savedObjectsClient, + userActionService, + alertsService, + context, + }) + ); }); }); diff --git a/x-pack/plugins/case/server/client/index.ts b/x-pack/plugins/case/server/client/index.ts index 1b9d3ce7ecb08..e15b9fc766562 100644 --- a/x-pack/plugins/case/server/client/index.ts +++ b/x-pack/plugins/case/server/client/index.ts @@ -5,73 +5,41 @@ * 2.0. */ -import { CaseClientFactoryArguments, CaseClient } from './types'; +import { + CaseClientFactoryArguments, + CaseClient, + CaseClientFactoryMethods, + CaseClientMethods, +} from './types'; import { create } from './cases/create'; +import { get } from './cases/get'; import { update } from './cases/update'; +import { push } from './cases/push'; import { addComment } from './comments/add'; import { getFields } from './configure/get_fields'; import { getMappings } from './configure/get_mappings'; import { updateAlertsStatus } from './alerts/update_status'; +import { get as getUserActions } from './user_actions/get'; +import { get as getAlerts } from './alerts/get'; export { CaseClient } from './types'; -export const createCaseClient = ({ - caseConfigureService, - caseService, - connectorMappingsService, - request, - savedObjectsClient, - userActionService, - alertsService, - context, -}: CaseClientFactoryArguments): CaseClient => { - return { - create: create({ - alertsService, - caseConfigureService, - caseService, - connectorMappingsService, - request, - savedObjectsClient, - userActionService, - }), - update: update({ - alertsService, - caseConfigureService, - caseService, - connectorMappingsService, - request, - savedObjectsClient, - userActionService, - }), - addComment: addComment({ - alertsService, - caseConfigureService, - caseService, - connectorMappingsService, - request, - savedObjectsClient, - userActionService, - }), - getFields: getFields(), - getMappings: getMappings({ - alertsService, - caseConfigureService, - caseService, - connectorMappingsService, - request, - savedObjectsClient, - userActionService, - }), - updateAlertsStatus: updateAlertsStatus({ - alertsService, - caseConfigureService, - caseService, - connectorMappingsService, - context, - request, - savedObjectsClient, - userActionService, - }), +export const createCaseClient = (args: CaseClientFactoryArguments): CaseClient => { + const methods: CaseClientFactoryMethods = { + create, + get, + update, + push, + addComment, + getAlerts, + getFields, + getMappings, + getUserActions, + updateAlertsStatus, }; + + return (Object.keys(methods) as CaseClientMethods[]).reduce((client, method) => { + client[method] = methods[method](args); + return client; + }, {} as CaseClient); }; diff --git a/x-pack/plugins/case/server/client/mocks.ts b/x-pack/plugins/case/server/client/mocks.ts index 0d7f3972e58e7..b2a07e36b3aed 100644 --- a/x-pack/plugins/case/server/client/mocks.ts +++ b/x-pack/plugins/case/server/client/mocks.ts @@ -6,9 +6,9 @@ */ import { omit } from 'lodash/fp'; -import { KibanaRequest } from 'kibana/server'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { KibanaRequest, kibanaResponseFactory } from '../../../../../src/core/server/http'; import { loggingSystemMock } from '../../../../../src/core/server/mocks'; -import { actionsClientMock } from '../../../actions/server/mocks'; import { AlertServiceContract, CaseConfigureService, @@ -17,17 +17,20 @@ import { ConnectorMappingsService, } from '../services'; import { CaseClient } from './types'; -import { authenticationMock } from '../routes/api/__fixtures__'; +import { authenticationMock, createActionsClient } from '../routes/api/__fixtures__'; import { createCaseClient } from '.'; -import { getActions } from '../routes/api/__mocks__/request_responses'; import type { CasesRequestHandlerContext } from '../types'; export type CaseClientMock = jest.Mocked; export const createCaseClientMock = (): CaseClientMock => ({ addComment: jest.fn(), create: jest.fn(), + get: jest.fn(), + push: jest.fn(), + getAlerts: jest.fn(), getFields: jest.fn(), getMappings: jest.fn(), + getUserActions: jest.fn(), update: jest.fn(), updateAlertsStatus: jest.fn(), }); @@ -47,10 +50,10 @@ export const createCaseClientWithMockSavedObjectsClient = async ({ alertsService: jest.Mocked; }; }> => { - const actionsMock = actionsClientMock.create(); - actionsMock.getAll.mockImplementation(() => Promise.resolve(getActions())); + const actionsMock = createActionsClient(); const log = loggingSystemMock.create().get('case'); const request = {} as KibanaRequest; + const response = kibanaResponseFactory; const caseServicePlugin = new CaseService(log); const caseConfigureServicePlugin = new CaseConfigureService(log); @@ -63,11 +66,15 @@ export const createCaseClientWithMockSavedObjectsClient = async ({ const connectorMappingsService = await connectorMappingsServicePlugin.setup(); const userActionService = { - postUserActions: jest.fn(), getUserActions: jest.fn(), + postUserActions: jest.fn(), }; - const alertsService = { initialize: jest.fn(), updateAlertsStatus: jest.fn() }; + const alertsService = { + initialize: jest.fn(), + updateAlertsStatus: jest.fn(), + getAlerts: jest.fn(), + }; const context = { core: { @@ -89,6 +96,7 @@ export const createCaseClientWithMockSavedObjectsClient = async ({ const caseClient = createCaseClient({ savedObjectsClient, request, + response, caseService, caseConfigureService, connectorMappingsService, diff --git a/x-pack/plugins/case/server/client/types.ts b/x-pack/plugins/case/server/client/types.ts index a3466e26294f8..8778aa46a2d24 100644 --- a/x-pack/plugins/case/server/client/types.ts +++ b/x-pack/plugins/case/server/client/types.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { KibanaRequest, SavedObjectsClientContract } from 'kibana/server'; +import { KibanaRequest, KibanaResponseFactory, SavedObjectsClientContract } from 'kibana/server'; import { ActionsClient } from '../../../actions/server'; import { CasePostRequest, @@ -16,6 +16,7 @@ import { CommentRequest, ConnectorMappingsAttributes, GetFieldsResponse, + CaseUserActionsResponse, } from '../../common/api'; import { CaseConfigureServiceSetup, @@ -25,6 +26,7 @@ import { } from '../services'; import { ConnectorMappingsServiceSetup } from '../services/connector_mappings'; import type { CasesRequestHandlerContext } from '../types'; +import { CaseClientGetAlertsResponse } from './alerts/types'; export interface CaseClientCreate { theCase: CasePostRequest; @@ -35,6 +37,18 @@ export interface CaseClientUpdate { cases: CasesPatchRequest; } +export interface CaseClientGet { + id: string; + includeComments?: boolean; +} + +export interface CaseClientPush { + actionsClient: ActionsClient; + caseClient: CaseClient; + caseId: string; + connectorId: string; +} + export interface CaseClientAddComment { caseClient: CaseClient; caseId: string; @@ -46,11 +60,27 @@ export interface CaseClientUpdateAlertsStatus { status: CaseStatuses; } +export interface CaseClientGetAlerts { + ids: string[]; +} + +export interface CaseClientGetUserActions { + caseId: string; +} + +export interface MappingsClient { + actionsClient: ActionsClient; + caseClient: CaseClient; + connectorId: string; + connectorType: string; +} + export interface CaseClientFactoryArguments { caseConfigureService: CaseConfigureServiceSetup; caseService: CaseServiceSetup; connectorMappingsService: ConnectorMappingsServiceSetup; request: KibanaRequest; + response: KibanaResponseFactory; savedObjectsClient: SavedObjectsClientContract; userActionService: CaseUserActionServiceSetup; alertsService: AlertServiceContract; @@ -65,15 +95,22 @@ export interface ConfigureFields { export interface CaseClient { addComment: (args: CaseClientAddComment) => Promise; create: (args: CaseClientCreate) => Promise; + get: (args: CaseClientGet) => Promise; + getAlerts: (args: CaseClientGetAlerts) => Promise; getFields: (args: ConfigureFields) => Promise; getMappings: (args: MappingsClient) => Promise; + getUserActions: (args: CaseClientGetUserActions) => Promise; + push: (args: CaseClientPush) => Promise; update: (args: CaseClientUpdate) => Promise; updateAlertsStatus: (args: CaseClientUpdateAlertsStatus) => Promise; } -export interface MappingsClient { - actionsClient: ActionsClient; - caseClient: CaseClient; - connectorId: string; - connectorType: string; -} +export type CaseClientFactoryMethod = ( + factoryArgs: CaseClientFactoryArguments +) => (methodArgs: any) => Promise; + +export type CaseClientMethods = keyof CaseClient; + +export type CaseClientFactoryMethods = { + [K in CaseClientMethods]: CaseClientFactoryMethod; +}; diff --git a/x-pack/plugins/case/server/client/user_actions/get.ts b/x-pack/plugins/case/server/client/user_actions/get.ts new file mode 100644 index 0000000000000..e83a9e3484262 --- /dev/null +++ b/x-pack/plugins/case/server/client/user_actions/get.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 { CASE_SAVED_OBJECT, CASE_COMMENT_SAVED_OBJECT } from '../../saved_object_types'; +import { CaseUserActionsResponseRt, CaseUserActionsResponse } from '../../../common/api'; +import { CaseClientGetUserActions, CaseClientFactoryArguments } from '../types'; + +export const get = ({ + savedObjectsClient, + userActionService, +}: CaseClientFactoryArguments) => async ({ + caseId, +}: CaseClientGetUserActions): Promise => { + const userActions = await userActionService.getUserActions({ + client: savedObjectsClient, + caseId, + }); + + return CaseUserActionsResponseRt.encode( + userActions.saved_objects.map((ua) => ({ + ...ua.attributes, + action_id: ua.id, + case_id: ua.references.find((r) => r.type === CASE_SAVED_OBJECT)?.id ?? '', + comment_id: ua.references.find((r) => r.type === CASE_COMMENT_SAVED_OBJECT)?.id ?? null, + })) + ); +}; diff --git a/x-pack/plugins/case/server/connectors/case/index.ts b/x-pack/plugins/case/server/connectors/case/index.ts index 01446942c33c6..9907aa5b3cd3a 100644 --- a/x-pack/plugins/case/server/connectors/case/index.ts +++ b/x-pack/plugins/case/server/connectors/case/index.ts @@ -7,7 +7,7 @@ import { curry } from 'lodash'; -import { KibanaRequest } from 'kibana/server'; +import { KibanaRequest, kibanaResponseFactory } from '../../../../../../src/core/server'; import { ActionTypeExecutorResult } from '../../../../actions/common'; import { CasePatchRequest, CasePostRequest } from '../../../common/api'; import { createCaseClient } from '../../client'; @@ -73,6 +73,7 @@ async function executor( const caseClient = createCaseClient({ savedObjectsClient, request: {} as KibanaRequest, + response: kibanaResponseFactory, caseService, caseConfigureService, connectorMappingsService, diff --git a/x-pack/plugins/case/server/connectors/index.ts b/x-pack/plugins/case/server/connectors/index.ts index 100511e271b02..00809d81ca5f2 100644 --- a/x-pack/plugins/case/server/connectors/index.ts +++ b/x-pack/plugins/case/server/connectors/index.ts @@ -5,43 +5,14 @@ * 2.0. */ -import { Logger } from 'kibana/server'; -import { - ActionTypeConfig, - ActionTypeSecrets, - ActionTypeParams, - ActionType, - // eslint-disable-next-line @kbn/eslint/no-restricted-paths -} from '../../../actions/server/types'; -import { - CaseServiceSetup, - CaseConfigureServiceSetup, - CaseUserActionServiceSetup, - ConnectorMappingsServiceSetup, - AlertServiceContract, -} from '../services'; - +import { RegisterConnectorsArgs, ExternalServiceFormatterMapper } from './types'; import { getActionType as getCaseConnector } from './case'; +import { serviceNowITSMExternalServiceFormatter } from './servicenow/itsm_formatter'; +import { serviceNowSIRExternalServiceFormatter } from './servicenow/sir_formatter'; +import { jiraExternalServiceFormatter } from './jira/external_service_formatter'; +import { resilientExternalServiceFormatter } from './resilient/external_service_formatter'; -export interface GetActionTypeParams { - logger: Logger; - caseService: CaseServiceSetup; - caseConfigureService: CaseConfigureServiceSetup; - connectorMappingsService: ConnectorMappingsServiceSetup; - userActionService: CaseUserActionServiceSetup; - alertsService: AlertServiceContract; -} - -export interface RegisterConnectorsArgs extends GetActionTypeParams { - actionsRegisterType< - Config extends ActionTypeConfig = ActionTypeConfig, - Secrets extends ActionTypeSecrets = ActionTypeSecrets, - Params extends ActionTypeParams = ActionTypeParams, - ExecutorResultData = void - >( - actionType: ActionType - ): void; -} +export * from './types'; export const registerConnectors = ({ actionsRegisterType, @@ -63,3 +34,10 @@ export const registerConnectors = ({ }) ); }; + +export const externalServiceFormatters: ExternalServiceFormatterMapper = { + '.servicenow': serviceNowITSMExternalServiceFormatter, + '.servicenow-sir': serviceNowSIRExternalServiceFormatter, + '.jira': jiraExternalServiceFormatter, + '.resilient': resilientExternalServiceFormatter, +}; diff --git a/x-pack/plugins/case/server/connectors/jira/external_service_formatter.test.ts b/x-pack/plugins/case/server/connectors/jira/external_service_formatter.test.ts new file mode 100644 index 0000000000000..0bfaf7cdbd9e3 --- /dev/null +++ b/x-pack/plugins/case/server/connectors/jira/external_service_formatter.test.ts @@ -0,0 +1,35 @@ +/* + * 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 { CaseResponse } from '../../../common/api'; +import { jiraExternalServiceFormatter } from './external_service_formatter'; + +describe('Jira formatter', () => { + const theCase = { + tags: ['tag'], + connector: { fields: { priority: 'High', issueType: 'Task', parent: null } }, + } as CaseResponse; + + it('it formats correctly', async () => { + const res = await jiraExternalServiceFormatter.format(theCase, []); + expect(res).toEqual({ ...theCase.connector.fields, labels: theCase.tags }); + }); + + it('it formats correctly when fields do not exist ', async () => { + const invalidFields = { tags: ['tag'], connector: { fields: null } } as CaseResponse; + const res = await jiraExternalServiceFormatter.format(invalidFields, []); + expect(res).toEqual({ priority: null, issueType: null, parent: null, labels: theCase.tags }); + }); + + it('it replace white spaces with hyphens on tags', async () => { + const res = await jiraExternalServiceFormatter.format( + { ...theCase, tags: ['a tag with spaces'] }, + [] + ); + expect(res).toEqual({ ...theCase.connector.fields, labels: ['a-tag-with-spaces'] }); + }); +}); diff --git a/x-pack/plugins/case/server/connectors/jira/external_service_formatter.ts b/x-pack/plugins/case/server/connectors/jira/external_service_formatter.ts new file mode 100644 index 0000000000000..74376d295fea5 --- /dev/null +++ b/x-pack/plugins/case/server/connectors/jira/external_service_formatter.ts @@ -0,0 +1,29 @@ +/* + * 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 { JiraFieldsType, ConnectorJiraTypeFields } from '../../../common/api'; +import { ExternalServiceFormatter } from '../types'; + +interface ExternalServiceParams extends JiraFieldsType { + labels: string[]; +} + +const format: ExternalServiceFormatter['format'] = (theCase) => { + const { priority = null, issueType = null, parent = null } = + (theCase.connector.fields as ConnectorJiraTypeFields['fields']) ?? {}; + return { + priority, + // Jira do not allows empty spaces on labels. We replace white spaces with hyphens + labels: theCase.tags.map((tag) => tag.replace(/\s+/g, '-')), + issueType, + parent, + }; +}; + +export const jiraExternalServiceFormatter: ExternalServiceFormatter = { + format, +}; diff --git a/x-pack/plugins/case/server/connectors/resilient/external_service_formatter.test.ts b/x-pack/plugins/case/server/connectors/resilient/external_service_formatter.test.ts new file mode 100644 index 0000000000000..01280e9692b5e --- /dev/null +++ b/x-pack/plugins/case/server/connectors/resilient/external_service_formatter.test.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 { CaseResponse } from '../../../common/api'; +import { resilientExternalServiceFormatter } from './external_service_formatter'; + +describe('IBM Resilient formatter', () => { + const theCase = { + connector: { fields: { incidentTypes: ['2'], severityCode: '2' } }, + } as CaseResponse; + + it('it formats correctly', async () => { + const res = await resilientExternalServiceFormatter.format(theCase, []); + expect(res).toEqual({ ...theCase.connector.fields }); + }); + + it('it formats correctly when fields do not exist ', async () => { + const invalidFields = { tags: ['a tag'], connector: { fields: null } } as CaseResponse; + const res = await resilientExternalServiceFormatter.format(invalidFields, []); + expect(res).toEqual({ incidentTypes: null, severityCode: null }); + }); +}); diff --git a/x-pack/plugins/case/server/connectors/resilient/external_service_formatter.ts b/x-pack/plugins/case/server/connectors/resilient/external_service_formatter.ts new file mode 100644 index 0000000000000..76554dce32797 --- /dev/null +++ b/x-pack/plugins/case/server/connectors/resilient/external_service_formatter.ts @@ -0,0 +1,19 @@ +/* + * 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 { ResilientFieldsType, ConnectorResillientTypeFields } from '../../../common/api'; +import { ExternalServiceFormatter } from '../types'; + +const format: ExternalServiceFormatter['format'] = (theCase) => { + const { incidentTypes = null, severityCode = null } = + (theCase.connector.fields as ConnectorResillientTypeFields['fields']) ?? {}; + return { incidentTypes, severityCode }; +}; + +export const resilientExternalServiceFormatter: ExternalServiceFormatter = { + format, +}; diff --git a/x-pack/plugins/case/server/connectors/servicenow/itsm_formatter.ts b/x-pack/plugins/case/server/connectors/servicenow/itsm_formatter.ts new file mode 100644 index 0000000000000..60faa82a9e3fa --- /dev/null +++ b/x-pack/plugins/case/server/connectors/servicenow/itsm_formatter.ts @@ -0,0 +1,19 @@ +/* + * 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 { ServiceNowITSMFieldsType, ConnectorServiceNowITSMTypeFields } from '../../../common/api'; +import { ExternalServiceFormatter } from '../types'; + +const format: ExternalServiceFormatter['format'] = (theCase) => { + const { severity = null, urgency = null, impact = null } = + (theCase.connector.fields as ConnectorServiceNowITSMTypeFields['fields']) ?? {}; + return { severity, urgency, impact }; +}; + +export const serviceNowITSMExternalServiceFormatter: ExternalServiceFormatter = { + format, +}; diff --git a/x-pack/plugins/case/server/connectors/servicenow/itsm_formmater.test.ts b/x-pack/plugins/case/server/connectors/servicenow/itsm_formmater.test.ts new file mode 100644 index 0000000000000..033f184c7e751 --- /dev/null +++ b/x-pack/plugins/case/server/connectors/servicenow/itsm_formmater.test.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 { CaseResponse } from '../../../common/api'; +import { serviceNowITSMExternalServiceFormatter } from './itsm_formatter'; + +describe('ITSM formatter', () => { + const theCase = { + connector: { fields: { severity: '2', urgency: '2', impact: '2' } }, + } as CaseResponse; + + it('it formats correctly', async () => { + const res = await serviceNowITSMExternalServiceFormatter.format(theCase, []); + expect(res).toEqual(theCase.connector.fields); + }); + + it('it formats correctly when fields do not exist ', async () => { + const invalidFields = { connector: { fields: null } } as CaseResponse; + const res = await serviceNowITSMExternalServiceFormatter.format(invalidFields, []); + expect(res).toEqual({ severity: null, urgency: null, impact: null }); + }); +}); diff --git a/x-pack/plugins/case/server/connectors/servicenow/sir_formatter.test.ts b/x-pack/plugins/case/server/connectors/servicenow/sir_formatter.test.ts new file mode 100644 index 0000000000000..4faca62c6e706 --- /dev/null +++ b/x-pack/plugins/case/server/connectors/servicenow/sir_formatter.test.ts @@ -0,0 +1,164 @@ +/* + * 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 { CaseResponse } from '../../../common/api'; +import { serviceNowSIRExternalServiceFormatter } from './sir_formatter'; + +describe('ITSM formatter', () => { + const theCase = { + connector: { + fields: { + destIp: true, + sourceIp: true, + category: 'Denial of Service', + subcategory: 'Inbound DDos', + malwareHash: true, + malwareUrl: true, + priority: '2 - High', + }, + }, + } as CaseResponse; + + it('it formats correctly without alerts', async () => { + const res = await serviceNowSIRExternalServiceFormatter.format(theCase, []); + expect(res).toEqual({ + dest_ip: null, + source_ip: null, + category: 'Denial of Service', + subcategory: 'Inbound DDos', + malware_hash: null, + malware_url: null, + priority: '2 - High', + }); + }); + + it('it formats correctly when fields do not exist ', async () => { + const invalidFields = { connector: { fields: null } } as CaseResponse; + const res = await serviceNowSIRExternalServiceFormatter.format(invalidFields, []); + expect(res).toEqual({ + dest_ip: null, + source_ip: null, + category: null, + subcategory: null, + malware_hash: null, + malware_url: null, + priority: null, + }); + }); + + it('it formats correctly with alerts', async () => { + const alerts = [ + { + id: 'alert-1', + index: 'index-1', + destination: { ip: '192.168.1.1' }, + source: { ip: '192.168.1.2' }, + file: { + hash: { sha256: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08' }, + }, + url: { full: 'https://attack.com' }, + }, + { + id: 'alert-2', + index: 'index-2', + destination: { ip: '192.168.1.4' }, + source: { ip: '192.168.1.3' }, + file: { + hash: { sha256: '60303ae22b998861bce3b28f33eec1be758a213c86c93c076dbe9f558c11c752' }, + }, + url: { full: 'https://attack.com/api' }, + }, + ]; + const res = await serviceNowSIRExternalServiceFormatter.format(theCase, alerts); + expect(res).toEqual({ + dest_ip: '192.168.1.1,192.168.1.4', + source_ip: '192.168.1.2,192.168.1.3', + category: 'Denial of Service', + subcategory: 'Inbound DDos', + malware_hash: + '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08,60303ae22b998861bce3b28f33eec1be758a213c86c93c076dbe9f558c11c752', + malware_url: 'https://attack.com,https://attack.com/api', + priority: '2 - High', + }); + }); + + it('it handles duplicates correctly', async () => { + const alerts = [ + { + id: 'alert-1', + index: 'index-1', + destination: { ip: '192.168.1.1' }, + source: { ip: '192.168.1.2' }, + file: { + hash: { sha256: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08' }, + }, + url: { full: 'https://attack.com' }, + }, + { + id: 'alert-2', + index: 'index-2', + destination: { ip: '192.168.1.1' }, + source: { ip: '192.168.1.3' }, + file: { + hash: { sha256: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08' }, + }, + url: { full: 'https://attack.com/api' }, + }, + ]; + const res = await serviceNowSIRExternalServiceFormatter.format(theCase, alerts); + expect(res).toEqual({ + dest_ip: '192.168.1.1', + source_ip: '192.168.1.2,192.168.1.3', + category: 'Denial of Service', + subcategory: 'Inbound DDos', + malware_hash: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08', + malware_url: 'https://attack.com,https://attack.com/api', + priority: '2 - High', + }); + }); + + it('it formats correctly when field is not selected', async () => { + const alerts = [ + { + id: 'alert-1', + index: 'index-1', + destination: { ip: '192.168.1.1' }, + source: { ip: '192.168.1.2' }, + file: { + hash: { sha256: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08' }, + }, + url: { full: 'https://attack.com' }, + }, + { + id: 'alert-2', + index: 'index-2', + destination: { ip: '192.168.1.1' }, + source: { ip: '192.168.1.3' }, + file: { + hash: { sha256: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08' }, + }, + url: { full: 'https://attack.com/api' }, + }, + ]; + + const newCase = { + ...theCase, + connector: { fields: { ...theCase.connector.fields, destIp: false, malwareHash: false } }, + } as CaseResponse; + + const res = await serviceNowSIRExternalServiceFormatter.format(newCase, alerts); + expect(res).toEqual({ + dest_ip: null, + source_ip: '192.168.1.2,192.168.1.3', + category: 'Denial of Service', + subcategory: 'Inbound DDos', + malware_hash: null, + malware_url: 'https://attack.com,https://attack.com/api', + priority: '2 - High', + }); + }); +}); diff --git a/x-pack/plugins/case/server/connectors/servicenow/sir_formatter.ts b/x-pack/plugins/case/server/connectors/servicenow/sir_formatter.ts new file mode 100644 index 0000000000000..d2458e6c7ae53 --- /dev/null +++ b/x-pack/plugins/case/server/connectors/servicenow/sir_formatter.ts @@ -0,0 +1,88 @@ +/* + * 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 { get } from 'lodash/fp'; +import { ConnectorServiceNowSIRTypeFields } from '../../../common/api'; +import { ExternalServiceFormatter } from '../types'; +interface ExternalServiceParams { + dest_ip: string | null; + source_ip: string | null; + category: string | null; + subcategory: string | null; + malware_hash: string | null; + malware_url: string | null; + priority: string | null; +} +type SirFieldKey = 'dest_ip' | 'source_ip' | 'malware_hash' | 'malware_url'; +type AlertFieldMappingAndValues = Record< + string, + { alertPath: string; sirFieldKey: SirFieldKey; add: boolean } +>; +const format: ExternalServiceFormatter['format'] = (theCase, alerts) => { + const { + destIp = null, + sourceIp = null, + category = null, + subcategory = null, + malwareHash = null, + malwareUrl = null, + priority = null, + } = (theCase.connector.fields as ConnectorServiceNowSIRTypeFields['fields']) ?? {}; + const alertFieldMapping: AlertFieldMappingAndValues = { + destIp: { alertPath: 'destination.ip', sirFieldKey: 'dest_ip', add: !!destIp }, + sourceIp: { alertPath: 'source.ip', sirFieldKey: 'source_ip', add: !!sourceIp }, + malwareHash: { alertPath: 'file.hash.sha256', sirFieldKey: 'malware_hash', add: !!malwareHash }, + malwareUrl: { alertPath: 'url.full', sirFieldKey: 'malware_url', add: !!malwareUrl }, + }; + + const manageDuplicate: Record> = { + dest_ip: new Set(), + source_ip: new Set(), + malware_hash: new Set(), + malware_url: new Set(), + }; + + let sirFields: Record = { + dest_ip: null, + source_ip: null, + malware_hash: null, + malware_url: null, + }; + + const fieldsToAdd = (Object.keys(alertFieldMapping) as SirFieldKey[]).filter( + (key) => alertFieldMapping[key].add + ); + + if (fieldsToAdd.length > 0) { + sirFields = alerts.reduce>((acc, alert) => { + fieldsToAdd.forEach((alertField) => { + const field = get(alertFieldMapping[alertField].alertPath, alert); + if (field && !manageDuplicate[alertFieldMapping[alertField].sirFieldKey].has(field)) { + manageDuplicate[alertFieldMapping[alertField].sirFieldKey].add(field); + acc = { + ...acc, + [alertFieldMapping[alertField].sirFieldKey]: `${ + acc[alertFieldMapping[alertField].sirFieldKey] != null + ? `${acc[alertFieldMapping[alertField].sirFieldKey]},${field}` + : field + }`, + }; + } + }); + return acc; + }, sirFields); + } + + return { + ...sirFields, + category, + subcategory, + priority, + }; +}; +export const serviceNowSIRExternalServiceFormatter: ExternalServiceFormatter = { + format, +}; diff --git a/x-pack/plugins/case/server/connectors/types.ts b/x-pack/plugins/case/server/connectors/types.ts new file mode 100644 index 0000000000000..8e7eb91ad2dc6 --- /dev/null +++ b/x-pack/plugins/case/server/connectors/types.ts @@ -0,0 +1,54 @@ +/* + * 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 { Logger } from 'kibana/server'; +import { + ActionTypeConfig, + ActionTypeSecrets, + ActionTypeParams, + ActionType, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../actions/server/types'; +import { CaseResponse, ConnectorTypes } from '../../common/api'; +import { CaseClientGetAlertsResponse } from '../client/alerts/types'; +import { + CaseServiceSetup, + CaseConfigureServiceSetup, + CaseUserActionServiceSetup, + ConnectorMappingsServiceSetup, + AlertServiceContract, +} from '../services'; + +export interface GetActionTypeParams { + logger: Logger; + caseService: CaseServiceSetup; + caseConfigureService: CaseConfigureServiceSetup; + connectorMappingsService: ConnectorMappingsServiceSetup; + userActionService: CaseUserActionServiceSetup; + alertsService: AlertServiceContract; +} + +export interface RegisterConnectorsArgs extends GetActionTypeParams { + actionsRegisterType< + Config extends ActionTypeConfig = ActionTypeConfig, + Secrets extends ActionTypeSecrets = ActionTypeSecrets, + Params extends ActionTypeParams = ActionTypeParams, + ExecutorResultData = void + >( + actionType: ActionType + ): void; +} + +export type FormatterConnectorTypes = Exclude; + +export interface ExternalServiceFormatter { + format: (theCase: CaseResponse, alerts: CaseClientGetAlertsResponse) => TExternalServiceParams; +} + +export type ExternalServiceFormatterMapper = { + [x in FormatterConnectorTypes]: ExternalServiceFormatter; +}; diff --git a/x-pack/plugins/case/server/plugin.ts b/x-pack/plugins/case/server/plugin.ts index 8b4fdc73dab44..5d05db165f637 100644 --- a/x-pack/plugins/case/server/plugin.ts +++ b/x-pack/plugins/case/server/plugin.ts @@ -5,7 +5,13 @@ * 2.0. */ -import { IContextProvider, KibanaRequest, Logger, PluginInitializerContext } from 'kibana/server'; +import { + IContextProvider, + KibanaRequest, + KibanaResponseFactory, + Logger, + PluginInitializerContext, +} from 'kibana/server'; import { CoreSetup, CoreStart } from 'src/core/server'; import { SecurityPluginSetup } from '../../security/server'; @@ -123,11 +129,13 @@ export class CasePlugin { const getCaseClientWithRequestAndContext = async ( context: CasesRequestHandlerContext, - request: KibanaRequest + request: KibanaRequest, + response: KibanaResponseFactory ) => { return createCaseClient({ savedObjectsClient: core.savedObjects.getScopedClient(request), request, + response, caseService: this.caseService!, caseConfigureService: this.caseConfigureService!, connectorMappingsService: this.connectorMappingsService!, @@ -161,7 +169,7 @@ export class CasePlugin { userActionService: CaseUserActionServiceSetup; alertsService: AlertServiceContract; }): IContextProvider => { - return async (context, request) => { + return async (context, request, response) => { const [{ savedObjects }] = await core.getStartServices(); return { getCaseClient: () => { @@ -172,8 +180,9 @@ export class CasePlugin { connectorMappingsService, userActionService, alertsService, - request, context, + request, + response, }); }, }; diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts index 8dc970d235fea..18730effdf55a 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/create_mock_so_repository.ts @@ -17,6 +17,7 @@ import { CASE_SAVED_OBJECT, CASE_CONFIGURE_SAVED_OBJECT, CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT, + CASE_USER_ACTION_SAVED_OBJECT, } from '../../../saved_object_types'; export const createMockSavedObjectsRepository = ({ @@ -24,11 +25,13 @@ export const createMockSavedObjectsRepository = ({ caseCommentSavedObject = [], caseConfigureSavedObject = [], caseMappingsSavedObject = [], + caseUserActionsSavedObject = [], }: { caseSavedObject?: any[]; caseCommentSavedObject?: any[]; caseConfigureSavedObject?: any[]; caseMappingsSavedObject?: any[]; + caseUserActionsSavedObject?: any[]; } = {}) => { const mockSavedObjectsClientContract = ({ bulkGet: jest.fn((objects: SavedObjectsBulkGetObject[]) => { @@ -57,6 +60,7 @@ export const createMockSavedObjectsRepository = ({ }), }; }), + bulkCreate: jest.fn(), bulkUpdate: jest.fn((objects: Array>) => { return { saved_objects: objects.map(({ id, type, attributes }) => { @@ -136,6 +140,16 @@ export const createMockSavedObjectsRepository = ({ saved_objects: caseCommentSavedObject, }; } + + if (findArgs.type === CASE_USER_ACTION_SAVED_OBJECT) { + return { + page: 1, + per_page: 5, + total: caseUserActionsSavedObject.length, + saved_objects: caseUserActionsSavedObject, + }; + } + return { page: 1, per_page: 5, diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/index.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/index.ts index 5e2c29f29a3e7..1abd44aec1552 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/index.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/index.ts @@ -10,3 +10,4 @@ export { createMockSavedObjectsRepository } from './create_mock_so_repository'; export { createRouteContext } from './route_contexts'; export { authenticationMock } from './authc_mock'; export { createRoute } from './mock_router'; +export { createActionsClient } from './mock_actions_client'; diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_actions_client.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_actions_client.ts new file mode 100644 index 0000000000000..d153c328cbb91 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_actions_client.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 { SavedObjectsErrorHelpers } from 'src/core/server'; +import { actionsClientMock } from '../../../../../actions/server/mocks'; +import { + getActions, + getActionTypes, + getActionExecuteResults, +} from '../__mocks__/request_responses'; + +export const createActionsClient = () => { + const actionsMock = actionsClientMock.create(); + actionsMock.getAll.mockImplementation(() => Promise.resolve(getActions())); + actionsMock.listTypes.mockImplementation(() => Promise.resolve(getActionTypes())); + actionsMock.get.mockImplementation(({ id }) => { + const actions = getActions(); + const action = actions.find((a) => a.id === id); + if (action) { + return Promise.resolve(action); + } else { + return Promise.reject(SavedObjectsErrorHelpers.createGenericNotFoundError('action', id)); + } + }); + actionsMock.execute.mockImplementation(({ actionId }) => + Promise.resolve(getActionExecuteResults(actionId)) + ); + + return actionsMock; +}; diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts index 4ac5004eb3dfd..514f77a8f953d 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts @@ -8,6 +8,7 @@ import { SavedObject } from 'kibana/server'; import { CaseStatuses, + CaseUserActionAttributes, CommentAttributes, CommentType, ConnectorMappings, @@ -15,7 +16,10 @@ import { ESCaseAttributes, ESCasesConfigureAttributes, } from '../../../../common/api'; -import { CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT } from '../../../saved_object_types'; +import { + CASE_CONNECTOR_MAPPINGS_SAVED_OBJECT, + CASE_USER_ACTION_SAVED_OBJECT, +} from '../../../saved_object_types'; import { mappings } from '../../../client/configure/mock'; export const mockCases: Array> = [ @@ -424,3 +428,44 @@ export const mockCaseMappings: Array> = [ references: [], }, ]; + +export const mockUserActions: Array> = [ + { + type: CASE_USER_ACTION_SAVED_OBJECT, + id: 'mock-user-actions-1', + attributes: { + action_field: ['description', 'status', 'tags', 'title', 'connector', 'settings'], + action: 'create', + action_at: '2021-02-03T17:41:03.771Z', + action_by: { + email: 'elastic@elastic.co', + full_name: 'Elastic', + username: 'elastic', + }, + new_value: + '{"title":"A case","tags":["case"],"description":"Yeah!","connector":{"id":"connector-od","name":"My Connector","type":".servicenow-sir","fields":{"category":"Denial of Service","destIp":true,"malwareHash":true,"malwareUrl":true,"priority":"2","sourceIp":true,"subcategory":"45"}},"settings":{"syncAlerts":true}}', + old_value: null, + }, + version: 'WzYsMV0=', + references: [], + }, + { + type: CASE_USER_ACTION_SAVED_OBJECT, + id: 'mock-user-actions-2', + attributes: { + action_field: ['comment'], + action: 'create', + action_at: '2021-02-03T17:44:21.067Z', + action_by: { + email: 'elastic@elastic.co', + full_name: 'Elastic', + username: 'elastic', + }, + new_value: + '{"type":"alert","alertId":"cec3da90fb37a44407145adf1593f3b0d5ad94c4654201f773d63b5d4706128e","index":".siem-signals-default-000008"}', + old_value: null, + }, + version: 'WzYsMV0=', + references: [], + }, +]; diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts index 9f7258fc7edaf..74665ffdc5b16 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts @@ -5,24 +5,25 @@ * 2.0. */ -import { KibanaRequest } from 'src/core/server'; -import { loggingSystemMock, elasticsearchServiceMock } from 'src/core/server/mocks'; -import { actionsClientMock } from '../../../../../actions/server/mocks'; +import { KibanaRequest, kibanaResponseFactory } from '../../../../../../../src/core/server'; +import { + loggingSystemMock, + elasticsearchServiceMock, +} from '../../../../../../../src/core/server/mocks'; import { createCaseClient } from '../../../client'; import { AlertService, CaseService, CaseConfigureService, ConnectorMappingsService, + CaseUserActionService, } from '../../../services'; -import { getActions, getActionTypes } from '../__mocks__/request_responses'; import { authenticationMock } from '../__fixtures__'; import type { CasesRequestHandlerContext } from '../../../types'; +import { createActionsClient } from './mock_actions_client'; export const createRouteContext = async (client: any, badAuth = false) => { - const actionsMock = actionsClientMock.create(); - actionsMock.getAll.mockImplementation(() => Promise.resolve(getActions())); - actionsMock.listTypes.mockImplementation(() => Promise.resolve(getActionTypes())); + const actionsMock = createActionsClient(); const log = loggingSystemMock.create().get('case'); const esClientMock = elasticsearchServiceMock.createClusterClient(); @@ -30,11 +31,13 @@ export const createRouteContext = async (client: any, badAuth = false) => { const caseServicePlugin = new CaseService(log); const caseConfigureServicePlugin = new CaseConfigureService(log); const connectorMappingsServicePlugin = new ConnectorMappingsService(log); + const caseUserActionsServicePlugin = new CaseUserActionService(log); const caseService = await caseServicePlugin.setup({ authentication: badAuth ? authenticationMock.createInvalid() : authenticationMock.create(), }); const caseConfigureService = await caseConfigureServicePlugin.setup(); + const userActionService = await caseUserActionsServicePlugin.setup(); const alertsService = new AlertService(); alertsService.initialize(esClientMock); @@ -59,16 +62,14 @@ export const createRouteContext = async (client: any, badAuth = false) => { const caseClient = createCaseClient({ savedObjectsClient: client, request: {} as KibanaRequest, + response: kibanaResponseFactory, caseService, caseConfigureService, connectorMappingsService, - userActionService: { - postUserActions: jest.fn(), - getUserActions: jest.fn(), - }, + userActionService, alertsService, context, }); - return context; + return { context, services: { userActionService } }; }; diff --git a/x-pack/plugins/case/server/routes/api/__mocks__/request_responses.ts b/x-pack/plugins/case/server/routes/api/__mocks__/request_responses.ts index f2109167527c7..ae14b44e7dffe 100644 --- a/x-pack/plugins/case/server/routes/api/__mocks__/request_responses.ts +++ b/x-pack/plugins/case/server/routes/api/__mocks__/request_responses.ts @@ -10,11 +10,9 @@ import { CasePostRequest, CasesConfigureRequest, ConnectorTypes, - PostPushRequest, } from '../../../../common/api'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { FindActionResult } from '../../../../../actions/server/types'; -import { params } from '../cases/configure/mock'; export const newCase: CasePostRequest = { title: 'My new case', @@ -74,6 +72,16 @@ export const getActions = (): FindActionResult[] => [ isPreconfigured: false, referencedByCount: 0, }, + { + id: 'for-mock-case-id-3', + actionTypeId: '.jira', + name: 'For mock case id 3', + config: { + apiUrl: 'https://elastic.jira.com', + }, + isPreconfigured: false, + referencedByCount: 0, + }, ]; export const getActionTypes = (): ActionTypeConnector[] => [ @@ -119,6 +127,18 @@ export const getActionTypes = (): ActionTypeConnector[] => [ }, ]; +export const getActionExecuteResults = (actionId = '123') => ({ + status: 'ok' as const, + data: { + title: 'RJ2-200', + id: '10663', + pushedDate: '2020-12-17T00:32:40.738Z', + url: 'https://siem-kibana.atlassian.net/browse/RJ2-200', + comments: [], + }, + actionId, +}); + export const newConfiguration: CasesConfigureRequest = { connector: { id: '456', @@ -129,11 +149,6 @@ export const newConfiguration: CasesConfigureRequest = { closure_type: 'close-by-pushing', }; -export const newPostPushRequest: PostPushRequest = { - params: params[ConnectorTypes.jira], - connector_type: ConnectorTypes.jira, -}; - export const executePushResponse = { status: 'ok', data: { diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.test.ts b/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.test.ts index 9454f582e50c6..dcbcd7b9e246d 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.test.ts @@ -33,14 +33,14 @@ describe('DELETE comment', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseCommentSavedObject: mockCaseComments, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(204); }); it(`returns an error when thrown from deleteComment service`, async () => { @@ -53,14 +53,14 @@ describe('DELETE comment', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseCommentSavedObject: mockCaseComments, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(404); }); }); diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/get_comment.test.ts b/x-pack/plugins/case/server/routes/api/cases/comments/get_comment.test.ts index a1f4b8c2583cf..8ee43eaba8a82 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/get_comment.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/get_comment.test.ts @@ -34,14 +34,14 @@ describe('GET comment', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseCommentSavedObject: mockCaseComments, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); const myPayload = mockCaseComments.find((s) => s.id === 'mock-comment-1'); expect(myPayload).not.toBeUndefined(); @@ -59,13 +59,13 @@ describe('GET comment', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseCommentSavedObject: mockCaseComments, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(404); }); }); diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.test.ts b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.test.ts index 3bd8a688e1bba..33dc24d776c70 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.test.ts @@ -41,14 +41,14 @@ describe('PATCH comment', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseCommentSavedObject: mockCaseComments, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); expect(response.payload.comments[response.payload.comments.length - 1].comment).toEqual( 'Update my comment' @@ -71,14 +71,14 @@ describe('PATCH comment', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseCommentSavedObject: mockCaseComments, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); expect(response.payload.comments[response.payload.comments.length - 1].alertId).toEqual( 'new-id' @@ -102,14 +102,14 @@ describe('PATCH comment', () => { body: requestAttributes, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseCommentSavedObject: mockCaseComments, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(400); expect(response.payload.isBoom).toEqual(true); } @@ -130,14 +130,14 @@ describe('PATCH comment', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseCommentSavedObject: mockCaseComments, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(400); expect(response.payload.isBoom).toEqual(true); } @@ -161,14 +161,14 @@ describe('PATCH comment', () => { body: requestAttributes, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseCommentSavedObject: mockCaseComments, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(400); expect(response.payload.isBoom).toEqual(true); } @@ -190,14 +190,14 @@ describe('PATCH comment', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseCommentSavedObject: mockCaseComments, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(400); expect(response.payload.isBoom).toEqual(true); } @@ -219,14 +219,14 @@ describe('PATCH comment', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseCommentSavedObject: mockCaseComments, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(400); expect(response.payload.isBoom).toEqual(true); expect(response.payload.message).toEqual('You cannot change the type of the comment.'); @@ -247,14 +247,14 @@ describe('PATCH comment', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseCommentSavedObject: mockCaseComments, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(409); }); @@ -273,14 +273,14 @@ describe('PATCH comment', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseCommentSavedObject: mockCaseComments, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(404); expect(response.payload.isBoom).toEqual(true); }); diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.test.ts b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.test.ts index 54699415cd984..0ab038a62ac77 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.test.ts @@ -43,14 +43,14 @@ describe('POST comment', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseCommentSavedObject: mockCaseComments, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); expect(response.payload.comments[response.payload.comments.length - 1].id).toEqual( 'mock-comment' @@ -71,14 +71,14 @@ describe('POST comment', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseCommentSavedObject: mockCaseComments, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); expect(response.payload.comments[response.payload.comments.length - 1].id).toEqual( 'mock-comment' @@ -95,14 +95,14 @@ describe('POST comment', () => { body: {}, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseCommentSavedObject: mockCaseComments, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(400); expect(response.payload.isBoom).toEqual(true); }); @@ -124,14 +124,14 @@ describe('POST comment', () => { body: requestAttributes, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseCommentSavedObject: mockCaseComments, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(400); expect(response.payload.isBoom).toEqual(true); } @@ -152,14 +152,14 @@ describe('POST comment', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseCommentSavedObject: mockCaseComments, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(400); expect(response.payload.isBoom).toEqual(true); } @@ -183,14 +183,14 @@ describe('POST comment', () => { body: requestAttributes, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseCommentSavedObject: mockCaseComments, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(400); expect(response.payload.isBoom).toEqual(true); } @@ -212,14 +212,14 @@ describe('POST comment', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseCommentSavedObject: mockCaseComments, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(400); expect(response.payload.isBoom).toEqual(true); } @@ -238,14 +238,14 @@ describe('POST comment', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseCommentSavedObject: mockCaseComments, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(404); expect(response.payload.isBoom).toEqual(true); }); @@ -262,14 +262,14 @@ describe('POST comment', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseCommentSavedObject: mockCaseComments, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(400); expect(response.payload.isBoom).toEqual(true); }); @@ -289,7 +289,7 @@ describe('POST comment', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseCommentSavedObject: mockCaseComments, @@ -297,7 +297,7 @@ describe('POST comment', () => { true ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); expect(response.payload.comments[response.payload.comments.length - 1]).toEqual({ comment: 'Wow, good luck catching that bad meanie!', diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/get_configure.test.ts b/x-pack/plugins/case/server/routes/api/cases/configure/get_configure.test.ts index ddcbb3522f986..ff4216a05ae58 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/get_configure.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/get_configure.test.ts @@ -34,7 +34,7 @@ describe('GET configuration', () => { method: 'get', }); - const context = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, caseMappingsSavedObject: mockCaseMappings, @@ -57,7 +57,7 @@ describe('GET configuration', () => { method: 'get', }); - const context = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: [{ ...mockCaseConfigure[0], version: undefined }], caseMappingsSavedObject: mockCaseMappings, @@ -98,7 +98,7 @@ describe('GET configuration', () => { method: 'get', }); - const context = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: [], }) @@ -116,7 +116,7 @@ describe('GET configuration', () => { method: 'get', }); - const context = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: [{ ...mockCaseConfigure[0], id: 'throw-error-find' }], }) @@ -133,7 +133,7 @@ describe('GET configuration', () => { method: 'get', }); - const context = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, caseMappingsSavedObject: [], diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/get_configure.ts b/x-pack/plugins/case/server/routes/api/cases/configure/get_configure.ts index 0f74b7291dd81..17972e129a825 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/get_configure.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/get_configure.ts @@ -33,9 +33,9 @@ export function initGetCaseConfigure({ caseConfigureService, router }: RouteDeps throw Boom.badRequest('RouteHandlerContext is not registered for cases'); } const caseClient = context.case.getCaseClient(); - const actionsClient = await context.actions?.getActionsClient(); + const actionsClient = context.actions?.getActionsClient(); if (actionsClient == null) { - throw Boom.notFound('Action client have not been found'); + throw Boom.notFound('Action client not found'); } try { mappings = await caseClient.getMappings({ diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.test.ts b/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.test.ts index 1e37918d7766a..3fa0fe2f83f79 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.test.ts @@ -32,7 +32,7 @@ describe('GET connectors', () => { method: 'get', }); - const context = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, caseMappingsSavedObject: mockCaseMappings, @@ -54,7 +54,7 @@ describe('GET connectors', () => { method: 'get', }); - const context = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, caseMappingsSavedObject: mockCaseMappings, @@ -106,6 +106,16 @@ describe('GET connectors', () => { isPreconfigured: false, referencedByCount: 0, }, + { + id: 'for-mock-case-id-3', + actionTypeId: '.jira', + name: 'For mock case id 3', + config: { + apiUrl: 'https://elastic.jira.com', + }, + isPreconfigured: false, + referencedByCount: 0, + }, ]); }); @@ -115,7 +125,7 @@ describe('GET connectors', () => { method: 'get', }); - const context = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, caseMappingsSavedObject: mockCaseMappings, diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.ts b/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.ts index fb0595f858d4e..0a368e0276bb5 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/get_connectors.ts @@ -14,18 +14,15 @@ import { FindActionResult } from '../../../../../../actions/server/types'; import { CASE_CONFIGURE_CONNECTORS_URL, - SERVICENOW_ACTION_TYPE_ID, - JIRA_ACTION_TYPE_ID, - RESILIENT_ACTION_TYPE_ID, + SUPPORTED_CONNECTORS, } from '../../../../../common/constants'; const isConnectorSupported = ( action: FindActionResult, actionTypes: Record ): boolean => - [SERVICENOW_ACTION_TYPE_ID, JIRA_ACTION_TYPE_ID, RESILIENT_ACTION_TYPE_ID].includes( - action.actionTypeId - ) && actionTypes[action.actionTypeId]?.enabledInLicense; + SUPPORTED_CONNECTORS.includes(action.actionTypeId) && + actionTypes[action.actionTypeId]?.enabledInLicense; /* * Be aware that this api will only return 20 connectors @@ -39,10 +36,10 @@ export function initCaseConfigureGetActionConnector({ router }: RouteDeps) { }, async (context, request, response) => { try { - const actionsClient = await context.actions?.getActionsClient(); + const actionsClient = context.actions?.getActionsClient(); if (actionsClient == null) { - throw Boom.notFound('Action client have not been found'); + throw Boom.notFound('Action client not found'); } const actionTypes = (await actionsClient.listTypes()).reduce( diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/mock.ts b/x-pack/plugins/case/server/routes/api/cases/configure/mock.ts deleted file mode 100644 index 9959a3e4acee6..0000000000000 --- a/x-pack/plugins/case/server/routes/api/cases/configure/mock.ts +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { - ServiceConnectorCaseParams, - ServiceConnectorCommentParams, - ConnectorMappingsAttributes, - ConnectorTypes, -} from '../../../../../common/api/connectors'; -export const updateUser = { - updatedAt: '2020-03-13T08:34:53.450Z', - updatedBy: { fullName: 'Another User', username: 'another' }, -}; -const entity = { - createdAt: '2020-03-13T08:34:53.450Z', - createdBy: { fullName: 'Elastic User', username: 'elastic' }, - updatedAt: null, - updatedBy: null, -}; -export const comment: ServiceConnectorCommentParams = { - comment: 'first comment', - commentId: 'b5b4c4d0-574e-11ea-9e2e-21b90f8a9631', - ...entity, -}; -export const defaultPipes = ['informationCreated']; -const basicParams = { - comments: [comment], - description: 'a description', - title: 'a title', - savedObjectId: '1231231231232', - externalId: null, -}; -export const params = { - [ConnectorTypes.jira]: { - ...basicParams, - issueType: '10003', - priority: 'Highest', - parent: '5002', - ...entity, - } as ServiceConnectorCaseParams, - [ConnectorTypes.resilient]: { - ...basicParams, - incidentTypes: ['10003'], - severityCode: '1', - ...entity, - } as ServiceConnectorCaseParams, - [ConnectorTypes.servicenow]: { - ...basicParams, - impact: '3', - severity: '1', - urgency: '2', - ...entity, - } as ServiceConnectorCaseParams, - [ConnectorTypes.none]: {}, -}; -export const mappings: ConnectorMappingsAttributes[] = [ - { - source: 'title', - target: 'short_description', - action_type: 'overwrite', - }, - { - source: 'description', - target: 'description', - action_type: 'append', - }, - { - source: 'comments', - target: 'comments', - action_type: 'append', - }, -]; diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.test.ts b/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.test.ts index c67a1c064a82f..f43f561e30e10 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.test.ts @@ -42,7 +42,7 @@ describe('PATCH configuration', () => { }, }); - const context = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, caseMappingsSavedObject: mockCaseMappings, @@ -76,7 +76,7 @@ describe('PATCH configuration', () => { }, }); - const context = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, caseMappingsSavedObject: mockCaseMappings, @@ -115,7 +115,7 @@ describe('PATCH configuration', () => { }, }); - const context = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, caseMappingsSavedObject: mockCaseMappings, @@ -153,7 +153,7 @@ describe('PATCH configuration', () => { }, }); - const context = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, caseMappingsSavedObject: [], @@ -193,7 +193,7 @@ describe('PATCH configuration', () => { }, }); - const context = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: [], }) @@ -215,7 +215,7 @@ describe('PATCH configuration', () => { }, }); - const context = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, caseMappingsSavedObject: mockCaseMappings, @@ -243,7 +243,7 @@ describe('PATCH configuration', () => { }, }); - const context = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, caseMappingsSavedObject: mockCaseMappings, diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts b/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts index f847c4f776bf0..6925f116136b3 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts @@ -66,7 +66,7 @@ export function initPatchCaseConfigure({ caseConfigureService, caseService, rout throw Boom.badRequest('RouteHandlerContext is not registered for cases'); } const caseClient = context.case.getCaseClient(); - const actionsClient = await context.actions?.getActionsClient(); + const actionsClient = context.actions?.getActionsClient(); if (actionsClient == null) { throw Boom.notFound('Action client have not been found'); } diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.test.ts b/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.test.ts index 0a7f3ef488fce..7dcb7d1fa12ca 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.test.ts @@ -40,7 +40,7 @@ describe('POST configuration', () => { body: newConfiguration, }); - const context = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, caseMappingsSavedObject: mockCaseMappings, @@ -73,7 +73,7 @@ describe('POST configuration', () => { body: newConfiguration, }); - const context = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, caseMappingsSavedObject: [], @@ -113,7 +113,7 @@ describe('POST configuration', () => { body: newConfiguration, }); - const context = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, caseMappingsSavedObject: mockCaseMappings, @@ -154,7 +154,7 @@ describe('POST configuration', () => { }, }); - const context = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, caseMappingsSavedObject: mockCaseMappings, @@ -180,7 +180,7 @@ describe('POST configuration', () => { }, }); - const context = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, caseMappingsSavedObject: mockCaseMappings, @@ -206,7 +206,7 @@ describe('POST configuration', () => { }, }); - const context = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, caseMappingsSavedObject: mockCaseMappings, @@ -232,7 +232,7 @@ describe('POST configuration', () => { }, }); - const context = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, caseMappingsSavedObject: mockCaseMappings, @@ -258,7 +258,7 @@ describe('POST configuration', () => { }, }); - const context = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, caseMappingsSavedObject: mockCaseMappings, @@ -282,7 +282,7 @@ describe('POST configuration', () => { caseMappingsSavedObject: mockCaseMappings, }); - const context = await createRouteContext(savedObjectRepository); + const { context } = await createRouteContext(savedObjectRepository); const res = await routeHandler(context, req, kibanaResponseFactory); @@ -302,7 +302,7 @@ describe('POST configuration', () => { caseMappingsSavedObject: mockCaseMappings, }); - const context = await createRouteContext(savedObjectRepository); + const { context } = await createRouteContext(savedObjectRepository); const res = await routeHandler(context, req, kibanaResponseFactory); @@ -325,7 +325,7 @@ describe('POST configuration', () => { caseMappingsSavedObject: mockCaseMappings, }); - const context = await createRouteContext(savedObjectRepository); + const { context } = await createRouteContext(savedObjectRepository); const res = await routeHandler(context, req, kibanaResponseFactory); @@ -341,7 +341,7 @@ describe('POST configuration', () => { body: newConfiguration, }); - const context = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: [{ ...mockCaseConfigure[0], id: 'throw-error-find' }], }) @@ -359,7 +359,7 @@ describe('POST configuration', () => { body: newConfiguration, }); - const context = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: [{ ...mockCaseConfigure[0], id: 'throw-error-delete' }], }) @@ -384,7 +384,7 @@ describe('POST configuration', () => { }, }); - const context = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, caseMappingsSavedObject: mockCaseMappings, @@ -411,7 +411,7 @@ describe('POST configuration', () => { }, }); - const context = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, caseMappingsSavedObject: mockCaseMappings, @@ -437,7 +437,7 @@ describe('POST configuration', () => { }, }); - const context = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, caseMappingsSavedObject: mockCaseMappings, @@ -459,7 +459,7 @@ describe('POST configuration', () => { }, }); - const context = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseConfigureSavedObject: mockCaseConfigure, caseMappingsSavedObject: mockCaseMappings, diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts b/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts index 8e5fd95facc3d..0bcf2ac18740f 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts @@ -39,9 +39,9 @@ export function initPostCaseConfigure({ caseConfigureService, caseService, route throw Boom.badRequest('RouteHandlerContext is not registered for cases'); } const caseClient = context.case.getCaseClient(); - const actionsClient = await context.actions?.getActionsClient(); + const actionsClient = context.actions?.getActionsClient(); if (actionsClient == null) { - throw Boom.notFound('Action client have not been found'); + throw Boom.notFound('Action client not found'); } const client = context.core.savedObjects.client; const query = pipe( diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/post_push_to_service.test.ts b/x-pack/plugins/case/server/routes/api/cases/configure/post_push_to_service.test.ts deleted file mode 100644 index e382813dbf0c5..0000000000000 --- a/x-pack/plugins/case/server/routes/api/cases/configure/post_push_to_service.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { kibanaResponseFactory, RequestHandler } from 'src/core/server'; -import { httpServerMock } from 'src/core/server/mocks'; - -import { - createMockSavedObjectsRepository, - createRoute, - createRouteContext, - mockCaseMappings, -} from '../../__fixtures__'; - -import { initPostPushToService } from './post_push_to_service'; -import { executePushResponse, newPostPushRequest } from '../../__mocks__/request_responses'; -import { CASE_CONFIGURE_PUSH_URL } from '../../../../../common/constants'; -import type { CasesRequestHandlerContext } from '../../../../types'; - -describe('Post push to service', () => { - let routeHandler: RequestHandler; - const req = httpServerMock.createKibanaRequest({ - path: `${CASE_CONFIGURE_PUSH_URL}`, - method: 'post', - params: { - connector_id: '666', - }, - body: newPostPushRequest, - }); - let context: CasesRequestHandlerContext; - beforeAll(async () => { - routeHandler = await createRoute(initPostPushToService, 'post'); - const spyOnDate = jest.spyOn(global, 'Date') as jest.SpyInstance<{}, []>; - spyOnDate.mockImplementation(() => ({ - toISOString: jest.fn().mockReturnValue('2020-04-09T09:43:51.778Z'), - })); - context = await createRouteContext( - createMockSavedObjectsRepository({ - caseMappingsSavedObject: mockCaseMappings, - }) - ); - }); - - it('Happy path - posts success', async () => { - const betterContext = ({ - ...context, - actions: { - ...context.actions, - getActionsClient: () => { - const actions = context!.actions!.getActionsClient(); - return { - ...actions, - execute: jest.fn().mockImplementation(({ actionId }) => { - return { - status: 'ok', - data: { - title: 'RJ2-200', - id: '10663', - pushedDate: '2020-12-17T00:32:40.738Z', - url: 'https://siem-kibana.atlassian.net/browse/RJ2-200', - comments: [], - }, - actionId, - }; - }), - }; - }, - }, - } as unknown) as CasesRequestHandlerContext; - - const res = await routeHandler(betterContext, req, kibanaResponseFactory); - - expect(res.status).toEqual(200); - expect(res.payload).toEqual({ - ...executePushResponse, - actionId: '666', - }); - }); - it('Unhappy path - context case missing', async () => { - const betterContext = ({ - ...context, - case: null, - } as unknown) as CasesRequestHandlerContext; - - const res = await routeHandler(betterContext, req, kibanaResponseFactory); - expect(res.status).toEqual(400); - expect(res.payload.isBoom).toBeTruthy(); - expect(res.payload.output.payload.message).toEqual( - 'RouteHandlerContext is not registered for cases' - ); - }); - it('Unhappy path - context actions missing', async () => { - const betterContext = ({ - ...context, - actions: null, - } as unknown) as CasesRequestHandlerContext; - - const res = await routeHandler(betterContext, req, kibanaResponseFactory); - expect(res.status).toEqual(404); - expect(res.payload.isBoom).toBeTruthy(); - expect(res.payload.output.payload.message).toEqual('Action client have not been found'); - }); -}); diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/post_push_to_service.ts b/x-pack/plugins/case/server/routes/api/cases/configure/post_push_to_service.ts deleted file mode 100644 index b8ba1a9ccb6ef..0000000000000 --- a/x-pack/plugins/case/server/routes/api/cases/configure/post_push_to_service.ts +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { pipe } from 'fp-ts/lib/pipeable'; -import { fold } from 'fp-ts/lib/Either'; -import { identity } from 'fp-ts/lib/function'; -import Boom from '@hapi/boom'; -import { RouteDeps } from '../../types'; -import { escapeHatch, wrapError } from '../../utils'; - -import { CASE_CONFIGURE_PUSH_URL } from '../../../../../common/constants'; -import { - ConnectorRequestParamsRt, - PostPushRequestRt, - throwErrors, -} from '../../../../../common/api'; -import { mapIncident } from './utils'; - -export function initPostPushToService({ router }: RouteDeps) { - router.post( - { - path: CASE_CONFIGURE_PUSH_URL, - validate: { - params: escapeHatch, - body: escapeHatch, - }, - }, - async (context, request, response) => { - try { - if (!context.case) { - throw Boom.badRequest('RouteHandlerContext is not registered for cases'); - } - const caseClient = context.case.getCaseClient(); - const actionsClient = await context.actions?.getActionsClient(); - if (actionsClient == null) { - throw Boom.notFound('Action client have not been found'); - } - const params = pipe( - ConnectorRequestParamsRt.decode(request.params), - fold(throwErrors(Boom.badRequest), identity) - ); - const body = pipe( - PostPushRequestRt.decode(request.body), - fold(throwErrors(Boom.badRequest), identity) - ); - - const myConnectorMappings = await caseClient.getMappings({ - actionsClient, - caseClient, - connectorId: params.connector_id, - connectorType: body.connector_type, - }); - - const res = await mapIncident( - actionsClient, - params.connector_id, - body.connector_type, - myConnectorMappings, - body.params - ); - const pushRes = await actionsClient.execute({ - actionId: params.connector_id, - params: { - subAction: 'pushToService', - subActionParams: res, - }, - }); - - return response.ok({ - body: pushRes, - }); - } catch (error) { - return response.customError(wrapError(error)); - } - } - ); -} diff --git a/x-pack/plugins/case/server/routes/api/cases/delete_cases.test.ts b/x-pack/plugins/case/server/routes/api/cases/delete_cases.test.ts index 84e452ea8e871..d588950bec9aa 100644 --- a/x-pack/plugins/case/server/routes/api/cases/delete_cases.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/delete_cases.test.ts @@ -33,14 +33,14 @@ describe('DELETE case', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseCommentSavedObject: mockCaseComments, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(204); }); it(`returns an error when thrown from deleteCase service`, async () => { @@ -52,14 +52,14 @@ describe('DELETE case', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseCommentSavedObject: mockCaseComments, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(404); }); it(`returns an error when thrown from getAllCaseComments service`, async () => { @@ -71,14 +71,14 @@ describe('DELETE case', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCasesErrorTriggerData, caseCommentSavedObject: mockCaseComments, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(400); }); it(`returns an error when thrown from deleteComment service`, async () => { @@ -90,14 +90,14 @@ describe('DELETE case', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCasesErrorTriggerData, caseCommentSavedObject: mockCasesErrorTriggerData, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(400); }); }); diff --git a/x-pack/plugins/case/server/routes/api/cases/find_cases.test.ts b/x-pack/plugins/case/server/routes/api/cases/find_cases.test.ts index acd7de1e8643e..ca9f731ca5010 100644 --- a/x-pack/plugins/case/server/routes/api/cases/find_cases.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/find_cases.test.ts @@ -30,13 +30,13 @@ describe('FIND all cases', () => { method: 'get', }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); expect(response.payload.cases).toHaveLength(4); // mockSavedObjectsRepository do not support filters and returns all cases every time. @@ -51,13 +51,13 @@ describe('FIND all cases', () => { method: 'get', }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); expect(response.payload.cases[2].connector.id).toEqual('123'); }); @@ -68,13 +68,13 @@ describe('FIND all cases', () => { method: 'get', }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: [mockCaseNoConnectorId], }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); expect(response.payload.cases[0].connector.id).toEqual('none'); }); @@ -85,14 +85,14 @@ describe('FIND all cases', () => { method: 'get', }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: [mockCaseNoConnectorId], caseConfigureSavedObject: mockCaseConfigure, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); expect(response.payload.cases[0].connector.id).toEqual('none'); }); diff --git a/x-pack/plugins/case/server/routes/api/cases/get_case.test.ts b/x-pack/plugins/case/server/routes/api/cases/get_case.test.ts index 7aa6f110a0079..968dd0424fe3f 100644 --- a/x-pack/plugins/case/server/routes/api/cases/get_case.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/get_case.test.ts @@ -40,13 +40,13 @@ describe('GET case', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); const savedObject = (mockCases.find( (s) => s.id === 'mock-id-1' ) as unknown) as SavedObject; @@ -71,13 +71,13 @@ describe('GET case', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(404); expect(response.payload.isBoom).toEqual(true); @@ -95,14 +95,14 @@ describe('GET case', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseCommentSavedObject: mockCaseComments, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); expect(response.payload.comments).toHaveLength(5); @@ -120,13 +120,13 @@ describe('GET case', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCasesErrorTriggerData, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(400); }); @@ -143,13 +143,13 @@ describe('GET case', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: [mockCaseNoConnectorId], }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); expect(response.payload.connector).toEqual({ @@ -172,14 +172,14 @@ describe('GET case', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: [mockCaseNoConnectorId], caseConfigureSavedObject: mockCaseConfigure, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); expect(response.payload.connector).toEqual({ @@ -202,14 +202,14 @@ describe('GET case', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseConfigureSavedObject: mockCaseConfigure, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); expect(response.payload.connector).toEqual({ diff --git a/x-pack/plugins/case/server/routes/api/cases/get_case.ts b/x-pack/plugins/case/server/routes/api/cases/get_case.ts index f563fc274b18b..55377d93e528d 100644 --- a/x-pack/plugins/case/server/routes/api/cases/get_case.ts +++ b/x-pack/plugins/case/server/routes/api/cases/get_case.ts @@ -7,9 +7,8 @@ import { schema } from '@kbn/config-schema'; -import { CaseResponseRt } from '../../../../common/api'; import { RouteDeps } from '../types'; -import { flattenCaseSavedObject, wrapError } from '../utils'; +import { wrapError } from '../utils'; import { CASE_DETAILS_URL } from '../../../../common/constants'; export function initGetCaseApi({ caseConfigureService, caseService, router }: RouteDeps) { @@ -26,44 +25,17 @@ export function initGetCaseApi({ caseConfigureService, caseService, router }: Ro }, }, async (context, request, response) => { - try { - const client = context.core.savedObjects.client; - const includeComments = JSON.parse(request.query.includeComments); - - const [theCase] = await Promise.all([ - caseService.getCase({ - client, - caseId: request.params.case_id, - }), - ]); - - if (!includeComments) { - return response.ok({ - body: CaseResponseRt.encode( - flattenCaseSavedObject({ - savedObject: theCase, - }) - ), - }); - } + if (!context.case) { + return response.badRequest({ body: 'RouteHandlerContext is not registered for cases' }); + } - const theComments = await caseService.getAllCaseComments({ - client, - caseId: request.params.case_id, - options: { - sortField: 'created_at', - sortOrder: 'asc', - }, - }); + const caseClient = context.case.getCaseClient(); + const includeComments = JSON.parse(request.query.includeComments); + const id = request.params.case_id; + try { return response.ok({ - body: CaseResponseRt.encode( - flattenCaseSavedObject({ - savedObject: theCase, - comments: theComments.saved_objects, - totalComment: theComments.total, - }) - ), + body: await caseClient.get({ id, includeComments }), }); } catch (error) { return response.customError(wrapError(error)); diff --git a/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts b/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts index 95f7e5bb19a01..6d1134b15b65e 100644 --- a/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts @@ -44,13 +44,13 @@ describe('PATCH cases', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); expect(response.payload).toEqual([ { @@ -97,14 +97,14 @@ describe('PATCH cases', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseConfigureSavedObject: mockCaseConfigure, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); expect(response.payload).toEqual([ { @@ -151,13 +151,13 @@ describe('PATCH cases', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); expect(response.payload).toEqual([ { @@ -204,13 +204,13 @@ describe('PATCH cases', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: [mockCaseNoConnectorId], }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); expect(response.payload[0].connector.id).toEqual('none'); }); @@ -230,13 +230,13 @@ describe('PATCH cases', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); expect(response.payload[0].connector.id).toEqual('123'); }); @@ -261,13 +261,13 @@ describe('PATCH cases', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); expect(response.payload[0].connector).toEqual({ id: '456', @@ -292,13 +292,13 @@ describe('PATCH cases', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(409); }); @@ -317,14 +317,14 @@ describe('PATCH cases', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseCommentSavedObject: mockCaseComments, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(406); }); @@ -343,13 +343,13 @@ describe('PATCH cases', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(404); expect(response.payload.isBoom).toEqual(true); }); diff --git a/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts b/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts index 997516d2e30b6..292e2c6775a80 100644 --- a/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts @@ -49,13 +49,13 @@ describe('POST cases', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); expect(response.payload.id).toEqual('mock-it'); expect(response.payload.status).toEqual('open'); @@ -88,14 +88,14 @@ describe('POST cases', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseConfigureSavedObject: mockCaseConfigure, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); expect(response.payload.connector).toEqual({ id: '123', @@ -121,13 +121,13 @@ describe('POST cases', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(400); }); @@ -146,13 +146,13 @@ describe('POST cases', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(400); expect(response.payload.isBoom).toEqual(true); }); @@ -179,7 +179,7 @@ describe('POST cases', () => { }, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, caseConfigureSavedObject: mockCaseConfigure, @@ -187,7 +187,7 @@ describe('POST cases', () => { true ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); expect(response.payload).toEqual({ closed_at: null, diff --git a/x-pack/plugins/case/server/routes/api/cases/push_case.test.ts b/x-pack/plugins/case/server/routes/api/cases/push_case.test.ts index 549195966b2a7..49801ea4e2f3e 100644 --- a/x-pack/plugins/case/server/routes/api/cases/push_case.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/push_case.test.ts @@ -13,63 +13,187 @@ import { createRoute, createRouteContext, mockCases, + mockCaseConfigure, + mockCaseMappings, + mockUserActions, + mockCaseComments, } from '../__fixtures__'; -import { initPushCaseUserActionApi } from './push_case'; -import { CASE_DETAILS_URL } from '../../../../common/constants'; -import { mockCaseConfigure } from '../__fixtures__/mock_saved_objects'; +import { initPushCaseApi } from './push_case'; +import { CasesRequestHandlerContext } from '../../../types'; +import { getCasePushUrl } from '../../../../common/api/helpers'; describe('Push case', () => { let routeHandler: RequestHandler; const mockDate = '2019-11-25T21:54:48.952Z'; - const caseExternalServiceRequestBody = { - connector_id: 'connector_id', - connector_name: 'connector_name', - external_id: 'external_id', - external_title: 'external_title', - external_url: 'external_url', - }; + const caseId = 'mock-id-3'; + const connectorId = '123'; + const path = getCasePushUrl(caseId, connectorId); + beforeAll(async () => { - routeHandler = await createRoute(initPushCaseUserActionApi, 'post'); + routeHandler = await createRoute(initPushCaseApi, 'post'); const spyOnDate = jest.spyOn(global, 'Date') as jest.SpyInstance<{}, []>; spyOnDate.mockImplementation(() => ({ toISOString: jest.fn().mockReturnValue(mockDate), })); }); + it(`Pushes a case`, async () => { const request = httpServerMock.createKibanaRequest({ - path: `${CASE_DETAILS_URL}/_push`, + path, + method: 'post', + params: { + case_id: caseId, + connector_id: connectorId, + }, + body: {}, + }); + + const { context } = await createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseMappingsSavedObject: mockCaseMappings, + caseConfigureSavedObject: mockCaseConfigure, + caseUserActionsSavedObject: mockUserActions, + }) + ); + + const response = await routeHandler(context, request, kibanaResponseFactory); + expect(response.status).toEqual(200); + expect(response.payload.external_service).toEqual({ + connector_id: connectorId, + connector_name: 'ServiceNow', + external_id: '10663', + external_title: 'RJ2-200', + external_url: 'https://siem-kibana.atlassian.net/browse/RJ2-200', + pushed_at: mockDate, + pushed_by: { + email: 'd00d@awesome.com', + full_name: 'Awesome D00d', + username: 'awesome', + }, + }); + }); + + it(`Pushes a case with comments`, async () => { + const request = httpServerMock.createKibanaRequest({ + path, + method: 'post', + params: { + case_id: caseId, + connector_id: connectorId, + }, + body: {}, + }); + + const { context } = await createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseMappingsSavedObject: mockCaseMappings, + caseConfigureSavedObject: mockCaseConfigure, + caseUserActionsSavedObject: mockUserActions, + caseCommentSavedObject: [mockCaseComments[0]], + }) + ); + + const response = await routeHandler(context, request, kibanaResponseFactory); + expect(response.status).toEqual(200); + expect(response.payload.comments[0].pushed_at).toEqual(mockDate); + expect(response.payload.comments[0].pushed_by).toEqual({ + email: 'd00d@awesome.com', + full_name: 'Awesome D00d', + username: 'awesome', + }); + }); + + it(`Filters comments with type alert correctly`, async () => { + const request = httpServerMock.createKibanaRequest({ + path, method: 'post', params: { - case_id: 'mock-id-3', + case_id: caseId, + connector_id: connectorId, }, - body: caseExternalServiceRequestBody, + body: {}, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, + caseMappingsSavedObject: mockCaseMappings, + caseConfigureSavedObject: mockCaseConfigure, + caseUserActionsSavedObject: mockUserActions, + caseCommentSavedObject: [mockCaseComments[0], mockCaseComments[3]], }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const caseClient = context.case.getCaseClient(); + caseClient.getAlerts = jest.fn().mockResolvedValue([]); + + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload.external_service.pushed_at).toEqual(mockDate); - expect(response.payload.external_service.connector_id).toEqual('connector_id'); - expect(response.payload.closed_at).toEqual(null); + expect(caseClient.getAlerts).toHaveBeenCalledWith({ ids: ['test-id'] }); + }); + + it(`Calls execute with correct arguments`, async () => { + const request = httpServerMock.createKibanaRequest({ + path, + method: 'post', + params: { + case_id: caseId, + connector_id: 'for-mock-case-id-3', + }, + body: {}, + }); + + const { context } = await createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseMappingsSavedObject: mockCaseMappings, + caseConfigureSavedObject: mockCaseConfigure, + caseUserActionsSavedObject: mockUserActions, + }) + ); + + const actionsClient = context.actions.getActionsClient(); + + await routeHandler(context, request, kibanaResponseFactory); + expect(actionsClient.execute).toHaveBeenCalledWith({ + actionId: 'for-mock-case-id-3', + params: { + subAction: 'pushToService', + subActionParams: { + incident: { + issueType: 'Task', + parent: null, + priority: 'High', + labels: ['LOLBins'], + summary: 'Another bad one (created at 2019-11-25T22:32:17.947Z by elastic)', + description: + 'Oh no, a bad meanie going LOLBins all over the place! (created at 2019-11-25T22:32:17.947Z by elastic)', + externalId: null, + }, + comments: [], + }, + }, + }); }); + it(`Pushes a case and closes when closure_type: 'close-by-pushing'`, async () => { const request = httpServerMock.createKibanaRequest({ - path: `${CASE_DETAILS_URL}/_push`, + path, method: 'post', params: { - case_id: 'mock-id-3', + case_id: caseId, + connector_id: connectorId, }, - body: caseExternalServiceRequestBody, + body: {}, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, + caseMappingsSavedObject: mockCaseMappings, + caseUserActionsSavedObject: mockUserActions, caseConfigureSavedObject: [ { ...mockCaseConfigure[0], @@ -82,30 +206,259 @@ describe('Push case', () => { }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(200); - expect(response.payload.external_service.pushed_at).toEqual(mockDate); - expect(response.payload.external_service.connector_id).toEqual('connector_id'); expect(response.payload.closed_at).toEqual(mockDate); }); - it(`Returns an error if pushCaseUserAction throws`, async () => { + it(`post the correct user action`, async () => { + const request = httpServerMock.createKibanaRequest({ + path, + method: 'post', + params: { + case_id: caseId, + connector_id: connectorId, + }, + body: {}, + }); + + const { context, services } = await createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseMappingsSavedObject: mockCaseMappings, + caseConfigureSavedObject: mockCaseConfigure, + caseUserActionsSavedObject: mockUserActions, + }) + ); + + services.userActionService.postUserActions = jest.fn(); + const postUserActions = services.userActionService.postUserActions as jest.Mock; + + const response = await routeHandler(context, request, kibanaResponseFactory); + expect(response.status).toEqual(200); + expect(postUserActions.mock.calls[0][0].actions[0].attributes).toEqual({ + action: 'push-to-service', + action_at: '2019-11-25T21:54:48.952Z', + action_by: { + email: 'd00d@awesome.com', + full_name: 'Awesome D00d', + username: 'awesome', + }, + action_field: ['pushed'], + new_value: + '{"pushed_at":"2019-11-25T21:54:48.952Z","pushed_by":{"username":"awesome","full_name":"Awesome D00d","email":"d00d@awesome.com"},"connector_id":"123","connector_name":"ServiceNow","external_id":"10663","external_title":"RJ2-200","external_url":"https://siem-kibana.atlassian.net/browse/RJ2-200"}', + old_value: null, + }); + }); + + it('Unhappy path - case id is missing', async () => { const request = httpServerMock.createKibanaRequest({ - path: `${CASE_DETAILS_URL}/_push`, + path, method: 'post', - body: { - notagoodbody: 'Throw an error', + params: { + connector_id: connectorId, + }, + body: {}, + }); + + const { context } = await createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseMappingsSavedObject: mockCaseMappings, + caseConfigureSavedObject: mockCaseConfigure, + caseUserActionsSavedObject: mockUserActions, + }) + ); + + const res = await routeHandler(context, request, kibanaResponseFactory); + expect(res.status).toEqual(400); + }); + + it('Unhappy path - connector id is missing', async () => { + const request = httpServerMock.createKibanaRequest({ + path, + method: 'post', + params: { + case_id: caseId, }, + body: {}, }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, + caseMappingsSavedObject: mockCaseMappings, + caseConfigureSavedObject: mockCaseConfigure, + caseUserActionsSavedObject: mockUserActions, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); - expect(response.status).toEqual(400); - expect(response.payload.isBoom).toEqual(true); + const res = await routeHandler(context, request, kibanaResponseFactory); + expect(res.status).toEqual(400); + }); + + it('Unhappy path - case does not exists', async () => { + const request = httpServerMock.createKibanaRequest({ + path, + method: 'post', + params: { + case_id: 'not-exist', + connector_id: connectorId, + }, + body: {}, + }); + + const { context } = await createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseMappingsSavedObject: mockCaseMappings, + caseConfigureSavedObject: mockCaseConfigure, + caseUserActionsSavedObject: mockUserActions, + }) + ); + + const res = await routeHandler(context, request, kibanaResponseFactory); + expect(res.status).toEqual(404); + }); + + it('Unhappy path - connector does not exists', async () => { + const request = httpServerMock.createKibanaRequest({ + path, + method: 'post', + params: { + case_id: caseId, + connector_id: 'not-exists', + }, + body: {}, + }); + + const { context } = await createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseMappingsSavedObject: mockCaseMappings, + caseConfigureSavedObject: mockCaseConfigure, + caseUserActionsSavedObject: mockUserActions, + }) + ); + + const res = await routeHandler(context, request, kibanaResponseFactory); + expect(res.status).toEqual(404); + }); + + it('Unhappy path - cannot push to a closed case', async () => { + const request = httpServerMock.createKibanaRequest({ + path, + method: 'post', + params: { + case_id: 'mock-id-4', + connector_id: connectorId, + }, + body: {}, + }); + + const { context } = await createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseMappingsSavedObject: mockCaseMappings, + caseConfigureSavedObject: mockCaseConfigure, + caseUserActionsSavedObject: mockUserActions, + }) + ); + + const res = await routeHandler(context, request, kibanaResponseFactory); + expect(res.status).toEqual(409); + expect(res.payload.output.payload.message).toBe( + 'This case Another bad one is closed. You can not pushed if the case is closed.' + ); + }); + + it('Unhappy path - throws when external service returns an error', async () => { + const request = httpServerMock.createKibanaRequest({ + path, + method: 'post', + params: { + case_id: caseId, + connector_id: connectorId, + }, + body: {}, + }); + + const { context } = await createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseMappingsSavedObject: mockCaseMappings, + caseConfigureSavedObject: mockCaseConfigure, + caseUserActionsSavedObject: mockUserActions, + }) + ); + + const actionsClient = context.actions.getActionsClient(); + (actionsClient.execute as jest.Mock).mockResolvedValue({ + status: 'error', + }); + + const res = await routeHandler(context, request, kibanaResponseFactory); + expect(res.status).toEqual(424); + expect(res.payload.output.payload.message).toBe('Error pushing to service'); + }); + + it('Unhappy path - context case missing', async () => { + const request = httpServerMock.createKibanaRequest({ + path, + method: 'post', + params: { + case_id: caseId, + connector_id: connectorId, + }, + body: {}, + }); + + const { context } = await createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseMappingsSavedObject: mockCaseMappings, + caseConfigureSavedObject: mockCaseConfigure, + caseUserActionsSavedObject: mockUserActions, + }) + ); + + const betterContext = ({ + ...context, + case: null, + } as unknown) as CasesRequestHandlerContext; + + const res = await routeHandler(betterContext, request, kibanaResponseFactory); + expect(res.status).toEqual(400); + expect(res.payload).toEqual('RouteHandlerContext is not registered for cases'); + }); + + it('Unhappy path - context actions missing', async () => { + const request = httpServerMock.createKibanaRequest({ + path, + method: 'post', + params: { + case_id: caseId, + connector_id: connectorId, + }, + body: {}, + }); + + const { context } = await createRouteContext( + createMockSavedObjectsRepository({ + caseSavedObject: mockCases, + caseMappingsSavedObject: mockCaseMappings, + caseConfigureSavedObject: mockCaseConfigure, + caseUserActionsSavedObject: mockUserActions, + }) + ); + + const betterContext = ({ + ...context, + actions: null, + } as unknown) as CasesRequestHandlerContext; + + const res = await routeHandler(betterContext, request, kibanaResponseFactory); + expect(res.status).toEqual(400); + expect(res.payload).toEqual('Action client not found'); }); }); diff --git a/x-pack/plugins/case/server/routes/api/cases/push_case.ts b/x-pack/plugins/case/server/routes/api/cases/push_case.ts index 218b1f16b9aab..6d670c38bbf85 100644 --- a/x-pack/plugins/case/server/routes/api/cases/push_case.ts +++ b/x-pack/plugins/case/server/routes/api/cases/push_case.ts @@ -5,204 +5,51 @@ * 2.0. */ -import { schema } from '@kbn/config-schema'; import Boom from '@hapi/boom'; -import isEmpty from 'lodash/isEmpty'; import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; -import { - flattenCaseSavedObject, - wrapError, - escapeHatch, - getCommentContextFromAttributes, -} from '../utils'; +import { wrapError, escapeHatch } from '../utils'; -import { - CaseExternalServiceRequestRt, - CaseResponseRt, - throwErrors, - CaseStatuses, -} from '../../../../common/api'; -import { buildCaseUserActionItem } from '../../../services/user_actions/helpers'; +import { throwErrors, CasePushRequestParamsRt } from '../../../../common/api'; import { RouteDeps } from '../types'; -import { CASE_DETAILS_URL } from '../../../../common/constants'; +import { CASE_PUSH_URL } from '../../../../common/constants'; -export function initPushCaseUserActionApi({ - caseConfigureService, - caseService, - router, - userActionService, -}: RouteDeps) { +export function initPushCaseApi({ router }: RouteDeps) { router.post( { - path: `${CASE_DETAILS_URL}/_push`, + path: CASE_PUSH_URL, validate: { - params: schema.object({ - case_id: schema.string(), - }), + params: escapeHatch, body: escapeHatch, }, }, async (context, request, response) => { - try { - const client = context.core.savedObjects.client; - const actionsClient = await context.actions?.getActionsClient(); - - const caseId = request.params.case_id; - const query = pipe( - CaseExternalServiceRequestRt.decode(request.body), - fold(throwErrors(Boom.badRequest), identity) - ); - - if (actionsClient == null) { - throw Boom.notFound('Action client have not been found'); - } - - // eslint-disable-next-line @typescript-eslint/naming-convention - const { username, full_name, email } = await caseService.getUser({ request, response }); - - const pushedDate = new Date().toISOString(); - - const [myCase, myCaseConfigure, totalCommentsFindByCases, connectors] = await Promise.all([ - caseService.getCase({ - client, - caseId: request.params.case_id, - }), - caseConfigureService.find({ client }), - caseService.getAllCaseComments({ - client, - caseId, - options: { - fields: [], - page: 1, - perPage: 1, - }, - }), - actionsClient.getAll(), - ]); - - if (myCase.attributes.status === CaseStatuses.closed) { - throw Boom.conflict( - `This case ${myCase.attributes.title} is closed. You can not pushed if the case is closed.` - ); - } - - const comments = await caseService.getAllCaseComments({ - client, - caseId, - options: { - fields: [], - page: 1, - perPage: totalCommentsFindByCases.total, - }, - }); - - const externalService = { - pushed_at: pushedDate, - pushed_by: { username, full_name, email }, - ...query, - }; + if (!context.case) { + return response.badRequest({ body: 'RouteHandlerContext is not registered for cases' }); + } - const updateConnector = myCase.attributes.connector; + const caseClient = context.case.getCaseClient(); + const actionsClient = context.actions?.getActionsClient(); - if ( - isEmpty(updateConnector) || - (updateConnector != null && updateConnector.id === 'none') || - !connectors.some((connector) => connector.id === updateConnector.id) - ) { - throw Boom.notFound('Connector not found or set to none'); - } + if (actionsClient == null) { + return response.badRequest({ body: 'Action client not found' }); + } - const [updatedCase, updatedComments] = await Promise.all([ - caseService.patchCase({ - client, - caseId, - updatedAttributes: { - ...(myCaseConfigure.total > 0 && - myCaseConfigure.saved_objects[0].attributes.closure_type === 'close-by-pushing' - ? { - status: CaseStatuses.closed, - closed_at: pushedDate, - closed_by: { email, full_name, username }, - } - : {}), - external_service: externalService, - updated_at: pushedDate, - updated_by: { username, full_name, email }, - }, - version: myCase.version, - }), - caseService.patchComments({ - client, - comments: comments.saved_objects - .filter((comment) => comment.attributes.pushed_at == null) - .map((comment) => ({ - commentId: comment.id, - updatedAttributes: { - pushed_at: pushedDate, - pushed_by: { username, full_name, email }, - }, - version: comment.version, - })), - }), - userActionService.postUserActions({ - client, - actions: [ - ...(myCaseConfigure.total > 0 && - myCaseConfigure.saved_objects[0].attributes.closure_type === 'close-by-pushing' - ? [ - buildCaseUserActionItem({ - action: 'update', - actionAt: pushedDate, - actionBy: { username, full_name, email }, - caseId, - fields: ['status'], - newValue: CaseStatuses.closed, - oldValue: myCase.attributes.status, - }), - ] - : []), - buildCaseUserActionItem({ - action: 'push-to-service', - actionAt: pushedDate, - actionBy: { username, full_name, email }, - caseId, - fields: ['pushed'], - newValue: JSON.stringify(externalService), - }), - ], - }), - ]); + try { + const params = pipe( + CasePushRequestParamsRt.decode(request.params), + fold(throwErrors(Boom.badRequest), identity) + ); return response.ok({ - body: CaseResponseRt.encode( - flattenCaseSavedObject({ - savedObject: { - ...myCase, - ...updatedCase, - attributes: { ...myCase.attributes, ...updatedCase?.attributes }, - references: myCase.references, - }, - comments: comments.saved_objects.map((origComment) => { - const updatedComment = updatedComments.saved_objects.find( - (c) => c.id === origComment.id - ); - return { - ...origComment, - ...updatedComment, - attributes: { - ...origComment.attributes, - ...updatedComment?.attributes, - ...getCommentContextFromAttributes(origComment.attributes), - }, - version: updatedComment?.version ?? origComment.version, - references: origComment?.references ?? [], - }; - }), - }) - ), + body: await caseClient.push({ + caseClient, + actionsClient, + caseId: params.case_id, + connectorId: params.connector_id, + }), }); } catch (error) { return response.customError(wrapError(error)); diff --git a/x-pack/plugins/case/server/routes/api/cases/status/get_status.test.ts b/x-pack/plugins/case/server/routes/api/cases/status/get_status.test.ts index e8761ad69dcca..9644162629f24 100644 --- a/x-pack/plugins/case/server/routes/api/cases/status/get_status.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/status/get_status.test.ts @@ -36,24 +36,24 @@ describe('GET status', () => { method: 'get', }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: mockCases, }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); - expect(theContext.core.savedObjects.client.find).toHaveBeenNthCalledWith(1, { + const response = await routeHandler(context, request, kibanaResponseFactory); + expect(context.core.savedObjects.client.find).toHaveBeenNthCalledWith(1, { ...findArgs, filter: 'cases.attributes.status: open', }); - expect(theContext.core.savedObjects.client.find).toHaveBeenNthCalledWith(2, { + expect(context.core.savedObjects.client.find).toHaveBeenNthCalledWith(2, { ...findArgs, filter: 'cases.attributes.status: in-progress', }); - expect(theContext.core.savedObjects.client.find).toHaveBeenNthCalledWith(3, { + expect(context.core.savedObjects.client.find).toHaveBeenNthCalledWith(3, { ...findArgs, filter: 'cases.attributes.status: closed', }); @@ -71,13 +71,13 @@ describe('GET status', () => { method: 'get', }); - const theContext = await createRouteContext( + const { context } = await createRouteContext( createMockSavedObjectsRepository({ caseSavedObject: [{ ...mockCases[0], id: 'throw-error-find' }], }) ); - const response = await routeHandler(theContext, request, kibanaResponseFactory); + const response = await routeHandler(context, request, kibanaResponseFactory); expect(response.status).toEqual(404); }); }); diff --git a/x-pack/plugins/case/server/routes/api/cases/user_actions/get_all_user_actions.ts b/x-pack/plugins/case/server/routes/api/cases/user_actions/get_all_user_actions.ts index 346eec3dde752..06e929cc40e6b 100644 --- a/x-pack/plugins/case/server/routes/api/cases/user_actions/get_all_user_actions.ts +++ b/x-pack/plugins/case/server/routes/api/cases/user_actions/get_all_user_actions.ts @@ -7,13 +7,11 @@ import { schema } from '@kbn/config-schema'; -import { CaseUserActionsResponseRt } from '../../../../../common/api'; -import { CASE_SAVED_OBJECT, CASE_COMMENT_SAVED_OBJECT } from '../../../../saved_object_types'; import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; import { CASE_USER_ACTIONS_URL } from '../../../../../common/constants'; -export function initGetAllUserActionsApi({ userActionService, router }: RouteDeps) { +export function initGetAllUserActionsApi({ router }: RouteDeps) { router.get( { path: CASE_USER_ACTIONS_URL, @@ -24,22 +22,16 @@ export function initGetAllUserActionsApi({ userActionService, router }: RouteDep }, }, async (context, request, response) => { + if (!context.case) { + return response.badRequest({ body: 'RouteHandlerContext is not registered for cases' }); + } + + const caseClient = context.case.getCaseClient(); + const caseId = request.params.case_id; + try { - const client = context.core.savedObjects.client; - const userActions = await userActionService.getUserActions({ - client, - caseId: request.params.case_id, - }); return response.ok({ - body: CaseUserActionsResponseRt.encode( - userActions.saved_objects.map((ua) => ({ - ...ua.attributes, - action_id: ua.id, - case_id: ua.references.find((r) => r.type === CASE_SAVED_OBJECT)?.id ?? '', - comment_id: - ua.references.find((r) => r.type === CASE_COMMENT_SAVED_OBJECT)?.id ?? null, - })) - ), + body: await caseClient.getUserActions({ caseId }), }); } catch (error) { return response.customError(wrapError(error)); diff --git a/x-pack/plugins/case/server/routes/api/index.ts b/x-pack/plugins/case/server/routes/api/index.ts index c399364ea35ec..00660e08bbd83 100644 --- a/x-pack/plugins/case/server/routes/api/index.ts +++ b/x-pack/plugins/case/server/routes/api/index.ts @@ -10,7 +10,7 @@ import { initFindCasesApi } from '././cases/find_cases'; import { initGetCaseApi } from './cases/get_case'; import { initPatchCasesApi } from './cases/patch_cases'; import { initPostCaseApi } from './cases/post_case'; -import { initPushCaseUserActionApi } from './cases/push_case'; +import { initPushCaseApi } from './cases/push_case'; import { initGetReportersApi } from './cases/reporters/get_reporters'; import { initGetCasesStatusApi } from './cases/status/get_status'; import { initGetTagsApi } from './cases/tags/get_tags'; @@ -28,7 +28,6 @@ import { initCaseConfigureGetActionConnector } from './cases/configure/get_conne import { initGetCaseConfigure } from './cases/configure/get_configure'; import { initPatchCaseConfigure } from './cases/configure/patch_configure'; import { initPostCaseConfigure } from './cases/configure/post_configure'; -import { initPostPushToService } from './cases/configure/post_push_to_service'; import { RouteDeps } from './types'; @@ -39,7 +38,7 @@ export function initCaseApi(deps: RouteDeps) { initGetCaseApi(deps); initPatchCasesApi(deps); initPostCaseApi(deps); - initPushCaseUserActionApi(deps); + initPushCaseApi(deps); initGetAllUserActionsApi(deps); // Comments initDeleteCommentApi(deps); @@ -54,7 +53,6 @@ export function initCaseApi(deps: RouteDeps) { initGetCaseConfigure(deps); initPatchCaseConfigure(deps); initPostCaseConfigure(deps); - initPostPushToService(deps); // Reporters initGetReportersApi(deps); // Status diff --git a/x-pack/plugins/case/server/routes/api/utils.ts b/x-pack/plugins/case/server/routes/api/utils.ts index b7e556daffbd9..e2751c05d880a 100644 --- a/x-pack/plugins/case/server/routes/api/utils.ts +++ b/x-pack/plugins/case/server/routes/api/utils.ts @@ -191,11 +191,11 @@ export const sortToSnake = (sortField: string): SortFieldCase => { export const escapeHatch = schema.object({}, { unknowns: 'allow' }); -const isUserContext = (context: CommentRequest): context is CommentRequestUserType => { +export const isUserContext = (context: CommentRequest): context is CommentRequestUserType => { return context.type === CommentType.user; }; -const isAlertContext = (context: CommentRequest): context is CommentRequestAlertType => { +export const isAlertContext = (context: CommentRequest): context is CommentRequestAlertType => { return context.type === CommentType.alert; }; @@ -206,17 +206,3 @@ export const decodeComment = (comment: CommentRequest) => { pipe(excess(ContextTypeAlertRt).decode(comment), fold(throwErrors(badRequest), identity)); } }; - -export const getCommentContextFromAttributes = ( - attributes: CommentAttributes -): CommentRequestUserType | CommentRequestAlertType => - isUserContext(attributes) - ? { - type: CommentType.user, - comment: attributes.comment, - } - : { - type: CommentType.alert, - alertId: attributes.alertId, - index: attributes.index, - }; diff --git a/x-pack/plugins/case/server/services/alerts/index.ts b/x-pack/plugins/case/server/services/alerts/index.ts index 4f0d415f23b50..2776d6b40761e 100644 --- a/x-pack/plugins/case/server/services/alerts/index.ts +++ b/x-pack/plugins/case/server/services/alerts/index.ts @@ -19,6 +19,24 @@ interface UpdateAlertsStatusArgs { index: string; } +interface GetAlertsArgs { + request: KibanaRequest; + ids: string[]; + index: string; +} + +interface Alert { + _id: string; + _index: string; + _source: Record; +} + +interface AlertsResponse { + hits: { + hits: Alert[]; + }; +} + export class AlertService { private isInitialized = false; private esClient?: IClusterClient; @@ -55,4 +73,30 @@ export class AlertService { return result; } + + public async getAlerts({ request, ids, index }: GetAlertsArgs): Promise { + if (!this.isInitialized) { + throw new Error('AlertService not initialized'); + } + + // The above check makes sure that esClient is defined. + const result = await this.esClient!.asScoped(request).asCurrentUser.search({ + index, + body: { + query: { + bool: { + filter: { + bool: { + should: ids.map((_id) => ({ match: { _id } })), + minimum_should_match: 1, + }, + }, + }, + }, + }, + ignore_unavailable: true, + }); + + return result.body; + } } diff --git a/x-pack/plugins/case/server/services/mocks.ts b/x-pack/plugins/case/server/services/mocks.ts index 7c8b44b297362..0b3615793ef85 100644 --- a/x-pack/plugins/case/server/services/mocks.ts +++ b/x-pack/plugins/case/server/services/mocks.ts @@ -59,4 +59,5 @@ export const createUserActionServiceMock = (): CaseUserActionServiceMock => ({ export const createAlertServiceMock = (): AlertServiceMock => ({ initialize: jest.fn(), updateAlertsStatus: jest.fn(), + getAlerts: jest.fn(), }); diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/extend_button.tsx b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/extend_button.tsx index 1e2678912ce99..06459db154f4a 100644 --- a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/extend_button.tsx +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/components/actions/extend_button.tsx @@ -58,7 +58,9 @@ const ExtendConfirm = ({ onCancel={onConfirmDismiss} onConfirm={async () => { setIsLoading(true); - await api.sendExtend(id, `${extendByDuration.asMilliseconds()}ms`); + await api.sendExtend(id, `${newExpiration.toISOString()}`); + setIsLoading(false); + onConfirmDismiss(); onActionComplete(); }} confirmButtonText={confirm} diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/api.test.ts b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/api.test.ts index 86acbcdb53001..0fa13ac145223 100644 --- a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/api.test.ts +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/api.test.ts @@ -168,7 +168,7 @@ describe('Search Sessions Management API', () => { describe('extend', () => { beforeEach(() => { - sessionsClient.find = jest.fn().mockImplementation(async () => { + sessionsClient.extend = jest.fn().mockImplementation(async () => { return { saved_objects: [ { @@ -188,6 +188,20 @@ describe('Search Sessions Management API', () => { }); await api.sendExtend('my-id', '5d'); + expect(sessionsClient.extend).toHaveBeenCalledTimes(1); + expect(mockCoreStart.notifications.toasts.addSuccess).toHaveBeenCalled(); + }); + + test('displays error on reject', async () => { + sessionsClient.extend = jest.fn().mockRejectedValue({}); + const api = new SearchSessionsMgmtAPI(sessionsClient, mockConfig, { + urls: mockUrls, + notifications: mockCoreStart.notifications, + application: mockCoreStart.application, + }); + await api.sendExtend('my-id', '5d'); + + expect(sessionsClient.extend).toHaveBeenCalledTimes(1); expect(mockCoreStart.notifications.toasts.addError).toHaveBeenCalled(); }); }); diff --git a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/api.ts b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/api.ts index 264556f91cc37..42e9384cce2d8 100644 --- a/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/api.ts +++ b/x-pack/plugins/data_enhanced/public/search/sessions_mgmt/lib/api.ts @@ -166,9 +166,6 @@ export class SearchSessionsMgmtAPI { }), }); } catch (err) { - // eslint-disable-next-line no-console - console.error(err); - this.deps.notifications.toasts.addError(err, { title: i18n.translate('xpack.data.mgmt.searchSessions.api.deletedError', { defaultMessage: 'Failed to delete the search session!', @@ -178,11 +175,21 @@ export class SearchSessionsMgmtAPI { } // Extend - public async sendExtend(id: string, ttl: string): Promise { - this.deps.notifications.toasts.addError(new Error('Not implemented'), { - title: i18n.translate('xpack.data.mgmt.searchSessions.api.extendError', { - defaultMessage: 'Failed to extend the session expiration!', - }), - }); + public async sendExtend(id: string, expires: string): Promise { + try { + await this.sessionsClient.extend(id, expires); + + this.deps.notifications.toasts.addSuccess({ + title: i18n.translate('xpack.data.mgmt.searchSessions.api.extended', { + defaultMessage: 'The search session was extended.', + }), + }); + } catch (err) { + this.deps.notifications.toasts.addError(err, { + title: i18n.translate('xpack.data.mgmt.searchSessions.api.extendError', { + defaultMessage: 'Failed to extend the search session!', + }), + }); + } } } diff --git a/x-pack/plugins/fleet/server/services/agents/enroll.ts b/x-pack/plugins/fleet/server/services/agents/enroll.ts index c984a84ceea01..6ca19bf884cca 100644 --- a/x-pack/plugins/fleet/server/services/agents/enroll.ts +++ b/x-pack/plugins/fleet/server/services/agents/enroll.ts @@ -11,11 +11,13 @@ import semverParse from 'semver/functions/parse'; import semverDiff from 'semver/functions/diff'; import semverLte from 'semver/functions/lte'; -import { SavedObjectsClientContract } from 'src/core/server'; -import { AgentType, Agent, AgentSOAttributes, FleetServerAgent } from '../../types'; +import type { SavedObjectsClientContract } from 'src/core/server'; +import type { AgentType, Agent, AgentSOAttributes, FleetServerAgent } from '../../types'; import { savedObjectToAgent } from './saved_objects'; import { AGENT_SAVED_OBJECT_TYPE, AGENTS_INDEX } from '../../constants'; +import { IngestManagerError } from '../../errors'; import * as APIKeyService from '../api_keys'; +import { agentPolicyService } from '../../services'; import { appContextService } from '../app_context'; export async function enroll( @@ -27,6 +29,11 @@ export async function enroll( const agentVersion = metadata?.local?.elastic?.agent?.version; validateAgentVersion(agentVersion); + const agentPolicy = await agentPolicyService.get(soClient, agentPolicyId, false); + if (agentPolicy?.is_managed) { + throw new IngestManagerError(`Cannot enroll in managed policy ${agentPolicyId}`); + } + if (appContextService.getConfig()?.agents?.fleetServerEnabled) { const esClient = appContextService.getInternalUserESClient(); diff --git a/x-pack/plugins/fleet/tsconfig.json b/x-pack/plugins/fleet/tsconfig.json new file mode 100644 index 0000000000000..3a37b14410424 --- /dev/null +++ b/x-pack/plugins/fleet/tsconfig.json @@ -0,0 +1,39 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": true, + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + // add all the folders containg files to be compiled + "common/**/*", + "public/**/*", + "server/**/*", + "scripts/**/*" + ], + "references": [ + { "path": "../../../src/core/tsconfig.json" }, + // add references to other TypeScript projects the plugin depends on + + // requiredPlugins from ./kibana.json + { "path": "../licensing/tsconfig.json" }, + { "path": "../../../src/plugins/data/tsconfig.json" }, + { "path": "../encrypted_saved_objects/tsconfig.json" }, + + // optionalPlugins from ./kibana.json + { "path": "../security/tsconfig.json" }, + { "path": "../features/tsconfig.json" }, + { "path": "../cloud/tsconfig.json" }, + { "path": "../../../src/plugins/usage_collection/tsconfig.json" }, + { "path": "../../../src/plugins/home/tsconfig.json" }, + + // requiredBundles from ./kibana.json + { "path": "../../../src/plugins/kibana_react/tsconfig.json" }, + { "path": "../../../src/plugins/es_ui_shared/tsconfig.json" }, + { "path": "../infra/tsconfig.json" }, + { "path": "../../../src/plugins/kibana_utils/tsconfig.json" }, + ] +} diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx index a59c4d9878aea..dc375f6370048 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx @@ -248,6 +248,27 @@ export const setup = async (arg?: { appServicesContext: Partial { + const enablePhase = async () => { + await act(async () => { + find('enableDeletePhaseButton').simulate('click'); + }); + component.update(); + }; + + const disablePhase = async () => { + await act(async () => { + find('disableDeletePhaseButton').simulate('click'); + }); + component.update(); + }; + + return { + enablePhase, + disablePhase, + }; + }; + return { ...testBed, actions: { @@ -303,7 +324,7 @@ export const setup = async (arg?: { appServicesContext: Partial', () => { // Set max docs to test whether we keep the unknown fields in that object after serializing await actions.hot.setMaxDocs('1000'); // Remove the delete phase to ensure that we also correctly remove data - await actions.delete.enable(false); + await actions.delete.disablePhase(); await actions.savePolicy(); const latestRequest = server.requests[server.requests.length - 1]; @@ -89,7 +89,7 @@ describe('', () => { unknown_setting: true, }, }, - min_age: '0ms', + min_age: '0d', }, }, }); @@ -255,7 +255,7 @@ describe('', () => { "priority": 50, }, }, - "min_age": "0ms", + "min_age": "0d", } `); }); @@ -310,7 +310,7 @@ describe('', () => { "number_of_shards": 123, }, }, - "min_age": "0ms", + "min_age": "0d", }, }, } @@ -839,7 +839,7 @@ describe('', () => { expect(actions.timeline.hasColdPhase()).toBe(true); expect(actions.timeline.hasDeletePhase()).toBe(false); - await actions.delete.enable(true); + await actions.delete.enablePhase(); expect(actions.timeline.hasHotPhase()).toBe(true); expect(actions.timeline.hasWarmPhase()).toBe(true); expect(actions.timeline.hasColdPhase()).toBe(true); diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.tsx b/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.tsx index a9a351e394f7f..7c199e2ced765 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.tsx +++ b/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.tsx @@ -99,6 +99,13 @@ const activatePhase = async (rendered: ReactWrapper, phase: string) => { }); rendered.update(); }; +const activateDeletePhase = async (rendered: ReactWrapper) => { + const testSubject = `enableDeletePhaseButton`; + await act(async () => { + await findTestSubject(rendered, testSubject).simulate('click'); + }); + rendered.update(); +}; const openNodeAttributesSection = async (rendered: ReactWrapper, phase: string) => { const getControls = () => findTestSubject(rendered, `${phase}-dataTierAllocationControls`); await act(async () => { @@ -454,6 +461,11 @@ describe('edit policy', () => { waitForFormLibValidation(rendered); expectedErrorMessages(rendered, [i18nTexts.editPolicy.errors.nonNegativeNumberRequired]); }); + + test("doesn't show min age input", async () => { + const rendered = mountWithIntl(component); + expect(findTestSubject(rendered, 'hot-selectedMinimumAge').exists()).toBeFalsy(); + }); }); describe('warm phase', () => { beforeEach(() => { @@ -670,6 +682,13 @@ describe('edit policy', () => { expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); expect(findTestSubject(rendered, 'defaultAllocationNotice').exists()).toBeFalsy(); }); + + test('shows min age input only when enabled', async () => { + const rendered = mountWithIntl(component); + expect(findTestSubject(rendered, 'warm-selectedMinimumAge').exists()).toBeFalsy(); + await activatePhase(rendered, 'warm'); + expect(findTestSubject(rendered, 'warm-selectedMinimumAge').exists()).toBeTruthy(); + }); }); describe('cold phase', () => { beforeEach(() => { @@ -807,13 +826,20 @@ describe('edit policy', () => { expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); expect(findTestSubject(rendered, 'defaultAllocationNotice').exists()).toBeFalsy(); }); + + test('shows min age input only when enabled', async () => { + const rendered = mountWithIntl(component); + expect(findTestSubject(rendered, 'cold-selectedMinimumAge').exists()).toBeFalsy(); + await activatePhase(rendered, 'cold'); + expect(findTestSubject(rendered, 'cold-selectedMinimumAge').exists()).toBeTruthy(); + }); }); describe('delete phase', () => { test('should allow 0 for phase timing', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); await setPolicyName(rendered, 'mypolicy'); - await activatePhase(rendered, 'delete'); + await activateDeletePhase(rendered); await setPhaseAfter(rendered, 'delete', '0'); waitForFormLibValidation(rendered); expectedErrorMessages(rendered, []); @@ -822,11 +848,18 @@ describe('edit policy', () => { const rendered = mountWithIntl(component); await noRollover(rendered); await setPolicyName(rendered, 'mypolicy'); - await activatePhase(rendered, 'delete'); + await activateDeletePhase(rendered); await setPhaseAfter(rendered, 'delete', '-1'); waitForFormLibValidation(rendered); expectedErrorMessages(rendered, [i18nTexts.editPolicy.errors.nonNegativeNumberRequired]); }); + + test('is hidden when disabled', async () => { + const rendered = mountWithIntl(component); + expect(findTestSubject(rendered, 'delete-phaseContent').exists()).toBeFalsy(); + await activateDeletePhase(rendered); + expect(findTestSubject(rendered, 'delete-phaseContent').exists()).toBeTruthy(); + }); }); describe('not on cloud', () => { beforeEach(() => { diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/active_badge.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/active_badge.tsx deleted file mode 100644 index f3a6ee7276cde..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/active_badge.tsx +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { EuiBadge } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; - -export const ActiveBadge = () => { - return ( - - - - ); -}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/active_highlight/active_highlight.scss b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/active_highlight/active_highlight.scss deleted file mode 100644 index 96ca0c3a61067..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/active_highlight/active_highlight.scss +++ /dev/null @@ -1,16 +0,0 @@ -.ilmActivePhaseHighlight { - border-left: $euiBorderWidthThin solid $euiColorLightShade; - height: 100%; - - &.hotPhase.active { - border-left-color: $euiColorVis9_behindText; - } - - &.warmPhase.active { - border-left-color: $euiColorVis5_behindText; - } - - &.coldPhase.active { - border-left-color: $euiColorVis1_behindText; - } -} diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/index.ts index a84d15e6c19da..dc4f1e31d3696 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/index.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/index.ts @@ -5,14 +5,12 @@ * 2.0. */ -export { ActiveBadge } from './active_badge'; export { LearnMoreLink } from './learn_more_link'; export { OptionalLabel } from './optional_label'; export { PolicyJsonFlyout } from './policy_json_flyout'; export { DescribedFormRow, ToggleFieldWithDescribedFormRow } from './described_form_row'; export { FieldLoadingError } from './field_loading_error'; -export { ActiveHighlight } from './active_highlight'; export { Timeline } from './timeline'; export { FormErrorsCallout } from './form_errors_callout'; - +export { PhaseFooter } from './phase_footer'; export * from './phases'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/active_highlight/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/infinity_icon/index.ts similarity index 82% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/active_highlight/index.ts rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/infinity_icon/index.ts index c8d3b6540dc3d..850f3e4e07aed 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/active_highlight/index.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/infinity_icon/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { ActiveHighlight } from './active_highlight'; +export { InfinityIcon } from './infinity_icon'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/infinity_icon.svg.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/infinity_icon/infinity_icon.svg.tsx similarity index 100% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/infinity_icon.svg.tsx rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/infinity_icon/infinity_icon.svg.tsx diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/active_highlight/active_highlight.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/infinity_icon/infinity_icon.tsx similarity index 51% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/active_highlight/active_highlight.tsx rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/infinity_icon/infinity_icon.tsx index bae73c3cefa5d..435e6a909acd1 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/active_highlight/active_highlight.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/infinity_icon/infinity_icon.tsx @@ -6,13 +6,9 @@ */ import React, { FunctionComponent } from 'react'; +import { EuiIcon, EuiIconProps } from '@elastic/eui'; +import { InfinityIconSvg } from './infinity_icon.svg'; -import './active_highlight.scss'; - -interface Props { - phase: 'hot' | 'warm' | 'cold'; - enabled: boolean; -} -export const ActiveHighlight: FunctionComponent = ({ phase, enabled }) => { - return
; -}; +export const InfinityIcon: FunctionComponent> = (props) => ( + +); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phase_footer/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phase_footer/index.ts new file mode 100644 index 0000000000000..724904a1f188e --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phase_footer/index.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 { PhaseFooter } from './phase_footer'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phase_footer/phase_footer.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phase_footer/phase_footer.tsx new file mode 100644 index 0000000000000..82f0725bfe7d0 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phase_footer/phase_footer.tsx @@ -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 React, { FunctionComponent } from 'react'; + +import { i18n } from '@kbn/i18n'; + +import { EuiText, EuiButtonGroup, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +import { PhasesExceptDelete } from '../../../../../../common/types'; + +import { usePhaseTimings } from '../../form'; + +import { InfinityIconSvg } from '../infinity_icon/infinity_icon.svg'; + +interface Props { + phase: PhasesExceptDelete; +} + +export const PhaseFooter: FunctionComponent = ({ phase }) => { + const { + isDeletePhaseEnabled, + setDeletePhaseEnabled: setValue, + [phase]: phaseConfiguration, + } = usePhaseTimings(); + + if (!phaseConfiguration.isFinalDataPhase) { + return null; + } + + const phaseDescription = isDeletePhaseEnabled + ? i18n.translate('xpack.indexLifecycleMgmt.editPolicy.phaseTiming.beforeDeleteDescription', { + defaultMessage: 'Data will be deleted after this phase', + }) + : i18n.translate('xpack.indexLifecycleMgmt.editPolicy.phaseTiming.foreverTimingDescription', { + defaultMessage: 'Data will remain in this phase forever', + }); + + const selectedButton = isDeletePhaseEnabled + ? 'ilmEnableDeletePhaseButton' + : 'ilmDisableDeletePhaseButton'; + + const buttons = [ + { + id: `ilmDisableDeletePhaseButton`, + label: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.deletePhase.disablePhaseButtonLabel', + { + defaultMessage: 'Keep data in this phase forever', + } + ), + iconType: InfinityIconSvg, + }, + { + id: `ilmEnableDeletePhaseButton`, + label: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.deletePhase.enablePhaseButtonLabel', + { + defaultMessage: 'Delete data after this phase', + } + ), + iconType: 'trash', + 'data-test-subj': 'enableDeletePhaseButton', + }, + ]; + + return ( + + + + {phaseDescription} + + + + { + setValue(id === 'ilmEnableDeletePhaseButton'); + }} + isIconOnly={true} + /> + + + ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phase_icon/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phase_icon/index.ts new file mode 100644 index 0000000000000..26fda5d929284 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phase_icon/index.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 { PhaseIcon } from './phase_icon'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phase_icon/phase_icon.scss b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phase_icon/phase_icon.scss new file mode 100644 index 0000000000000..7c6a5aefdde6e --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phase_icon/phase_icon.scss @@ -0,0 +1,33 @@ +.ilmPhaseIcon { + width: $euiSizeXL; + height: $euiSizeXL; + display: flex; + justify-content: center; + align-items: center; + border-radius: 50%; + background-color: $euiColorLightestShade; + &--disabled { + margin-top: $euiSizeS; + width: $euiSize; + height: $euiSize; + } + &--delete { + background-color: $euiColorLightShade; + } + &__inner--hot { + fill: $euiColorVis9_behindText; + } + &__inner--warm { + fill: $euiColorVis5_behindText; + } + &__inner--cold { + fill: $euiColorVis1_behindText; + } + &__inner--delete { + fill: $euiColorDarkShade; + } + + &__inner--disabled { + fill: $euiColorMediumShade; + } +} diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phase_icon/phase_icon.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phase_icon/phase_icon.tsx new file mode 100644 index 0000000000000..8c0a0bcca1d76 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phase_icon/phase_icon.tsx @@ -0,0 +1,32 @@ +/* + * 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, { FunctionComponent } from 'react'; +import { EuiIcon } from '@elastic/eui'; +import { Phases } from '../../../../../../common/types'; +import './phase_icon.scss'; +interface Props { + enabled: boolean; + phase: string & keyof Phases; +} +export const PhaseIcon: FunctionComponent = ({ enabled, phase }) => { + return ( +
+ {enabled ? ( + + ) : ( + + )} +
+ ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/delete_phase/delete_phase.scss b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/delete_phase/delete_phase.scss new file mode 100644 index 0000000000000..60a39c7f1e9a6 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/delete_phase/delete_phase.scss @@ -0,0 +1,11 @@ +.ilmDeletePhase { + .euiCommentEvent { + &__header { + padding: $euiSize; + background-color: $euiColorEmptyShade; + } + &__body { + padding: $euiSize; + } + } +} diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/delete_phase/delete_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/delete_phase/delete_phase.tsx index c2da9246effb7..c65699ca12690 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/delete_phase/delete_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/delete_phase/delete_phase.tsx @@ -5,107 +5,85 @@ * 2.0. */ -import React, { FunctionComponent, Fragment } from 'react'; +import React, { FunctionComponent } from 'react'; import { get } from 'lodash'; +import { + EuiFlexItem, + EuiFlexGroup, + EuiTitle, + EuiButtonEmpty, + EuiSpacer, + EuiText, + EuiComment, +} from '@elastic/eui'; + import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiDescribedFormGroup, EuiTextColor, EuiFormRow } from '@elastic/eui'; -import { useFormData, ToggleField } from '../../../../../../shared_imports'; +import { useFormData } from '../../../../../../shared_imports'; -import { UseField } from '../../../form'; +import { i18nTexts } from '../../../i18n_texts'; -import { ActiveBadge, LearnMoreLink, OptionalLabel } from '../../index'; +import { usePhaseTimings } from '../../../form'; import { MinAgeField, SnapshotPoliciesField } from '../shared_fields'; +import './delete_phase.scss'; +import { PhaseIcon } from '../../phase_icon'; +import { PhaseErrorIndicator } from '../phase/phase_error_indicator'; const formFieldPaths = { enabled: '_meta.delete.enabled', }; export const DeletePhase: FunctionComponent = () => { + const { setDeletePhaseEnabled } = usePhaseTimings(); const [formData] = useFormData({ watch: formFieldPaths.enabled, }); const enabled = get(formData, formFieldPaths.enabled); - return ( -
- -

- -

{' '} - {enabled && } -
- } - titleSize="s" - description={ - -

- -

- -
- } - fullWidth - > - {enabled && } - - {enabled ? ( - - - - } - description={ - - {' '} - - - } - titleSize="xs" - fullWidth + if (!enabled) { + return null; + } + const phaseTitle = ( + + + +

{i18nTexts.editPolicy.titles.delete}

+
+
+ + + setDeletePhaseEnabled(false)} + data-test-subj={'disableDeletePhaseButton'} > - - - - - } - > - - -
- ) : null} -
+ + + + + + + + + ); + + return ( + } + className="ilmDeletePhase ilmPhase" + timelineIcon={} + > + + {i18nTexts.editPolicy.descriptions.delete} + + + + ); }; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/phase/phase.scss b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/phase/phase.scss new file mode 100644 index 0000000000000..15f2dc508a365 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/phase/phase.scss @@ -0,0 +1,27 @@ +.ilmPhase { + .euiCommentEvent { + &__header { + padding: $euiSize; + } + &__body { + padding: $euiSize; + } + } + .ilmSettingsButton { + color: $euiColorPrimary; + padding: $euiSizeS; + } + .euiCommentTimeline { + padding-top: $euiSize; + &::before { + height: calc(100% + #{$euiSizeXXL}); + } + } + &--enabled { + .euiCommentEvent { + &__header { + background-color: $euiColorEmptyShade; + } + } + } +} diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/phase/phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/phase/phase.tsx index f7e0f8e20e050..0ac6f6922ec1e 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/phase/phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/phase/phase.tsx @@ -5,126 +5,120 @@ * 2.0. */ -import React, { FunctionComponent, useState } from 'react'; +import React, { FunctionComponent } from 'react'; import { EuiFlexGroup, EuiFlexItem, - EuiPanel, EuiTitle, - EuiSpacer, EuiText, - EuiButtonEmpty, + EuiComment, + EuiAccordion, + EuiSpacer, + EuiBadge, } from '@elastic/eui'; import { get } from 'lodash'; import { FormattedMessage } from '@kbn/i18n/react'; +import { PhasesExceptDelete } from '../../../../../../../common/types'; import { ToggleField, useFormData } from '../../../../../../shared_imports'; import { i18nTexts } from '../../../i18n_texts'; +import { FormInternal } from '../../../types'; + import { UseField } from '../../../form'; -import { ActiveHighlight } from '../../active_highlight'; -import { MinAgeField } from '../shared_fields'; import { PhaseErrorIndicator } from './phase_error_indicator'; +import { MinAgeField } from '../shared_fields'; +import { PhaseIcon } from '../../phase_icon'; +import { PhaseFooter } from '../../phase_footer'; +import './phase.scss'; + interface Props { - phase: 'hot' | 'warm' | 'cold'; + phase: PhasesExceptDelete; } export const Phase: FunctionComponent = ({ children, phase }) => { const enabledPath = `_meta.${phase}.enabled`; - const [formData] = useFormData({ + const [formData] = useFormData({ watch: [enabledPath], }); + const isHotPhase = phase === 'hot'; // hot phase is always enabled - const enabled = get(formData, enabledPath) || phase === 'hot'; + const enabled = get(formData, enabledPath) || isHotPhase; - const [isShowingSettings, setShowingSettings] = useState(false); - return ( - + const phaseTitle = ( + + {!isHotPhase && ( + + + + )} - + +

{i18nTexts.editPolicy.titles[phase]}

+
- - - - - - {phase !== 'hot' && ( - - - - )} - - - - -

{i18nTexts.editPolicy.titles[phase]}

-
-
- - - -
-
-
-
- {enabled && ( - - - - {phase !== 'hot' && } - - - { - setShowingSettings(!isShowingSettings); - }} - size="xs" - iconType="controlsVertical" - iconSide="left" - aria-controls={`${phase}-phaseContent`} - > - - - - - - )} -
- - - {i18nTexts.editPolicy.descriptions[phase]} - - - {enabled && ( -
- - {children} -
- )} -
+ {isHotPhase && ( + + + + + + )} + +
); + + // @ts-ignore + const minAge = !isHotPhase && enabled ? : null; + + return ( + } + className={`ilmPhase ${enabled ? 'ilmPhase--enabled' : ''}`} + > + + {i18nTexts.editPolicy.descriptions[phase]} + + + {enabled && ( + <> + + + } + buttonClassName="ilmSettingsButton" + extraAction={} + > + + {children} + + + )} + + ); }; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/phase/phase_error_indicator.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/phase/phase_error_indicator.tsx index 98fdfe73ecbd8..647f12669cf77 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/phase/phase_error_indicator.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/phase/phase_error_indicator.tsx @@ -9,10 +9,11 @@ import { i18n } from '@kbn/i18n'; import React, { FunctionComponent, memo } from 'react'; import { EuiIconTip } from '@elastic/eui'; +import { Phases } from '../../../../../../../common/types'; import { useFormErrorsContext } from '../../../form'; interface Props { - phase: 'hot' | 'warm' | 'cold'; + phase: string & keyof Phases; } const i18nTexts = { diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/forcemerge_field.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/forcemerge_field.tsx index 2d5f5babe1e2a..bbdcbbf4759ef 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/forcemerge_field.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/forcemerge_field.tsx @@ -8,7 +8,9 @@ import React, { useMemo } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; -import { CheckBoxField, NumericField } from '../../../../../../shared_imports'; +import uuid from 'uuid'; +import { EuiCheckbox, EuiSpacer, EuiFlexGroup, EuiFlexItem, EuiIconTip } from '@elastic/eui'; +import { NumericField } from '../../../../../../shared_imports'; import { i18nTexts } from '../../../i18n_texts'; @@ -67,16 +69,29 @@ export const ForcemergeField: React.FunctionComponent = ({ phase }) => { }, }} /> - + + + {(field) => ( + + + + + + + + + + )} + ); }; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/min_age_field/min_age_field.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/min_age_field/min_age_field.tsx index 60af830356ab9..2f1a058f5a943 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/min_age_field/min_age_field.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/min_age_field/min_age_field.tsx @@ -75,7 +75,12 @@ export const MinAgeField: FunctionComponent = ({ phase }): React.ReactEle const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); return ( - + = ({ phase }) => config={{ defaultValue: cloud?.isCloudEnabled ? CLOUD_DEFAULT_REPO : undefined, - label: i18nTexts.editPolicy.searchableSnapshotsFieldLabel, validations: [ { validator: emptyField( diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/snapshot_policies_field.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/snapshot_policies_field.tsx index 2cbd5cea6165a..f9c973d14b3e2 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/snapshot_policies_field.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/snapshot_policies_field.tsx @@ -10,7 +10,13 @@ import { get } from 'lodash'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import { EuiCallOut, EuiComboBoxOptionOption, EuiLink, EuiSpacer } from '@elastic/eui'; +import { + EuiCallOut, + EuiComboBoxOptionOption, + EuiDescribedFormGroup, + EuiLink, + EuiSpacer, +} from '@elastic/eui'; import { ComboBoxField, useFormData } from '../../../../../../shared_imports'; import { useLoadSnapshotPolicies } from '../../../../../services/api'; @@ -18,7 +24,7 @@ import { useLoadSnapshotPolicies } from '../../../../../services/api'; import { useEditPolicyContext } from '../../../edit_policy_context'; import { UseField } from '../../../form'; -import { FieldLoadingError } from '../../'; +import { FieldLoadingError, LearnMoreLink, OptionalLabel } from '../../'; const waitForSnapshotFormField = 'phases.delete.actions.wait_for_snapshot.policy'; @@ -137,43 +143,78 @@ export const SnapshotPoliciesField: React.FunctionComponent = () => { } return ( - <> - path={waitForSnapshotFormField}> - {(field) => { - const singleSelectionArray: [selectedSnapshot?: string] = field.value - ? [field.value] - : []; + + + + } + description={ + <> + {' '} + + + } + titleSize="xs" + fullWidth + > + <> + + path={waitForSnapshotFormField} + componentProps={{ + label: ( + <> + + + + ), + }} + > + {(field) => { + const singleSelectionArray: [selectedSnapshot?: string] = field.value + ? [field.value] + : []; - return ( - { - field.setValue(newOption); - }, - onChange: (options: EuiComboBoxOptionOption[]) => { - if (options.length > 0) { - field.setValue(options[0].label); - } else { - field.setValue(''); - } - }, - }} - /> - ); - }} - - {calloutContent} - + return ( + { + field.setValue(newOption); + }, + onChange: (options: EuiComboBoxOptionOption[]) => { + if (options.length > 0) { + field.setValue(options[0].label); + } else { + field.setValue(''); + } + }, + }} + /> + ); + }} + + {calloutContent} + + ); }; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/timeline.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/timeline.tsx index 3ebd5935b8d3f..2d83009bd4df4 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/timeline.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/timeline/timeline.tsx @@ -6,15 +6,10 @@ */ import { i18n } from '@kbn/i18n'; + import React, { FunctionComponent, memo } from 'react'; -import { - EuiIcon, - EuiIconProps, - EuiFlexGroup, - EuiFlexItem, - EuiTitle, - EuiIconTip, -} from '@elastic/eui'; + +import { EuiFlexGroup, EuiFlexItem, EuiTitle, EuiIconTip } from '@elastic/eui'; import { PhasesExceptDelete } from '../../../../../../common/types'; @@ -25,15 +20,13 @@ import { AbsoluteTimings, } from '../../lib'; -import './timeline.scss'; -import { InfinityIconSvg } from './infinity_icon.svg'; +import { InfinityIcon } from '../infinity_icon'; + import { TimelinePhaseText } from './components'; const exists = (v: unknown) => v != null; -const InfinityIcon: FunctionComponent> = (props) => ( - -); +import './timeline.scss'; const toPercent = (n: number, total: number) => (n / total) * 100; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx index 749327a2dd441..0c7b5565372a5 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx @@ -239,19 +239,19 @@ export const EditPolicy: React.FunctionComponent = ({ history }) => { - +
+ - + - + - + - + - - - + +
diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/components/form.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/components/form.tsx index 429ae37b76013..be8243cab289f 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/components/form.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/components/form.tsx @@ -11,6 +11,7 @@ import { Form as LibForm, FormHook } from '../../../../../shared_imports'; import { ConfigurationIssuesProvider } from '../configuration_issues_context'; import { FormErrorsProvider } from '../form_errors_context'; +import { PhaseTimingsProvider } from '../phase_timings_context'; interface Props { form: FormHook; @@ -19,7 +20,9 @@ interface Props { export const Form: FunctionComponent = ({ form, children }) => ( - {children} + + {children} + ); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/index.ts index 753148f55db42..734a12a72bd30 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/index.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/index.ts @@ -21,3 +21,9 @@ export { } from './configuration_issues_context'; export { FormErrorsProvider, useFormErrorsContext } from './form_errors_context'; + +export { + PhaseTimingsProvider, + usePhaseTimings, + PhaseTimingConfiguration, +} from './phase_timings_context'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/phase_timings_context.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/phase_timings_context.tsx new file mode 100644 index 0000000000000..92cc8eeead91a --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/phase_timings_context.tsx @@ -0,0 +1,73 @@ +/* + * 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, { createContext, FunctionComponent, useContext } from 'react'; +import { useFormData } from '../../../../shared_imports'; +import { FormInternal } from '../types'; +import { UseField } from './index'; + +export interface PhaseTimingConfiguration { + /** + * Whether this is the final, non-delete, phase. + */ + isFinalDataPhase: boolean; +} + +const getPhaseTimingConfiguration = ( + formData: FormInternal +): { + hot: PhaseTimingConfiguration; + warm: PhaseTimingConfiguration; + cold: PhaseTimingConfiguration; +} => { + const isWarmPhaseEnabled = formData?._meta?.warm?.enabled; + const isColdPhaseEnabled = formData?._meta?.cold?.enabled; + return { + hot: { isFinalDataPhase: !isWarmPhaseEnabled && !isColdPhaseEnabled }, + warm: { isFinalDataPhase: isWarmPhaseEnabled && !isColdPhaseEnabled }, + cold: { isFinalDataPhase: isColdPhaseEnabled }, + }; +}; +export interface PhaseTimings { + hot: PhaseTimingConfiguration; + warm: PhaseTimingConfiguration; + cold: PhaseTimingConfiguration; + isDeletePhaseEnabled: boolean; + setDeletePhaseEnabled: (enabled: boolean) => void; +} + +const PhaseTimingsContext = createContext(null as any); + +export const PhaseTimingsProvider: FunctionComponent = ({ children }) => { + const [formData] = useFormData({ + watch: ['_meta.warm.enabled', '_meta.cold.enabled', '_meta.delete.enabled'], + }); + + return ( + + {(field) => { + return ( + + {children} + + ); + }} + + ); +}; +export const usePhaseTimings = () => { + const ctx = useContext(PhaseTimingsContext); + if (!ctx) throw new Error('Cannot use phase timings outside of phase timings context'); + + return ctx; +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/schema.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/schema.ts index ee84be231f4cc..600a660657863 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/schema.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/schema.ts @@ -70,7 +70,7 @@ export const schema: FormSchema = { ), }, minAgeUnit: { - defaultValue: 'ms', + defaultValue: 'd', }, bestCompression: { label: i18nTexts.editPolicy.bestCompressionFieldLabel, @@ -361,6 +361,18 @@ export const schema: FormSchema = { }, ], }, + actions: { + wait_for_snapshot: { + policy: { + label: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.waitForSnapshot.snapshotPolicyFieldLabel', + { + defaultMessage: 'Policy name (optional)', + } + ), + }, + }, + }, }, }, }; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/i18n_texts.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/i18n_texts.ts index 55af738d7d7ae..5deba8607cd52 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/i18n_texts.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/i18n_texts.ts @@ -188,6 +188,9 @@ export const i18nTexts = { cold: i18n.translate('xpack.indexLifecycleMgmt.editPolicy.coldPhase.coldPhaseTitle', { defaultMessage: 'Cold phase', }), + delete: i18n.translate('xpack.indexLifecycleMgmt.editPolicy.deletePhase.deletePhaseTitle', { + defaultMessage: 'Delete Data', + }), }, descriptions: { hot: i18n.translate('xpack.indexLifecycleMgmt.editPolicy.hotPhase.hotPhaseDescription', { @@ -202,6 +205,13 @@ export const i18nTexts = { defaultMessage: 'You are querying your index less frequently, so you can allocate shards on significantly less performant hardware. Because your queries are slower, you can reduce the number of replicas.', }), + delete: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.deletePhase.deletePhaseDescription', + { + defaultMessage: + 'You no longer need your index. You can define when it is safe to delete it.', + } + ), }, }, }; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/lib/absolute_timing_to_relative_timing.test.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/lib/absolute_timing_to_relative_timing.test.ts index 9f96bbfb25c72..7ec20cc2a5966 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/lib/absolute_timing_to_relative_timing.test.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/lib/absolute_timing_to_relative_timing.test.ts @@ -289,7 +289,7 @@ describe('Conversion of absolute policy timing to relative timing', () => { }, }) ) - ).toEqual({ total: 'Forever', hot: 'Forever', warm: undefined, cold: undefined }); + ).toEqual({ total: 'forever', hot: 'forever', warm: undefined, cold: undefined }); }); test('hot, then always warm', () => { @@ -308,7 +308,7 @@ describe('Conversion of absolute policy timing to relative timing', () => { }, }) ) - ).toEqual({ total: 'Forever', hot: 'Less than a day', warm: 'Forever', cold: undefined }); + ).toEqual({ total: 'forever', hot: 'less than a day', warm: 'forever', cold: undefined }); }); test('hot, then warm, then always cold', () => { @@ -333,10 +333,10 @@ describe('Conversion of absolute policy timing to relative timing', () => { }) ) ).toEqual({ - total: 'Forever', + total: 'forever', hot: '30 days', warm: '4 days', - cold: 'Forever', + cold: 'forever', }); }); @@ -357,7 +357,7 @@ describe('Conversion of absolute policy timing to relative timing', () => { }, }) ) - ).toEqual({ total: 'Forever', hot: '34 days', warm: undefined, cold: 'Forever' }); + ).toEqual({ total: 'forever', hot: '34 days', warm: undefined, cold: 'forever' }); }); }); @@ -445,7 +445,7 @@ describe('Conversion of absolute policy timing to relative timing', () => { total: '61 days', hot: '24 days', warm: '37 days', - cold: 'Less than a day', + cold: 'less than a day', }); }); @@ -474,7 +474,7 @@ describe('Conversion of absolute policy timing to relative timing', () => { total: '61 days', hot: '61 days', warm: undefined, - cold: 'Less than a day', + cold: 'less than a day', }); }); @@ -506,8 +506,8 @@ describe('Conversion of absolute policy timing to relative timing', () => { ).toEqual({ total: '61 days', hot: '61 days', - warm: 'Less than a day', - cold: 'Less than a day', + warm: 'less than a day', + cold: 'less than a day', }); }); }); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/lib/absolute_timing_to_relative_timing.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/lib/absolute_timing_to_relative_timing.ts index 10c26702e81f1..73ff8c76b9233 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/lib/absolute_timing_to_relative_timing.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/lib/absolute_timing_to_relative_timing.ts @@ -35,11 +35,11 @@ type MinAgePhase = 'warm' | 'cold' | 'delete'; type Phase = 'hot' | MinAgePhase; const i18nTexts = { - forever: i18n.translate('xpack.indexLifecycleMgmt.relativeTiming.Forever', { - defaultMessage: 'Forever', + forever: i18n.translate('xpack.indexLifecycleMgmt.relativeTiming.forever', { + defaultMessage: 'forever', }), lessThanADay: i18n.translate('xpack.indexLifecycleMgmt.relativeTiming.lessThanADay', { - defaultMessage: 'Less than a day', + defaultMessage: 'less than a day', }), day: i18n.translate('xpack.indexLifecycleMgmt.relativeTiming.day', { defaultMessage: 'day', diff --git a/x-pack/plugins/maps/kibana.json b/x-pack/plugins/maps/kibana.json index 744cc18c36f3e..3966af9e28742 100644 --- a/x-pack/plugins/maps/kibana.json +++ b/x-pack/plugins/maps/kibana.json @@ -25,7 +25,8 @@ ], "optionalPlugins": [ "home", - "savedObjectsTagging" + "savedObjectsTagging", + "charts" ], "ui": true, "server": true, diff --git a/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts b/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts index efd022292f90b..5d4b915c4e971 100644 --- a/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts +++ b/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts @@ -169,6 +169,7 @@ function getClusterStyleDescriptor( } export interface BlendedVectorLayerArguments { + chartsPaletteServiceGetColor?: (value: string) => string | null; source: IVectorSource; layerDescriptor: VectorLayerDescriptor; } @@ -205,7 +206,12 @@ export class BlendedVectorLayer extends VectorLayer implements IVectorLayer { this._documentStyle, this._clusterSource ); - this._clusterStyle = new VectorStyle(clusterStyleDescriptor, this._clusterSource, this); + this._clusterStyle = new VectorStyle( + clusterStyleDescriptor, + this._clusterSource, + this, + options.chartsPaletteServiceGetColor + ); let isClustered = false; const countDataRequest = this.getDataRequest(ACTIVE_COUNT_DATA_ID); diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.tsx b/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.tsx index ee1cda6eaee43..e9c0cb29c7c17 100644 --- a/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.tsx +++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/vector_layer.tsx @@ -81,6 +81,7 @@ export interface VectorLayerArguments { source: IVectorSource; joins?: InnerJoin[]; layerDescriptor: VectorLayerDescriptor; + chartsPaletteServiceGetColor?: (value: string) => string | null; } export interface IVectorLayer extends ILayer { @@ -119,13 +120,23 @@ export class VectorLayer extends AbstractLayer { return layerDescriptor as VectorLayerDescriptor; } - constructor({ layerDescriptor, source, joins = [] }: VectorLayerArguments) { + constructor({ + layerDescriptor, + source, + joins = [], + chartsPaletteServiceGetColor, + }: VectorLayerArguments) { super({ layerDescriptor, source, }); this._joins = joins; - this._style = new VectorStyle(layerDescriptor.style, source, this); + this._style = new VectorStyle( + layerDescriptor.style, + source, + this, + chartsPaletteServiceGetColor + ); } getSource(): IVectorSource { diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_color_property.tsx b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_color_property.tsx index cac56ad1c8a57..d654cdc6bff51 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_color_property.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_color_property.tsx @@ -16,7 +16,12 @@ import { getPercentilesMbColorRampStops, getColorPalette, } from '../../color_palettes'; -import { COLOR_MAP_TYPE, DATA_MAPPING_FUNCTION } from '../../../../../common/constants'; +import { + COLOR_MAP_TYPE, + DATA_MAPPING_FUNCTION, + FieldFormatter, + VECTOR_STYLES, +} from '../../../../../common/constants'; import { isCategoricalStopsInvalid, getOtherCategoryLabel, @@ -26,6 +31,8 @@ import { Break, BreakedLegend } from '../components/legend/breaked_legend'; import { ColorDynamicOptions, OrdinalColorStop } from '../../../../../common/descriptor_types'; import { LegendProps } from './style_property'; import { getOrdinalSuffix } from '../../../util/ordinal_suffix'; +import { IField } from '../../../fields/field'; +import { IVectorLayer } from '../../../layers/vector_layer/vector_layer'; const UP_TO = i18n.translate('xpack.maps.legend.upto', { defaultMessage: 'up to', @@ -34,6 +41,20 @@ const EMPTY_STOPS = { stops: [], defaultColor: null }; const RGBA_0000 = 'rgba(0,0,0,0)'; export class DynamicColorProperty extends DynamicStyleProperty { + private readonly _chartsPaletteServiceGetColor?: (value: string) => string | null; + + constructor( + options: ColorDynamicOptions, + styleName: VECTOR_STYLES, + field: IField | null, + vectorLayer: IVectorLayer, + getFieldFormatter: (fieldName: string) => null | FieldFormatter, + chartsPaletteServiceGetColor?: (value: string) => string | null + ) { + super(options, styleName, field, vectorLayer, getFieldFormatter); + this._chartsPaletteServiceGetColor = chartsPaletteServiceGetColor; + } + syncCircleColorWithMb(mbLayerId: string, mbMap: MbMap, alpha: number) { const color = this._getMbColor(); mbMap.setPaintProperty(mbLayerId, 'circle-color', color); @@ -260,12 +281,16 @@ export class DynamicColorProperty extends DynamicStyleProperty { - if (stop !== null) { + stops.forEach(({ stop, color }: { stop: string | number | null; color: string | null }) => { + if (stop !== null && color != null) { breaks.push({ color, symbolId, diff --git a/x-pack/plugins/maps/public/classes/styles/vector/vector_style.tsx b/x-pack/plugins/maps/public/classes/styles/vector/vector_style.tsx index cef5f5048e9af..c61e72807224a 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/vector_style.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/vector_style.tsx @@ -178,7 +178,8 @@ export class VectorStyle implements IVectorStyle { constructor( descriptor: VectorStyleDescriptor | null, source: IVectorSource, - layer: IVectorLayer + layer: IVectorLayer, + chartsPaletteServiceGetColor?: (value: string) => string | null ) { this._source = source; this._layer = layer; @@ -197,11 +198,13 @@ export class VectorStyle implements IVectorStyle { ); this._lineColorStyleProperty = this._makeColorProperty( this._descriptor.properties[VECTOR_STYLES.LINE_COLOR], - VECTOR_STYLES.LINE_COLOR + VECTOR_STYLES.LINE_COLOR, + chartsPaletteServiceGetColor ); this._fillColorStyleProperty = this._makeColorProperty( this._descriptor.properties[VECTOR_STYLES.FILL_COLOR], - VECTOR_STYLES.FILL_COLOR + VECTOR_STYLES.FILL_COLOR, + chartsPaletteServiceGetColor ); this._lineWidthStyleProperty = this._makeSizeProperty( this._descriptor.properties[VECTOR_STYLES.LINE_WIDTH], @@ -230,11 +233,13 @@ export class VectorStyle implements IVectorStyle { ); this._labelColorStyleProperty = this._makeColorProperty( this._descriptor.properties[VECTOR_STYLES.LABEL_COLOR], - VECTOR_STYLES.LABEL_COLOR + VECTOR_STYLES.LABEL_COLOR, + chartsPaletteServiceGetColor ); this._labelBorderColorStyleProperty = this._makeColorProperty( this._descriptor.properties[VECTOR_STYLES.LABEL_BORDER_COLOR], - VECTOR_STYLES.LABEL_BORDER_COLOR + VECTOR_STYLES.LABEL_BORDER_COLOR, + chartsPaletteServiceGetColor ); this._labelBorderSizeStyleProperty = new LabelBorderSizeProperty( this._descriptor.properties[VECTOR_STYLES.LABEL_BORDER_SIZE].options, @@ -890,7 +895,8 @@ export class VectorStyle implements IVectorStyle { _makeColorProperty( descriptor: ColorStylePropertyDescriptor | undefined, - styleName: VECTOR_STYLES + styleName: VECTOR_STYLES, + chartsPaletteServiceGetColor?: (value: string) => string | null ) { if (!descriptor || !descriptor.options) { return new StaticColorProperty({ color: '' }, styleName); @@ -904,7 +910,8 @@ export class VectorStyle implements IVectorStyle { styleName, field, this._layer, - this._getFieldFormatter + this._getFieldFormatter, + chartsPaletteServiceGetColor ); } else { throw new Error(`${descriptor} not implemented`); diff --git a/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx b/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx index a1d65bf08c458..b769ac489f565 100644 --- a/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx +++ b/x-pack/plugins/maps/public/embeddable/map_embeddable.tsx @@ -37,6 +37,7 @@ import { import { getIsLayerTOCOpen, getOpenTOCDetails } from '../selectors/ui_selectors'; import { getInspectorAdapters, + setChartsPaletteServiceGetColor, setEventHandlers, EventHandlers, } from '../reducers/non_serializable_instances'; @@ -54,7 +55,12 @@ import { RawValue, } from '../../common/constants'; import { RenderToolTipContent } from '../classes/tooltips/tooltip_property'; -import { getUiActions, getCoreI18n, getHttp } from '../kibana_services'; +import { + getUiActions, + getCoreI18n, + getHttp, + getChartsPaletteServiceGetColor, +} from '../kibana_services'; import { LayerDescriptor } from '../../common/descriptor_types'; import { MapContainer } from '../connected_components/map_container'; import { SavedMap } from '../routes/map_page'; @@ -83,6 +89,7 @@ export class MapEmbeddable private _prevQuery?: Query; private _prevRefreshConfig?: RefreshInterval; private _prevFilters?: Filter[]; + private _prevSyncColors?: boolean; private _prevSearchSessionId?: string; private _domNode?: HTMLElement; private _unsubscribeFromStore?: Unsubscribe; @@ -126,6 +133,8 @@ export class MapEmbeddable } private _initializeStore() { + this._dispatchSetChartsPaletteServiceGetColor(this.input.syncColors); + const store = this._savedMap.getStore(); store.dispatch(setReadOnly(true)); store.dispatch(disableScrollZoom()); @@ -221,6 +230,10 @@ export class MapEmbeddable if (this.input.refreshConfig && !_.isEqual(this.input.refreshConfig, this._prevRefreshConfig)) { this._dispatchSetRefreshConfig(this.input.refreshConfig); } + + if (this.input.syncColors !== this._prevSyncColors) { + this._dispatchSetChartsPaletteServiceGetColor(this.input.syncColors); + } } _dispatchSetQuery({ @@ -261,6 +274,19 @@ export class MapEmbeddable ); } + async _dispatchSetChartsPaletteServiceGetColor(syncColors?: boolean) { + this._prevSyncColors = syncColors; + const chartsPaletteServiceGetColor = syncColors + ? await getChartsPaletteServiceGetColor() + : null; + if (syncColors !== this._prevSyncColors) { + return; + } + this._savedMap + .getStore() + .dispatch(setChartsPaletteServiceGetColor(chartsPaletteServiceGetColor)); + } + /** * * @param {HTMLElement} domNode diff --git a/x-pack/plugins/maps/public/kibana_services.ts b/x-pack/plugins/maps/public/kibana_services.ts index 632a5f5382f73..4a7bccb31380d 100644 --- a/x-pack/plugins/maps/public/kibana_services.ts +++ b/x-pack/plugins/maps/public/kibana_services.ts @@ -11,6 +11,7 @@ import { MapsLegacyConfig } from '../../../../src/plugins/maps_legacy/config'; import { MapsConfigType } from '../config'; import { MapsPluginStartDependencies } from './plugin'; import { EMSSettings } from '../common/ems_settings'; +import { PaletteRegistry } from '../../../../src/plugins/charts/public'; let kibanaVersion: string; export const setKibanaVersion = (version: string) => (kibanaVersion = version); @@ -83,3 +84,22 @@ export const getShareService = () => pluginsStart.share; export const getIsAllowByValueEmbeddables = () => pluginsStart.dashboard.dashboardFeatureFlagConfig.allowByValueEmbeddables; + +export async function getChartsPaletteServiceGetColor(): Promise< + ((value: string) => string) | null +> { + const paletteRegistry: PaletteRegistry | null = pluginsStart.charts + ? await pluginsStart.charts.palettes.getPalettes() + : null; + if (!paletteRegistry) { + return null; + } + + const paletteDefinition = paletteRegistry.get('default'); + const chartConfiguration = { syncColors: true }; + return (value: string) => { + const series = [{ name: value, rankAtDepth: 0, totalSeriesAtDepth: 1 }]; + const color = paletteDefinition.getColor(series, chartConfiguration); + return color ? color : '#3d3d3d'; + }; +} diff --git a/x-pack/plugins/maps/public/plugin.ts b/x-pack/plugins/maps/public/plugin.ts index 8889d1d44f10f..4c668e0a2276b 100644 --- a/x-pack/plugins/maps/public/plugin.ts +++ b/x-pack/plugins/maps/public/plugin.ts @@ -64,6 +64,7 @@ import { } from './licensed_features'; import { EMSSettings } from '../common/ems_settings'; import { SavedObjectTaggingPluginStart } from '../../saved_objects_tagging/public'; +import { ChartsPluginStart } from '../../../../src/plugins/charts/public'; export interface MapsPluginSetupDependencies { inspector: InspectorSetupContract; @@ -76,6 +77,7 @@ export interface MapsPluginSetupDependencies { } export interface MapsPluginStartDependencies { + charts: ChartsPluginStart; data: DataPublicPluginStart; embeddable: EmbeddableStart; mapsFileUpload: FileUploadStartContract; diff --git a/x-pack/plugins/maps/public/reducers/non_serializable_instances.d.ts b/x-pack/plugins/maps/public/reducers/non_serializable_instances.d.ts index 54a90946a5a89..9808a5e09b8ab 100644 --- a/x-pack/plugins/maps/public/reducers/non_serializable_instances.d.ts +++ b/x-pack/plugins/maps/public/reducers/non_serializable_instances.d.ts @@ -15,6 +15,7 @@ export type NonSerializableState = { inspectorAdapters: Adapters; cancelRequestCallbacks: Map {}>; // key is request token, value is cancel callback eventHandlers: Partial; + chartsPaletteServiceGetColor: (value: string) => string | null; }; export interface ResultMeta { @@ -58,6 +59,14 @@ export function getInspectorAdapters(state: MapStoreState): Adapters; export function getEventHandlers(state: MapStoreState): Partial; +export function getChartsPaletteServiceGetColor( + state: MapStoreState +): (value: string) => string | null; + +export function setChartsPaletteServiceGetColor( + chartsPaletteServiceGetColor: ((value: string) => string) | null +): AnyAction; + export function cancelRequest(requestToken?: symbol): AnyAction; export function registerCancelCallback(requestToken: symbol, callback: () => void): AnyAction; diff --git a/x-pack/plugins/maps/public/reducers/non_serializable_instances.js b/x-pack/plugins/maps/public/reducers/non_serializable_instances.js index 46846a8df3f23..4cc4e91a308a5 100644 --- a/x-pack/plugins/maps/public/reducers/non_serializable_instances.js +++ b/x-pack/plugins/maps/public/reducers/non_serializable_instances.js @@ -12,6 +12,7 @@ import { getShowMapsInspectorAdapter } from '../kibana_services'; const REGISTER_CANCEL_CALLBACK = 'REGISTER_CANCEL_CALLBACK'; const UNREGISTER_CANCEL_CALLBACK = 'UNREGISTER_CANCEL_CALLBACK'; const SET_EVENT_HANDLERS = 'SET_EVENT_HANDLERS'; +const SET_CHARTS_PALETTE_SERVICE_GET_COLOR = 'SET_CHARTS_PALETTE_SERVICE_GET_COLOR'; function createInspectorAdapters() { const inspectorAdapters = { @@ -30,6 +31,7 @@ export function nonSerializableInstances(state, action = {}) { inspectorAdapters: createInspectorAdapters(), cancelRequestCallbacks: new Map(), // key is request token, value is cancel callback eventHandlers: {}, + chartsPaletteServiceGetColor: null, }; } @@ -50,6 +52,12 @@ export function nonSerializableInstances(state, action = {}) { eventHandlers: action.eventHandlers, }; } + case SET_CHARTS_PALETTE_SERVICE_GET_COLOR: { + return { + ...state, + chartsPaletteServiceGetColor: action.chartsPaletteServiceGetColor, + }; + } default: return state; } @@ -68,6 +76,11 @@ export const getEventHandlers = ({ nonSerializableInstances }) => { return nonSerializableInstances.eventHandlers; }; +export function getChartsPaletteServiceGetColor({ nonSerializableInstances }) { + console.log('getChartsPaletteServiceGetColor', nonSerializableInstances); + return nonSerializableInstances.chartsPaletteServiceGetColor; +} + // Actions export const registerCancelCallback = (requestToken, callback) => { return { @@ -104,3 +117,10 @@ export const setEventHandlers = (eventHandlers = {}) => { eventHandlers, }; }; + +export function setChartsPaletteServiceGetColor(chartsPaletteServiceGetColor) { + return { + type: SET_CHARTS_PALETTE_SERVICE_GET_COLOR, + chartsPaletteServiceGetColor, + }; +} diff --git a/x-pack/plugins/maps/public/reducers/store.js b/x-pack/plugins/maps/public/reducers/store.js index 3c9b5d1b98e29..4e355add59fee 100644 --- a/x-pack/plugins/maps/public/reducers/store.js +++ b/x-pack/plugins/maps/public/reducers/store.js @@ -15,6 +15,7 @@ import { MAP_DESTROYED } from '../actions'; export const DEFAULT_MAP_STORE_STATE = { ui: { ...DEFAULT_MAP_UI_STATE }, map: { ...DEFAULT_MAP_STATE }, + nonSerializableInstances: {}, }; export function createMapStore() { diff --git a/x-pack/plugins/maps/public/selectors/map_selectors.test.ts b/x-pack/plugins/maps/public/selectors/map_selectors.test.ts index eb11ee61d9deb..dd6a9fc377e5b 100644 --- a/x-pack/plugins/maps/public/selectors/map_selectors.test.ts +++ b/x-pack/plugins/maps/public/selectors/map_selectors.test.ts @@ -11,11 +11,6 @@ jest.mock('../classes/layers/blended_vector_layer/blended_vector_layer', () => { jest.mock('../classes/layers/heatmap_layer/heatmap_layer', () => {}); jest.mock('../classes/layers/vector_tile_layer/vector_tile_layer', () => {}); jest.mock('../classes/joins/inner_join', () => {}); -jest.mock('../reducers/non_serializable_instances', () => ({ - getInspectorAdapters: () => { - return {}; - }, -})); jest.mock('../kibana_services', () => ({ getTimeFilter: () => ({ getTime: () => { diff --git a/x-pack/plugins/maps/public/selectors/map_selectors.ts b/x-pack/plugins/maps/public/selectors/map_selectors.ts index 34af789f6834f..27281fe17f0fa 100644 --- a/x-pack/plugins/maps/public/selectors/map_selectors.ts +++ b/x-pack/plugins/maps/public/selectors/map_selectors.ts @@ -18,7 +18,10 @@ import { VectorStyle } from '../classes/styles/vector/vector_style'; import { HeatmapLayer } from '../classes/layers/heatmap_layer/heatmap_layer'; import { BlendedVectorLayer } from '../classes/layers/blended_vector_layer/blended_vector_layer'; import { getTimeFilter } from '../kibana_services'; -import { getInspectorAdapters } from '../reducers/non_serializable_instances'; +import { + getChartsPaletteServiceGetColor, + getInspectorAdapters, +} from '../reducers/non_serializable_instances'; import { TiledVectorLayer } from '../classes/layers/tiled_vector_layer/tiled_vector_layer'; import { copyPersistentState, TRACKED_LAYER_DESCRIPTOR } from '../reducers/util'; import { InnerJoin } from '../classes/joins/inner_join'; @@ -55,7 +58,8 @@ import { ILayer } from '../classes/layers/layer'; export function createLayerInstance( layerDescriptor: LayerDescriptor, - inspectorAdapters?: Adapters + inspectorAdapters?: Adapters, + chartsPaletteServiceGetColor?: (value: string) => string | null ): ILayer { const source: ISource = createSourceInstance(layerDescriptor.sourceDescriptor, inspectorAdapters); @@ -75,6 +79,7 @@ export function createLayerInstance( layerDescriptor: vectorLayerDescriptor, source: source as IVectorSource, joins, + chartsPaletteServiceGetColor, }); case VectorTileLayer.type: return new VectorTileLayer({ layerDescriptor, source: source as ITMSSource }); @@ -84,6 +89,7 @@ export function createLayerInstance( return new BlendedVectorLayer({ layerDescriptor: layerDescriptor as VectorLayerDescriptor, source: source as IVectorSource, + chartsPaletteServiceGetColor, }); case TiledVectorLayer.type: return new TiledVectorLayer({ @@ -295,9 +301,10 @@ export const getSpatialFiltersLayer = createSelector( export const getLayerList = createSelector( getLayerListRaw, getInspectorAdapters, - (layerDescriptorList, inspectorAdapters) => { + getChartsPaletteServiceGetColor, + (layerDescriptorList, inspectorAdapters, chartsPaletteServiceGetColor) => { return layerDescriptorList.map((layerDescriptor) => - createLayerInstance(layerDescriptor, inspectorAdapters) + createLayerInstance(layerDescriptor, inspectorAdapters, chartsPaletteServiceGetColor) ); } ); diff --git a/x-pack/plugins/security_solution/cypress/integration/cases/connector_options.spec.ts b/x-pack/plugins/security_solution/cypress/integration/cases/connector_options.spec.ts index 1380cfd9fca98..95b555c2acae6 100644 --- a/x-pack/plugins/security_solution/cypress/integration/cases/connector_options.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/cases/connector_options.spec.ts @@ -31,6 +31,13 @@ describe('Cases connector incident fields', () => { beforeEach(() => { cleanKibana(); cy.intercept('GET', '/api/cases/configure/connectors/_find', mockConnectorsResponse); + cy.intercept('POST', `/api/actions/action/${connectorIds.sn}/_execute`, (req) => { + const response = + req.body.params.subAction === 'getChoices' + ? executeResponses.servicenow.choices + : { status: 'ok', data: [] }; + req.reply(response); + }); cy.intercept('POST', `/api/actions/action/${connectorIds.jira}/_execute`, (req) => { const response = req.body.params.subAction === 'issueTypes' diff --git a/x-pack/plugins/security_solution/cypress/integration/detection_rules/indicator_match_rule.spec.ts b/x-pack/plugins/security_solution/cypress/integration/detection_rules/indicator_match_rule.spec.ts index 98e6dad350ea7..a69f808001800 100644 --- a/x-pack/plugins/security_solution/cypress/integration/detection_rules/indicator_match_rule.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/detection_rules/indicator_match_rule.spec.ts @@ -96,7 +96,7 @@ import { import { esArchiverLoad, esArchiverUnload } from '../../tasks/es_archiver'; import { loginAndWaitForPageWithoutDateRange } from '../../tasks/login'; -import { DETECTIONS_URL } from '../../urls/navigation'; +import { DETECTIONS_URL, RULE_CREATION } from '../../urls/navigation'; describe('Detection rules, Indicator Match', () => { const expectedUrls = newThreatIndicatorRule.referenceUrls.join(''); @@ -106,25 +106,22 @@ describe('Detection rules, Indicator Match', () => { const expectedNumberOfRules = 1; const expectedNumberOfAlerts = 1; - beforeEach(() => { + before(() => { cleanKibana(); esArchiverLoad('threat_indicator'); esArchiverLoad('threat_data'); - loginAndWaitForPageWithoutDateRange(DETECTIONS_URL); - waitForAlertsPanelToBeLoaded(); - waitForAlertsIndexToBeCreated(); - goToManageAlertsDetectionRules(); - waitForLoadElasticPrebuiltDetectionRulesTableToBeLoaded(); - goToCreateNewRule(); - selectIndicatorMatchType(); }); - - afterEach(() => { + after(() => { esArchiverUnload('threat_indicator'); esArchiverUnload('threat_data'); }); describe('Creating new indicator match rules', () => { + beforeEach(() => { + loginAndWaitForPageWithoutDateRange(RULE_CREATION); + selectIndicatorMatchType(); + }); + describe('Index patterns', () => { it('Contains a predefined index pattern', () => { getIndicatorIndex().should('have.text', indexPatterns.join('')); @@ -355,6 +352,19 @@ describe('Detection rules, Indicator Match', () => { getIndicatorMappingComboField(2).should('not.exist'); }); }); + }); + + describe('Generating signals', () => { + beforeEach(() => { + cleanKibana(); + loginAndWaitForPageWithoutDateRange(DETECTIONS_URL); + waitForAlertsPanelToBeLoaded(); + waitForAlertsIndexToBeCreated(); + goToManageAlertsDetectionRules(); + waitForLoadElasticPrebuiltDetectionRulesTableToBeLoaded(); + goToCreateNewRule(); + selectIndicatorMatchType(); + }); it('Creates and activates a new Indicator Match rule', () => { fillDefineIndicatorMatchRuleAndContinue(newThreatIndicatorRule); diff --git a/x-pack/plugins/security_solution/cypress/objects/case.ts b/x-pack/plugins/security_solution/cypress/objects/case.ts index b6c73cd37140c..7a3ce2cb00dfa 100644 --- a/x-pack/plugins/security_solution/cypress/objects/case.ts +++ b/x-pack/plugins/security_solution/cypress/objects/case.ts @@ -113,6 +113,77 @@ export const mockConnectorsResponse = [ }, ]; export const executeResponses = { + servicenow: { + choices: { + status: 'ok', + data: [ + { + dependent_value: '', + label: 'Priviledge Escalation', + value: 'Priviledge Escalation', + element: 'category', + }, + { + dependent_value: '', + label: 'Criminal activity/investigation', + value: 'Criminal activity/investigation', + element: 'category', + }, + { + dependent_value: '', + label: 'Denial of Service', + value: 'Denial of Service', + element: 'category', + }, + { + dependent_value: 'Denial of Service', + label: 'Inbound or outbound', + value: '12', + element: 'subcategory', + }, + { + dependent_value: 'Denial of Service', + label: 'Single or distributed (DoS or DDoS)', + value: '26', + element: 'subcategory', + }, + { + dependent_value: 'Denial of Service', + label: 'Inbound DDos', + value: 'inbound_ddos', + element: 'subcategory', + }, + ...['severity', 'urgency', 'impact', 'priority'] + .map((element) => [ + { + dependent_value: '', + label: '1 - Critical', + value: '1', + element, + }, + { + dependent_value: '', + label: '2 - High', + value: '2', + element, + }, + { + dependent_value: '', + label: '3 - Moderate', + value: '3', + element, + }, + { + dependent_value: '', + label: '4 - Low', + value: '4', + element, + }, + ]) + .flat(), + ], + }, + }, jira: { issueTypes: { status: 'ok', diff --git a/x-pack/plugins/security_solution/cypress/screens/case_details.ts b/x-pack/plugins/security_solution/cypress/screens/case_details.ts index 9ca7a99f9df16..ef8f45b222dd0 100644 --- a/x-pack/plugins/security_solution/cypress/screens/case_details.ts +++ b/x-pack/plugins/security_solution/cypress/screens/case_details.ts @@ -30,9 +30,9 @@ export const CASE_DETAILS_USER_ACTION_DESCRIPTION_USERNAME = export const CASE_DETAILS_USERNAMES = '[data-test-subj="case-view-username"]'; -export const CONNECTOR_CARD_DETAILS = '[data-test-subj="settings-connector-card"]'; +export const CONNECTOR_CARD_DETAILS = '[data-test-subj="connector-card"]'; -export const CONNECTOR_TITLE = '[data-test-subj="settings-connector-card"] span.euiTitle'; +export const CONNECTOR_TITLE = '[data-test-subj="connector-card"] span.euiTitle'; export const DELETE_CASE_BTN = '[data-test-subj="property-actions-trash"]'; diff --git a/x-pack/plugins/security_solution/cypress/screens/edit_connector.ts b/x-pack/plugins/security_solution/cypress/screens/edit_connector.ts index b25b8c11ff830..5b353983e5a92 100644 --- a/x-pack/plugins/security_solution/cypress/screens/edit_connector.ts +++ b/x-pack/plugins/security_solution/cypress/screens/edit_connector.ts @@ -7,7 +7,7 @@ import { connectorIds } from '../objects/case'; -export const CONNECTOR_RESILIENT = `[data-test-subj="connector-settings-resilient"]`; +export const CONNECTOR_RESILIENT = `[data-test-subj="connector-fields-resilient"]`; export const CONNECTOR_SELECTOR = '[data-test-subj="dropdown-connectors"]'; diff --git a/x-pack/plugins/security_solution/cypress/urls/navigation.ts b/x-pack/plugins/security_solution/cypress/urls/navigation.ts index f3881ab624f7b..2beed9e8ec0b7 100644 --- a/x-pack/plugins/security_solution/cypress/urls/navigation.ts +++ b/x-pack/plugins/security_solution/cypress/urls/navigation.ts @@ -24,5 +24,6 @@ export const KIBANA_HOME = '/app/home#/'; export const ADMINISTRATION_URL = '/app/security/administration'; export const NETWORK_URL = '/app/security/network'; export const OVERVIEW_URL = '/app/security/overview'; +export const RULE_CREATION = 'app/security/detections/rules/create'; export const TIMELINES_URL = '/app/security/timelines'; export const TIMELINE_TEMPLATES_URL = '/app/security/timelines/template'; diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx index 511bc682e5504..e74b66eeeb9f0 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx @@ -107,7 +107,7 @@ describe('CaseView ', () => { const fetchCaseUserActions = jest.fn(); const fetchCase = jest.fn(); const updateCase = jest.fn(); - const postPushToService = jest.fn(); + const pushCaseToExternalService = jest.fn(); const data = caseProps.caseData; const defaultGetCase = { @@ -144,7 +144,10 @@ describe('CaseView ', () => { jest.spyOn(routeData, 'useLocation').mockReturnValue(mockLocation); useGetCaseUserActionsMock.mockImplementation(() => defaultUseGetCaseUserActions); - usePostPushToServiceMock.mockImplementation(() => ({ isLoading: false, postPushToService })); + usePostPushToServiceMock.mockImplementation(() => ({ + isLoading: false, + pushCaseToExternalService, + })); useConnectorsMock.mockImplementation(() => ({ connectors: connectorsMock, loading: false })); useQueryAlertsMock.mockImplementation(() => ({ loading: false, @@ -378,7 +381,7 @@ describe('CaseView ', () => { wrapper.update(); - expect(postPushToService).toHaveBeenCalled(); + expect(pushCaseToExternalService).toHaveBeenCalled(); }); }); @@ -508,7 +511,7 @@ describe('CaseView ', () => { connector: { id: 'servicenow-1', name: 'SN 1', - type: ConnectorTypes.servicenow, + type: ConnectorTypes.serviceNowITSM, fields: null, }, }} @@ -556,7 +559,7 @@ describe('CaseView ', () => { connector: { id: 'servicenow-1', name: 'SN 1', - type: ConnectorTypes.servicenow, + type: ConnectorTypes.serviceNowITSM, fields: null, }, }} diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx b/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx index 2f39a5a2951b2..e690a01dca54b 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx @@ -297,7 +297,6 @@ export const CaseComponent = React.memo( updateCase: handleUpdateCase, userCanCrud, isValidConnector: isLoadingConnectors ? true : isValidConnector, - alerts, }); const onSubmitConnector = useCallback( @@ -397,7 +396,6 @@ export const CaseComponent = React.memo( ); } }, [dispatch]); - return ( <> diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors.test.tsx b/x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors.test.tsx index ef0c7cfcfa2d6..371ff3528f4f0 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/configure_cases/connectors.test.tsx @@ -72,7 +72,7 @@ describe('Connectors', () => { const newWrapper = mount( , { wrappingComponent: TestProviders, @@ -99,7 +99,7 @@ describe('Connectors', () => { const newWrapper = mount( , { wrappingComponent: TestProviders, diff --git a/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.test.tsx index 23cefce1bacd2..8e317d57dd9ac 100644 --- a/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/configure_cases/index.test.tsx @@ -186,14 +186,14 @@ describe('ConfigureCases', () => { connector: { id: 'servicenow-1', name: 'unchanged', - type: ConnectorTypes.servicenow, + type: ConnectorTypes.serviceNowITSM, fields: null, }, currentConfiguration: { connector: { id: 'servicenow-1', name: 'unchanged', - type: ConnectorTypes.servicenow, + type: ConnectorTypes.serviceNowITSM, fields: null, }, closureType: 'close-by-user', @@ -271,7 +271,7 @@ describe('ConfigureCases', () => { connector: { id: 'servicenow-1', name: 'unchanged', - type: ConnectorTypes.servicenow, + type: ConnectorTypes.serviceNowITSM, fields: null, }, closureType: 'close-by-user', @@ -331,7 +331,7 @@ describe('ConfigureCases', () => { connector: { id: 'servicenow-1', name: 'SN', - type: ConnectorTypes.servicenow, + type: ConnectorTypes.serviceNowITSM, fields: null, }, persistLoading: true, @@ -450,7 +450,7 @@ describe('ConfigureCases', () => { connector: { id: 'servicenow-1', name: 'My connector', - type: ConnectorTypes.servicenow, + type: ConnectorTypes.serviceNowITSM, fields: null, }, })) @@ -493,7 +493,7 @@ describe('closure options', () => { connector: { id: 'servicenow-1', name: 'My connector', - type: ConnectorTypes.servicenow, + type: ConnectorTypes.serviceNowITSM, fields: null, }, currentConfiguration: { @@ -522,7 +522,7 @@ describe('closure options', () => { connector: { id: 'servicenow-1', name: 'My connector', - type: ConnectorTypes.servicenow, + type: ConnectorTypes.serviceNowITSM, fields: null, }, closureType: 'close-by-pushing', @@ -546,7 +546,7 @@ describe('user interactions', () => { connector: { id: 'resilient-2', name: 'unchanged', - type: ConnectorTypes.servicenow, + type: ConnectorTypes.serviceNowITSM, fields: null, }, closureType: 'close-by-user', diff --git a/x-pack/plugins/security_solution/public/cases/components/connector_selector/form.tsx b/x-pack/plugins/security_solution/public/cases/components/connector_selector/form.tsx index 0aaac9c30feb9..d5f5530acde9b 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connector_selector/form.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/connector_selector/form.tsx @@ -5,13 +5,13 @@ * 2.0. */ -import React from 'react'; +import React, { useCallback } from 'react'; import { isEmpty } from 'lodash/fp'; import { EuiFormRow } from '@elastic/eui'; import { FieldHook, getFieldValidityAndErrorMessage } from '../../../shared_imports'; import { ConnectorsDropdown } from '../configure_cases/connectors_dropdown'; -import { ActionConnector } from '../../../../../case/common/api/cases'; +import { ActionConnector } from '../../../../../case/common/api'; interface ConnectorSelectorProps { connectors: ActionConnector[]; @@ -21,6 +21,7 @@ interface ConnectorSelectorProps { idAria: string; isEdit: boolean; isLoading: boolean; + handleChange?: (newValue: string) => void; } export const ConnectorSelector = ({ connectors, @@ -30,8 +31,19 @@ export const ConnectorSelector = ({ idAria, isEdit = true, isLoading = false, + handleChange, }: ConnectorSelectorProps) => { const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); + const onChange = useCallback( + (val: string) => { + if (handleChange) { + handleChange(val); + } + field.setValue(val); + }, + [handleChange, field] + ); + return isEdit ? ( diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/card.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/card.tsx similarity index 89% rename from x-pack/plugins/security_solution/public/cases/components/settings/card.tsx rename to x-pack/plugins/security_solution/public/cases/components/connectors/card.tsx index 36679cd2452bd..03f909948370d 100644 --- a/x-pack/plugins/security_solution/public/cases/components/settings/card.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/card.tsx @@ -9,7 +9,7 @@ import React, { memo, useMemo } from 'react'; import { EuiCard, EuiIcon, EuiLoadingSpinner } from '@elastic/eui'; import styled from 'styled-components'; -import { connectorsConfiguration } from '../connectors'; +import { connectorsConfiguration } from '.'; import { ConnectorTypes } from '../../../../../case/common/api/connectors'; interface ConnectorCardProps { @@ -51,10 +51,10 @@ const ConnectorCardDisplay: React.FC = ({ ); return ( <> - {isLoading && } + {isLoading && } {!isLoading && ( ({ config: { errors: {} }, secrets: { errors: {} } }), validateParams, actionConnectorFields: null, - actionParamsFields: lazy(() => import('./fields')), + actionParamsFields: lazy(() => import('./alert_fields')), }; } diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/config.ts b/x-pack/plugins/security_solution/public/cases/components/connectors/config.ts index 7be49720fc075..1d12d4b98a823 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/config.ts +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/config.ts @@ -5,17 +5,35 @@ * 2.0. */ -/* eslint-disable @kbn/eslint/no-restricted-paths */ - import { - ServiceNowITSMConnectorConfiguration, - JiraConnectorConfiguration, - ResilientConnectorConfiguration, + getResilientActionType, + getServiceNowITSMActionType, + getServiceNowSIRActionType, + getJiraActionType, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths } from '../../../../../triggers_actions_ui/public/common'; import { ConnectorConfiguration } from './types'; +const resilient = getResilientActionType(); +const serviceNowITSM = getServiceNowITSMActionType(); +const serviceNowSIR = getServiceNowSIRActionType(); +const jira = getJiraActionType(); + export const connectorsConfiguration: Record = { - '.servicenow': ServiceNowITSMConnectorConfiguration as ConnectorConfiguration, - '.jira': JiraConnectorConfiguration as ConnectorConfiguration, - '.resilient': ResilientConnectorConfiguration as ConnectorConfiguration, + '.servicenow': { + name: serviceNowITSM.actionTypeTitle ?? '', + logo: serviceNowITSM.iconClass, + }, + '.servicenow-sir': { + name: serviceNowSIR.actionTypeTitle ?? '', + logo: serviceNowSIR.iconClass, + }, + '.jira': { + name: jira.actionTypeTitle ?? '', + logo: jira.iconClass, + }, + '.resilient': { + name: resilient.actionTypeTitle ?? '', + logo: resilient.iconClass, + }, }; diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/connectors_registry.ts b/x-pack/plugins/security_solution/public/cases/components/connectors/connectors_registry.ts new file mode 100644 index 0000000000000..d6896a8ac8c80 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/connectors_registry.ts @@ -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. + */ + +import { i18n } from '@kbn/i18n'; +import { CaseConnector, CaseConnectorsRegistry } from './types'; + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +export const createCaseConnectorsRegistry = (): CaseConnectorsRegistry => { + const connectors: Map> = new Map(); + + const registry: CaseConnectorsRegistry = { + has: (id: string) => connectors.has(id), + register: (connector: CaseConnector) => { + if (connectors.has(connector.id)) { + throw new Error( + i18n.translate( + 'xpack.securitySolution.caseConnectorsRegistry.register.duplicateCaseConnectorErrorMessage', + { + defaultMessage: 'Object type "{id}" is already registered.', + values: { + id: connector.id, + }, + } + ) + ); + } + + connectors.set(connector.id, connector); + }, + get: (id: string): CaseConnector => { + if (!connectors.has(id)) { + throw new Error( + i18n.translate( + 'xpack.securitySolution.caseConnectorsRegistry.get.missingCaseConnectorErrorMessage', + { + defaultMessage: 'Object type "{id}" is not registered.', + values: { + id, + }, + } + ) + ); + } + return connectors.get(id)!; + }, + list: () => { + return Array.from(connectors).map(([id, connector]) => connector); + }, + }; + + return registry; +}; diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/fields_form.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/fields_form.tsx similarity index 64% rename from x-pack/plugins/security_solution/public/cases/components/settings/fields_form.tsx rename to x-pack/plugins/security_solution/public/cases/components/connectors/fields_form.tsx index 6b1a0cac8d9cd..41ed99e0f6768 100644 --- a/x-pack/plugins/security_solution/public/cases/components/settings/fields_form.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/fields_form.tsx @@ -8,24 +8,22 @@ import React, { memo, Suspense } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; -import { CaseSettingsConnector, SettingFieldsProps } from './types'; -import { getCaseSettings } from '.'; +import { CaseActionConnector, ConnectorFieldsProps } from './types'; +import { getCaseConnectors } from '.'; import { ConnectorTypeFields } from '../../../../../case/common/api/connectors'; -interface Props extends Omit, 'connector'> { - connector: CaseSettingsConnector | null; +interface Props extends Omit, 'connector'> { + connector: CaseActionConnector | null; } -const SettingFieldsFormComponent: React.FC = ({ connector, isEdit, onChange, fields }) => { - const { caseSettingsRegistry } = getCaseSettings(); +const ConnectorFieldsFormComponent: React.FC = ({ connector, isEdit, onChange, fields }) => { + const { caseConnectorsRegistry } = getCaseConnectors(); if (connector == null || connector.actionTypeId == null || connector.actionTypeId === '.none') { return null; } - const { caseSettingFieldsComponent: FieldsComponent } = caseSettingsRegistry.get( - connector.actionTypeId - ); + const { fieldsComponent: FieldsComponent } = caseConnectorsRegistry.get(connector.actionTypeId); return ( <> @@ -39,7 +37,7 @@ const SettingFieldsFormComponent: React.FC = ({ connector, isEdit, onChan
} > -
+
= ({ connector, isEdit, onChan ); }; -export const SettingFieldsForm = memo(SettingFieldsFormComponent); +export const ConnectorFieldsForm = memo(ConnectorFieldsFormComponent); diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/index.ts b/x-pack/plugins/security_solution/public/cases/components/connectors/index.ts index 96cb215557c24..267126fc6ec8b 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/index.ts +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/index.ts @@ -5,7 +5,53 @@ * 2.0. */ +import { CaseConnectorsRegistry } from './types'; +import { createCaseConnectorsRegistry } from './connectors_registry'; +import { getCaseConnector as getJiraCaseConnector } from './jira'; +import { getCaseConnector as getResilientCaseConnector } from './resilient'; +import { getServiceNowITSMCaseConnector, getServiceNowSIRCaseConnector } from './servicenow'; +import { + JiraFieldsType, + ServiceNowITSMFieldsType, + ServiceNowSIRFieldsType, + ResilientFieldsType, +} from '../../../../../case/common/api/connectors'; + export { getActionType as getCaseConnectorUI } from './case'; export * from './config'; export * from './types'; + +interface GetCaseConnectorsReturn { + caseConnectorsRegistry: CaseConnectorsRegistry; +} + +class CaseConnectors { + private caseConnectorsRegistry: CaseConnectorsRegistry; + + constructor() { + this.caseConnectorsRegistry = createCaseConnectorsRegistry(); + this.init(); + } + + private init() { + this.caseConnectorsRegistry.register(getJiraCaseConnector()); + this.caseConnectorsRegistry.register(getResilientCaseConnector()); + this.caseConnectorsRegistry.register( + getServiceNowITSMCaseConnector() + ); + this.caseConnectorsRegistry.register(getServiceNowSIRCaseConnector()); + } + + registry(): CaseConnectorsRegistry { + return this.caseConnectorsRegistry; + } +} + +const caseConnectors = new CaseConnectors(); + +export const getCaseConnectors = (): GetCaseConnectorsReturn => { + return { + caseConnectorsRegistry: caseConnectors.registry(), + }; +}; diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/jira/__mocks__/api.ts b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/__mocks__/api.ts similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/settings/jira/__mocks__/api.ts rename to x-pack/plugins/security_solution/public/cases/components/connectors/jira/__mocks__/api.ts diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/jira/api.test.ts b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/api.test.ts similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/settings/jira/api.test.ts rename to x-pack/plugins/security_solution/public/cases/components/connectors/jira/api.test.ts diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/jira/api.ts b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/api.ts similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/settings/jira/api.ts rename to x-pack/plugins/security_solution/public/cases/components/connectors/jira/api.ts diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/jira/fields.test.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/case_fields.test.tsx similarity index 99% rename from x-pack/plugins/security_solution/public/cases/components/settings/jira/fields.test.tsx rename to x-pack/plugins/security_solution/public/cases/components/connectors/jira/case_fields.test.tsx index 0c590d0ecd7ad..b151d41c4cdd8 100644 --- a/x-pack/plugins/security_solution/public/cases/components/settings/jira/fields.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/case_fields.test.tsx @@ -12,7 +12,7 @@ import { omit } from 'lodash/fp'; import { connector, issues } from '../mock'; import { useGetIssueTypes } from './use_get_issue_types'; import { useGetFieldsByIssueType } from './use_get_fields_by_issue_type'; -import Fields from './fields'; +import Fields from './case_fields'; import { waitFor } from '@testing-library/dom'; import { useGetSingleIssue } from './use_get_single_issue'; import { useGetIssues } from './use_get_issues'; diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/jira/fields.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/case_fields.tsx similarity index 91% rename from x-pack/plugins/security_solution/public/cases/components/settings/jira/fields.tsx rename to x-pack/plugins/security_solution/public/cases/components/connectors/jira/case_fields.tsx index 6409fe71a85fc..d768b552b78b4 100644 --- a/x-pack/plugins/security_solution/public/cases/components/settings/jira/fields.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/case_fields.tsx @@ -5,25 +5,26 @@ * 2.0. */ -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback, useMemo, useEffect, useRef } from 'react'; import { map } from 'lodash/fp'; import { EuiFormRow, EuiSelect, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import * as i18n from './translations'; import { ConnectorTypes, JiraFieldsType } from '../../../../../../case/common/api/connectors'; import { useKibana } from '../../../../common/lib/kibana'; -import { SettingFieldsProps } from '../types'; +import { ConnectorFieldsProps } from '../types'; import { useGetIssueTypes } from './use_get_issue_types'; import { useGetFieldsByIssueType } from './use_get_fields_by_issue_type'; import { SearchIssues } from './search_issues'; import { ConnectorCard } from '../card'; -const JiraSettingFieldsComponent: React.FunctionComponent> = ({ +const JiraFieldsComponent: React.FunctionComponent> = ({ connector, fields, isEdit = true, onChange, }) => { + const init = useRef(true); const { issueType = null, priority = null, parent = null } = fields ?? {}; const { http, notifications } = useKibana().services; @@ -138,8 +139,16 @@ const JiraSettingFieldsComponent: React.FunctionComponent { + if (init.current) { + init.current = false; + onChange({ issueType, priority, parent }); + } + }, [issueType, onChange, parent, priority]); + return isEdit ? ( -
+
=> { +export const getCaseConnector = (): CaseConnector => { return { id: '.jira', - caseSettingFieldsComponent: lazy(() => import('./fields')), + fieldsComponent: lazy(() => import('./case_fields')), }; }; diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/jira/search_issues.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/search_issues.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/settings/jira/search_issues.tsx rename to x-pack/plugins/security_solution/public/cases/components/connectors/jira/search_issues.tsx diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/jira/translations.ts b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/translations.ts similarity index 60% rename from x-pack/plugins/security_solution/public/cases/components/settings/jira/translations.ts rename to x-pack/plugins/security_solution/public/cases/components/connectors/jira/translations.ts index 65fe339aceb67..07f8f5b984cdd 100644 --- a/x-pack/plugins/security_solution/public/cases/components/settings/jira/translations.ts +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/translations.ts @@ -8,69 +8,69 @@ import { i18n } from '@kbn/i18n'; export const ISSUE_TYPES_API_ERROR = i18n.translate( - 'xpack.securitySolution.components.settings.jira.unableToGetIssueTypesMessage', + 'xpack.securitySolution.components.connectors.jira.unableToGetIssueTypesMessage', { defaultMessage: 'Unable to get issue types', } ); export const FIELDS_API_ERROR = i18n.translate( - 'xpack.securitySolution.components.settings.jira.unableToGetFieldsMessage', + 'xpack.securitySolution.components.connectors.jira.unableToGetFieldsMessage', { - defaultMessage: 'Unable to get fields', + defaultMessage: 'Unable to get connectors', } ); export const ISSUES_API_ERROR = i18n.translate( - 'xpack.securitySolution.components.settings.jira.unableToGetIssuesMessage', + 'xpack.securitySolution.components.connectors.jira.unableToGetIssuesMessage', { defaultMessage: 'Unable to get issues', } ); export const GET_ISSUE_API_ERROR = (id: string) => - i18n.translate('xpack.securitySolution.components.settings.jira.unableToGetIssueMessage', { + i18n.translate('xpack.securitySolution.components.connectors.jira.unableToGetIssueMessage', { defaultMessage: 'Unable to get issue with id {id}', values: { id }, }); export const SEARCH_ISSUES_COMBO_BOX_ARIA_LABEL = i18n.translate( - 'xpack.securitySolution.components.settings.jira.searchIssuesComboBoxAriaLabel', + 'xpack.securitySolution.components.connectors.jira.searchIssuesComboBoxAriaLabel', { defaultMessage: 'Type to search', } ); export const SEARCH_ISSUES_PLACEHOLDER = i18n.translate( - 'xpack.securitySolution.components.settings.jira.searchIssuesComboBoxPlaceholder', + 'xpack.securitySolution.components.connectors.jira.searchIssuesComboBoxPlaceholder', { defaultMessage: 'Type to search', } ); export const SEARCH_ISSUES_LOADING = i18n.translate( - 'xpack.securitySolution.components.settings.jira.searchIssuesLoading', + 'xpack.securitySolution.components.connectors.jira.searchIssuesLoading', { defaultMessage: 'Loading...', } ); export const PRIORITY = i18n.translate( - 'xpack.securitySolution.case.settings.jira.prioritySelectFieldLabel', + 'xpack.securitySolution.case.connectors.jira.prioritySelectFieldLabel', { defaultMessage: 'Priority', } ); export const ISSUE_TYPE = i18n.translate( - 'xpack.securitySolution.case.settings.jira.issueTypesSelectFieldLabel', + 'xpack.securitySolution.case.connectors.jira.issueTypesSelectFieldLabel', { defaultMessage: 'Issue type', } ); export const PARENT_ISSUE = i18n.translate( - 'xpack.securitySolution.case.settings.jira.parentIssueSearchLabel', + 'xpack.securitySolution.case.connectors.jira.parentIssueSearchLabel', { defaultMessage: 'Parent issue', } diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/jira/types.ts b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/types.ts similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/settings/jira/types.ts rename to x-pack/plugins/security_solution/public/cases/components/connectors/jira/types.ts diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/jira/use_get_fields_by_issue_type.test.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_fields_by_issue_type.test.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/settings/jira/use_get_fields_by_issue_type.test.tsx rename to x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_fields_by_issue_type.test.tsx diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/jira/use_get_fields_by_issue_type.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_fields_by_issue_type.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/settings/jira/use_get_fields_by_issue_type.tsx rename to x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_fields_by_issue_type.tsx diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/jira/use_get_issue_types.test.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_issue_types.test.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/settings/jira/use_get_issue_types.test.tsx rename to x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_issue_types.test.tsx diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/jira/use_get_issue_types.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_issue_types.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/settings/jira/use_get_issue_types.tsx rename to x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_issue_types.tsx diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/jira/use_get_issues.test.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_issues.test.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/settings/jira/use_get_issues.test.tsx rename to x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_issues.test.tsx diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/jira/use_get_issues.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_issues.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/settings/jira/use_get_issues.tsx rename to x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_issues.tsx diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/jira/use_get_single_issue.test.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_single_issue.test.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/settings/jira/use_get_single_issue.test.tsx rename to x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_single_issue.test.tsx diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/jira/use_get_single_issue.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_single_issue.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/settings/jira/use_get_single_issue.tsx rename to x-pack/plugins/security_solution/public/cases/components/connectors/jira/use_get_single_issue.tsx diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/mock.ts b/x-pack/plugins/security_solution/public/cases/components/connectors/mock.ts new file mode 100644 index 0000000000000..04e7338025258 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/mock.ts @@ -0,0 +1,109 @@ +/* + * 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 connector = { + id: '123', + name: 'My connector', + actionTypeId: '.jira', + config: {}, + isPreconfigured: false, +}; + +export const issues = [ + { id: 'personId', title: 'Person Task', key: 'personKey' }, + { id: 'womanId', title: 'Woman Task', key: 'womanKey' }, + { id: 'manId', title: 'Man Task', key: 'manKey' }, + { id: 'cameraId', title: 'Camera Task', key: 'cameraKey' }, + { id: 'tvId', title: 'TV Task', key: 'tvKey' }, +]; + +export const choices = [ + { + dependent_value: '', + label: 'Priviledge Escalation', + value: 'Priviledge Escalation', + element: 'category', + }, + { + dependent_value: '', + label: 'Criminal activity/investigation', + value: 'Criminal activity/investigation', + element: 'category', + }, + { + dependent_value: '', + label: 'Denial of Service', + value: 'Denial of Service', + element: 'category', + }, + { + dependent_value: 'Denial of Service', + label: 'Inbound or outbound', + value: '12', + element: 'subcategory', + }, + { + dependent_value: 'Denial of Service', + label: 'Single or distributed (DoS or DDoS)', + value: '26', + element: 'subcategory', + }, + { + dependent_value: 'Denial of Service', + label: 'Inbound DDos', + value: 'inbound_ddos', + element: 'subcategory', + }, + ...['severity', 'urgency', 'impact', 'priority'] + .map((element) => [ + { + dependent_value: '', + label: '1 - Critical', + value: '1', + element, + }, + { + dependent_value: '', + label: '2 - High', + value: '2', + element, + }, + { + dependent_value: '', + label: '3 - Moderate', + value: '3', + element, + }, + { + dependent_value: '', + label: '4 - Low', + value: '4', + element, + }, + ]) + .flat(), +]; + +export const severity = [ + { + id: 4, + name: 'Low', + }, + { + id: 5, + name: 'Medium', + }, + { + id: 6, + name: 'High', + }, +]; + +export const incidentTypes = [ + { id: 17, name: 'Communication error (fax; email)' }, + { id: 1001, name: 'Custom type' }, +]; diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/resilient/__mocks__/api.ts b/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/__mocks__/api.ts similarity index 70% rename from x-pack/plugins/security_solution/public/cases/components/settings/resilient/__mocks__/api.ts rename to x-pack/plugins/security_solution/public/cases/components/connectors/resilient/__mocks__/api.ts index f4397eaf1877c..c27248288907d 100644 --- a/x-pack/plugins/security_solution/public/cases/components/settings/resilient/__mocks__/api.ts +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/__mocks__/api.ts @@ -5,29 +5,10 @@ * 2.0. */ +import { incidentTypes, severity } from '../../mock'; import { Props } from '../api'; import { ResilientIncidentTypes, ResilientSeverity } from '../types'; -const severity = [ - { - id: 4, - name: 'Low', - }, - { - id: 5, - name: 'Medium', - }, - { - id: 6, - name: 'High', - }, -]; - -const incidentTypes = [ - { id: 17, name: 'Communication error (fax; email)' }, - { id: 1001, name: 'Custom type' }, -]; - export const getIncidentTypes = async (props: Props): Promise<{ data: ResilientIncidentTypes }> => Promise.resolve({ data: incidentTypes }); diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/resilient/api.ts b/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/api.ts similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/settings/resilient/api.ts rename to x-pack/plugins/security_solution/public/cases/components/connectors/resilient/api.ts diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/resilient/fields.test.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/case_fields.test.tsx similarity index 99% rename from x-pack/plugins/security_solution/public/cases/components/settings/resilient/fields.test.tsx rename to x-pack/plugins/security_solution/public/cases/components/connectors/resilient/case_fields.test.tsx index 9095f3b56f2c3..dd13083288020 100644 --- a/x-pack/plugins/security_solution/public/cases/components/settings/resilient/fields.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/case_fields.test.tsx @@ -13,7 +13,7 @@ import { waitFor } from '@testing-library/react'; import { connector } from '../mock'; import { useGetIncidentTypes } from './use_get_incident_types'; import { useGetSeverity } from './use_get_severity'; -import Fields from './fields'; +import Fields from './case_fields'; jest.mock('../../../../common/lib/kibana'); jest.mock('./use_get_incident_types'); diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/resilient/fields.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/case_fields.tsx similarity index 90% rename from x-pack/plugins/security_solution/public/cases/components/settings/resilient/fields.tsx rename to x-pack/plugins/security_solution/public/cases/components/connectors/resilient/case_fields.tsx index f79ce8a4a5630..8c62f5285c257 100644 --- a/x-pack/plugins/security_solution/public/cases/components/settings/resilient/fields.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/case_fields.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useMemo, useCallback, useEffect } from 'react'; +import React, { useMemo, useCallback, useEffect, useRef } from 'react'; import { EuiComboBox, EuiComboBoxOptionOption, @@ -16,8 +16,7 @@ import { } from '@elastic/eui'; import { useKibana } from '../../../../common/lib/kibana'; -import { SettingFieldsProps } from '../types'; - +import { ConnectorFieldsProps } from '../types'; import { useGetIncidentTypes } from './use_get_incident_types'; import { useGetSeverity } from './use_get_severity'; @@ -25,9 +24,10 @@ import * as i18n from './translations'; import { ConnectorTypes, ResilientFieldsType } from '../../../../../../case/common/api/connectors'; import { ConnectorCard } from '../card'; -const ResilientSettingFieldsComponent: React.FunctionComponent< - SettingFieldsProps +const ResilientFieldsComponent: React.FunctionComponent< + ConnectorFieldsProps > = ({ isEdit = true, fields, connector, onChange }) => { + const init = useRef(true); const { incidentTypes = null, severityCode = null } = fields ?? {}; const { http, notifications } = useKibana().services; @@ -136,14 +136,16 @@ const ResilientSettingFieldsComponent: React.FunctionComponent< } }, [incidentTypes, onFieldChange]); - // We need to set them up at initialization + // Set field at initialization useEffect(() => { - onChange({ incidentTypes, severityCode }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + if (init.current) { + init.current = false; + onChange({ incidentTypes, severityCode }); + } + }, [incidentTypes, onChange, severityCode]); return isEdit ? ( - + => { +export const getCaseConnector = (): CaseConnector => { return { id: '.resilient', - caseSettingFieldsComponent: lazy(() => import('./fields')), + fieldsComponent: lazy(() => import('./case_fields')), }; }; diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/resilient/translations.ts b/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/translations.ts similarity index 67% rename from x-pack/plugins/security_solution/public/cases/components/settings/resilient/translations.ts rename to x-pack/plugins/security_solution/public/cases/components/connectors/resilient/translations.ts index 648baf840884b..32a72c3803708 100644 --- a/x-pack/plugins/security_solution/public/cases/components/settings/resilient/translations.ts +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/translations.ts @@ -8,35 +8,35 @@ import { i18n } from '@kbn/i18n'; export const INCIDENT_TYPES_API_ERROR = i18n.translate( - 'xpack.securitySolution.case.settings.resilient.unableToGetIncidentTypesMessage', + 'xpack.securitySolution.case.connectors.resilient.unableToGetIncidentTypesMessage', { defaultMessage: 'Unable to get incident types', } ); export const SEVERITY_API_ERROR = i18n.translate( - 'xpack.securitySolution.case.settings.resilient.unableToGetSeverityMessage', + 'xpack.securitySolution.case.connectors.resilient.unableToGetSeverityMessage', { defaultMessage: 'Unable to get severity', } ); export const INCIDENT_TYPES_PLACEHOLDER = i18n.translate( - 'xpack.securitySolution.case.settings.resilient.incidentTypesPlaceholder', + 'xpack.securitySolution.case.connectors.resilient.incidentTypesPlaceholder', { defaultMessage: 'Choose types', } ); export const INCIDENT_TYPES_LABEL = i18n.translate( - 'xpack.securitySolution.case.settings.resilient.incidentTypesLabel', + 'xpack.securitySolution.case.connectors.resilient.incidentTypesLabel', { defaultMessage: 'Incident Types', } ); export const SEVERITY_LABEL = i18n.translate( - 'xpack.securitySolution.case.settings.resilient.severityLabel', + 'xpack.securitySolution.case.connectors.resilient.severityLabel', { defaultMessage: 'Severity', } diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/resilient/types.ts b/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/types.ts similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/settings/resilient/types.ts rename to x-pack/plugins/security_solution/public/cases/components/connectors/resilient/types.ts diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/resilient/use_get_incident_types.test.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/use_get_incident_types.test.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/settings/resilient/use_get_incident_types.test.tsx rename to x-pack/plugins/security_solution/public/cases/components/connectors/resilient/use_get_incident_types.test.tsx diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/resilient/use_get_incident_types.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/use_get_incident_types.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/settings/resilient/use_get_incident_types.tsx rename to x-pack/plugins/security_solution/public/cases/components/connectors/resilient/use_get_incident_types.tsx diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/resilient/use_get_severity.test.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/use_get_severity.test.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/settings/resilient/use_get_severity.test.tsx rename to x-pack/plugins/security_solution/public/cases/components/connectors/resilient/use_get_severity.test.tsx diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/resilient/use_get_severity.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/resilient/use_get_severity.tsx similarity index 100% rename from x-pack/plugins/security_solution/public/cases/components/settings/resilient/use_get_severity.tsx rename to x-pack/plugins/security_solution/public/cases/components/connectors/resilient/use_get_severity.tsx diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/__mocks__/api.ts b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/__mocks__/api.ts new file mode 100644 index 0000000000000..215e3d6f92e6d --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/__mocks__/api.ts @@ -0,0 +1,19 @@ +/* + * 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 { choices } from '../../mock'; +import { GetChoicesProps } from '../api'; +import { Choice } from '../types'; + +export const choicesResponse = { + status: 'ok', + data: choices, +}; + +export const getChoices = async ( + props: GetChoicesProps +): Promise<{ status: string; data: Choice[] }> => Promise.resolve(choicesResponse); diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/api.test.ts b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/api.test.ts new file mode 100644 index 0000000000000..6a6bb7e947997 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/api.test.ts @@ -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 { httpServiceMock } from '../../../../../../../../src/core/public/mocks'; +import { getChoices } from './api'; +import { choices } from '../mock'; + +const choicesResponse = { + status: 'ok', + data: choices, +}; + +describe('ServiceNow API', () => { + const http = httpServiceMock.createStartContract(); + + beforeEach(() => jest.resetAllMocks()); + + describe('getChoices', () => { + test('should call get choices API', async () => { + const abortCtrl = new AbortController(); + http.post.mockResolvedValueOnce(choicesResponse); + const res = await getChoices({ + http, + signal: abortCtrl.signal, + connectorId: 'test', + fields: ['priority'], + }); + + expect(res).toEqual(choicesResponse); + expect(http.post).toHaveBeenCalledWith('/api/actions/action/test/_execute', { + body: '{"params":{"subAction":"getChoices","subActionParams":{"fields":["priority"]}}}', + signal: abortCtrl.signal, + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/api.ts b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/api.ts new file mode 100644 index 0000000000000..d91ad9f8762bd --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/api.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 { HttpSetup } from 'kibana/public'; +import { ActionTypeExecutorResult } from '../../../../../../actions/common'; +import { Choice } from './types'; + +export const BASE_ACTION_API_PATH = '/api/actions'; + +export interface GetChoicesProps { + http: HttpSetup; + signal: AbortSignal; + connectorId: string; + fields: string[]; +} + +export async function getChoices({ http, signal, connectorId, fields }: GetChoicesProps) { + return http.post>( + `${BASE_ACTION_API_PATH}/action/${connectorId}/_execute`, + { + body: JSON.stringify({ + params: { subAction: 'getChoices', subActionParams: { fields } }, + }), + signal, + } + ); +} diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/index.ts b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/index.ts new file mode 100644 index 0000000000000..81bd81124599f --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/index.ts @@ -0,0 +1,35 @@ +/* + * 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 { lazy } from 'react'; + +import { CaseConnector } from '../types'; +import { + ServiceNowITSMFieldsType, + ServiceNowSIRFieldsType, +} from '../../../../../../case/common/api/connectors'; +import * as i18n from './translations'; + +export const getServiceNowITSMCaseConnector = (): CaseConnector => { + return { + id: '.servicenow', + fieldsComponent: lazy(() => import('./servicenow_itsm_case_fields')), + }; +}; + +export const getServiceNowSIRCaseConnector = (): CaseConnector => { + return { + id: '.servicenow-sir', + fieldsComponent: lazy(() => import('./servicenow_sir_case_fields')), + }; +}; + +export const serviceNowITSMFieldLabels = { + impact: i18n.IMPACT, + severity: i18n.SEVERITY, + urgency: i18n.URGENCY, +}; diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/servicenow/fields.test.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_itsm_case_fields.test.tsx similarity index 52% rename from x-pack/plugins/security_solution/public/cases/components/settings/servicenow/fields.test.tsx rename to x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_itsm_case_fields.test.tsx index 2e56e21aa8e98..555ed0dcbb161 100644 --- a/x-pack/plugins/security_solution/public/cases/components/settings/servicenow/fields.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_itsm_case_fields.test.tsx @@ -6,36 +6,74 @@ */ import React from 'react'; -import { mount } from 'enzyme'; -import Fields from './fields'; -import { connector } from '../mock'; -import { waitFor } from '@testing-library/dom'; +import { waitFor, act } from '@testing-library/react'; import { EuiSelect } from '@elastic/eui'; +import { mount } from 'enzyme'; + +import { connector, choices as mockChoices } from '../mock'; +import { Choice } from './types'; +import Fields from './servicenow_itsm_case_fields'; + +let onChoicesSuccess = (c: Choice[]) => {}; -describe('ServiceNow Fields', () => { +jest.mock('../../../../common/lib/kibana'); +jest.mock('./use_get_choices', () => ({ + useGetChoices: (args: { onSuccess: () => void }) => { + onChoicesSuccess = args.onSuccess; + return { isLoading: false, mockChoices }; + }, +})); + +describe('ServiceNowITSM Fields', () => { const fields = { severity: '1', urgency: '2', impact: '3' }; const onChange = jest.fn(); + beforeEach(() => { jest.clearAllMocks(); }); + it('all params fields are rendered - isEdit: true', () => { const wrapper = mount(); - expect(wrapper.find('[data-test-subj="severitySelect"]').first().prop('value')).toEqual('1'); - expect(wrapper.find('[data-test-subj="urgencySelect"]').first().prop('value')).toEqual('2'); - expect(wrapper.find('[data-test-subj="impactSelect"]').first().prop('value')).toEqual('3'); + expect(wrapper.find('[data-test-subj="severitySelect"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="urgencySelect"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="impactSelect"]').exists()).toBeTruthy(); }); - test('all params fields are rendered - isEdit: false', () => { + it('all params fields are rendered - isEdit: false', () => { const wrapper = mount( ); + act(() => { + onChoicesSuccess(mockChoices); + }); + expect(wrapper.find('[data-test-subj="card-list-item"]').at(0).text()).toEqual( - 'Urgency: Medium' + 'Urgency: 2 - High' ); expect(wrapper.find('[data-test-subj="card-list-item"]').at(1).text()).toEqual( - 'Severity: High' + 'Severity: 1 - Critical' + ); + expect(wrapper.find('[data-test-subj="card-list-item"]').at(2).text()).toEqual( + 'Impact: 3 - Moderate' + ); + }); + + it('it transforms the options correctly', async () => { + const wrapper = mount(); + act(() => { + onChoicesSuccess(mockChoices); + }); + + wrapper.update(); + const testers = ['severity', 'urgency', 'impact']; + testers.forEach((subj) => + expect(wrapper.find(`[data-test-subj="${subj}Select"]`).first().prop('options')).toEqual([ + { value: '1', text: '1 - Critical' }, + { value: '2', text: '2 - High' }, + { value: '3', text: '3 - Moderate' }, + { value: '4', text: '4 - Low' }, + ]) ); - expect(wrapper.find('[data-test-subj="card-list-item"]').at(2).text()).toEqual('Impact: Low'); }); describe('onChange calls', () => { diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_itsm_case_fields.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_itsm_case_fields.tsx new file mode 100644 index 0000000000000..e278492b57148 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_itsm_case_fields.tsx @@ -0,0 +1,164 @@ +/* + * 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, useEffect, useMemo, useState, useRef } from 'react'; +import { EuiFormRow, EuiSelect, EuiSpacer, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import * as i18n from './translations'; + +import { ConnectorFieldsProps } from '../types'; +import { + ConnectorTypes, + ServiceNowITSMFieldsType, +} from '../../../../../../case/common/api/connectors'; +import { useKibana } from '../../../../common/lib/kibana'; +import { ConnectorCard } from '../card'; +import { useGetChoices } from './use_get_choices'; +import { Options, Choice } from './types'; + +const useGetChoicesFields = ['urgency', 'severity', 'impact']; +const defaultOptions: Options = { + urgency: [], + severity: [], + impact: [], +}; + +const ServiceNowITSMFieldsComponent: React.FunctionComponent< + ConnectorFieldsProps +> = ({ isEdit = true, fields, connector, onChange }) => { + const init = useRef(true); + const { severity = null, urgency = null, impact = null } = fields ?? {}; + const { http, notifications } = useKibana().services; + const [options, setOptions] = useState(defaultOptions); + + const listItems = useMemo( + () => [ + ...(urgency != null && urgency.length > 0 + ? [ + { + title: i18n.URGENCY, + description: options.urgency.find((option) => `${option.value}` === urgency)?.text, + }, + ] + : []), + ...(severity != null && severity.length > 0 + ? [ + { + title: i18n.SEVERITY, + description: options.severity.find((option) => `${option.value}` === severity)?.text, + }, + ] + : []), + ...(impact != null && impact.length > 0 + ? [ + { + title: i18n.IMPACT, + description: options.impact.find((option) => `${option.value}` === impact)?.text, + }, + ] + : []), + ], + [urgency, options.urgency, options.severity, options.impact, severity, impact] + ); + + const onChoicesSuccess = (choices: Choice[]) => + setOptions( + choices.reduce( + (acc, choice) => ({ + ...acc, + [choice.element]: [ + ...(acc[choice.element] != null ? acc[choice.element] : []), + { value: choice.value, text: choice.label }, + ], + }), + defaultOptions + ) + ); + + const { isLoading: isLoadingChoices } = useGetChoices({ + http, + toastNotifications: notifications.toasts, + connector, + fields: useGetChoicesFields, + onSuccess: onChoicesSuccess, + }); + + const onChangeCb = useCallback( + ( + key: keyof ServiceNowITSMFieldsType, + value: ServiceNowITSMFieldsType[keyof ServiceNowITSMFieldsType] + ) => { + onChange({ ...fields, [key]: value }); + }, + [fields, onChange] + ); + + // Set field at initialization + useEffect(() => { + if (init.current) { + init.current = false; + onChange({ urgency, severity, impact }); + } + }, [impact, onChange, severity, urgency]); + + return isEdit ? ( +
+ + onChangeCb('urgency', e.target.value)} + /> + + + + + + onChangeCb('severity', e.target.value)} + /> + + + + + onChangeCb('impact', e.target.value)} + /> + + + +
+ ) : ( + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { ServiceNowITSMFieldsComponent as default }; diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_sir_case_fields.test.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_sir_case_fields.test.tsx new file mode 100644 index 0000000000000..7d785406afec8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_sir_case_fields.test.tsx @@ -0,0 +1,198 @@ +/* + * 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 { mount } from 'enzyme'; +import { waitFor, act } from '@testing-library/react'; +import { EuiSelect } from '@elastic/eui'; + +import { connector, choices as mockChoices } from '../mock'; +import { Choice } from './types'; +import Fields from './servicenow_sir_case_fields'; + +let onChoicesSuccess = (c: Choice[]) => {}; + +jest.mock('../../../../common/lib/kibana'); +jest.mock('./use_get_choices', () => ({ + useGetChoices: (args: { onSuccess: () => void }) => { + onChoicesSuccess = args.onSuccess; + return { isLoading: false, mockChoices }; + }, +})); + +describe('ServiceNowSIR Fields', () => { + const fields = { + destIp: true, + sourceIp: true, + malwareHash: true, + malwareUrl: true, + priority: '1', + category: 'Denial of Service', + subcategory: '26', + }; + const onChange = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('all params fields are rendered - isEdit: true', () => { + const wrapper = mount(); + expect(wrapper.find('[data-test-subj="destIpCheckbox"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="sourceIpCheckbox"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="malwareUrlCheckbox"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="malwareHashCheckbox"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="prioritySelect"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="categorySelect"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="subcategorySelect"]').exists()).toBeTruthy(); + }); + + test('all params fields are rendered - isEdit: false', () => { + const wrapper = mount( + + ); + act(() => { + onChoicesSuccess(mockChoices); + }); + wrapper.update(); + + expect(wrapper.find('[data-test-subj="card-list-item"]').at(0).text()).toEqual( + 'Destination IP: Yes' + ); + expect(wrapper.find('[data-test-subj="card-list-item"]').at(1).text()).toEqual( + 'Source IP: Yes' + ); + expect(wrapper.find('[data-test-subj="card-list-item"]').at(2).text()).toEqual( + 'Malware URL: Yes' + ); + expect(wrapper.find('[data-test-subj="card-list-item"]').at(3).text()).toEqual( + 'Malware Hash: Yes' + ); + expect(wrapper.find('[data-test-subj="card-list-item"]').at(4).text()).toEqual( + 'Priority: 1 - Critical' + ); + expect(wrapper.find('[data-test-subj="card-list-item"]').at(5).text()).toEqual( + 'Category: Denial of Service' + ); + expect(wrapper.find('[data-test-subj="card-list-item"]').at(6).text()).toEqual( + 'Subcategory: Single or distributed (DoS or DDoS)' + ); + }); + + test('it transforms the categories to options correctly', async () => { + const wrapper = mount(); + act(() => { + onChoicesSuccess(mockChoices); + }); + + wrapper.update(); + expect(wrapper.find('[data-test-subj="categorySelect"]').first().prop('options')).toEqual([ + { value: 'Priviledge Escalation', text: 'Priviledge Escalation' }, + { + value: 'Criminal activity/investigation', + text: 'Criminal activity/investigation', + }, + { value: 'Denial of Service', text: 'Denial of Service' }, + ]); + }); + + test('it transforms the subcategories to options correctly', async () => { + const wrapper = mount(); + act(() => { + onChoicesSuccess(mockChoices); + }); + + wrapper.update(); + expect(wrapper.find('[data-test-subj="subcategorySelect"]').first().prop('options')).toEqual([ + { + text: 'Inbound or outbound', + value: '12', + }, + { + text: 'Single or distributed (DoS or DDoS)', + value: '26', + }, + { + text: 'Inbound DDos', + value: 'inbound_ddos', + }, + ]); + }); + + test('it transforms the priorities to options correctly', async () => { + const wrapper = mount(); + act(() => { + onChoicesSuccess(mockChoices); + }); + + wrapper.update(); + expect(wrapper.find('[data-test-subj="prioritySelect"]').first().prop('options')).toEqual([ + { + text: '1 - Critical', + value: '1', + }, + { + text: '2 - High', + value: '2', + }, + { + text: '3 - Moderate', + value: '3', + }, + { + text: '4 - Low', + value: '4', + }, + ]); + }); + + describe('onChange calls', () => { + const wrapper = mount(); + + act(() => { + onChoicesSuccess(mockChoices); + }); + wrapper.update(); + + expect(onChange).toHaveBeenCalledWith(fields); + + const checkbox = ['destIp', 'sourceIp', 'malwareHash', 'malwareUrl']; + checkbox.forEach((subj) => + test(`${subj.toUpperCase()}`, async () => { + await waitFor(() => { + wrapper + .find(`[data-test-subj="${subj}Checkbox"] input`) + .first() + .simulate('change', { target: { checked: false } }); + expect(onChange).toHaveBeenCalledWith({ + ...fields, + [subj]: false, + }); + }); + }) + ); + + const testers = ['priority', 'category', 'subcategory']; + testers.forEach((subj) => + test(`${subj.toUpperCase()}`, async () => { + await waitFor(() => { + const select = wrapper.find(EuiSelect).filter(`[data-test-subj="${subj}Select"]`)!; + select.prop('onChange')!({ + target: { + value: '9', + }, + } as React.ChangeEvent); + }); + wrapper.update(); + expect(onChange).toHaveBeenCalledWith({ + ...fields, + [subj]: '9', + }); + }) + ); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_sir_case_fields.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_sir_case_fields.tsx new file mode 100644 index 0000000000000..96db43fe261ac --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/servicenow_sir_case_fields.tsx @@ -0,0 +1,293 @@ +/* + * 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, useEffect, useMemo, useState, useRef } from 'react'; +import { + EuiFormRow, + EuiSelect, + EuiFlexGroup, + EuiFlexItem, + EuiSelectOption, + EuiCheckbox, +} from '@elastic/eui'; + +import { + ConnectorTypes, + ServiceNowSIRFieldsType, +} from '../../../../../../case/common/api/connectors'; +import { useKibana } from '../../../../common/lib/kibana'; +import { ConnectorFieldsProps } from '../types'; +import { ConnectorCard } from '../card'; +import { useGetChoices } from './use_get_choices'; +import { Choice, Fields } from './types'; + +import * as i18n from './translations'; + +const useGetChoicesFields = ['category', 'subcategory', 'priority']; +const defaultFields: Fields = { + category: [], + subcategory: [], + priority: [], +}; + +const choicesToEuiOptions = (choices: Choice[]): EuiSelectOption[] => + choices.map((choice) => ({ value: choice.value, text: choice.label })); + +const ServiceNowSIRFieldsComponent: React.FunctionComponent< + ConnectorFieldsProps +> = ({ isEdit = true, fields, connector, onChange }) => { + const init = useRef(true); + const { + category = null, + destIp = true, + malwareHash = true, + malwareUrl = true, + priority = null, + sourceIp = true, + subcategory = null, + } = fields ?? {}; + + const { http, notifications } = useKibana().services; + + const [choices, setChoices] = useState(defaultFields); + + const onChangeCb = useCallback( + ( + key: keyof ServiceNowSIRFieldsType, + value: ServiceNowSIRFieldsType[keyof ServiceNowSIRFieldsType] + ) => { + onChange({ ...fields, [key]: value }); + }, + [fields, onChange] + ); + + const onChoicesSuccess = (values: Choice[]) => { + setChoices( + values.reduce( + (acc, value) => ({ + ...acc, + [value.element]: [...(acc[value.element] != null ? acc[value.element] : []), value], + }), + defaultFields + ) + ); + }; + + const { isLoading: isLoadingChoices } = useGetChoices({ + http, + toastNotifications: notifications.toasts, + connector, + fields: useGetChoicesFields, + onSuccess: onChoicesSuccess, + }); + + const categoryOptions = useMemo(() => choicesToEuiOptions(choices.category), [choices.category]); + const priorityOptions = useMemo(() => choicesToEuiOptions(choices.priority), [choices.priority]); + + const subcategoryOptions = useMemo( + () => + choicesToEuiOptions( + choices.subcategory.filter((choice) => choice.dependent_value === category) + ), + [choices.subcategory, category] + ); + + const listItems = useMemo( + () => [ + ...(destIp != null && destIp + ? [ + { + title: i18n.DEST_IP, + description: i18n.ALERT_FIELD_ENABLED_TEXT, + }, + ] + : []), + ...(sourceIp != null && sourceIp + ? [ + { + title: i18n.SOURCE_IP, + description: i18n.ALERT_FIELD_ENABLED_TEXT, + }, + ] + : []), + ...(malwareUrl != null && malwareUrl + ? [ + { + title: i18n.MALWARE_URL, + description: i18n.ALERT_FIELD_ENABLED_TEXT, + }, + ] + : []), + ...(malwareHash != null && malwareHash + ? [ + { + title: i18n.MALWARE_HASH, + description: i18n.ALERT_FIELD_ENABLED_TEXT, + }, + ] + : []), + ...(priority != null && priority.length > 0 + ? [ + { + title: i18n.PRIORITY, + description: priorityOptions.find((option) => `${option.value}` === priority)?.text, + }, + ] + : []), + ...(category != null && category.length > 0 + ? [ + { + title: i18n.CATEGORY, + description: categoryOptions.find((option) => `${option.value}` === category)?.text, + }, + ] + : []), + ...(subcategory != null && subcategory.length > 0 + ? [ + { + title: i18n.SUBCATEGORY, + description: subcategoryOptions.find((option) => `${option.value}` === subcategory) + ?.text, + }, + ] + : []), + ], + [ + category, + categoryOptions, + destIp, + malwareHash, + malwareUrl, + priority, + priorityOptions, + sourceIp, + subcategory, + subcategoryOptions, + ] + ); + + // Set field at initialization + useEffect(() => { + if (init.current) { + init.current = false; + onChange({ category, destIp, malwareHash, malwareUrl, priority, sourceIp, subcategory }); + } + }, [category, destIp, malwareHash, malwareUrl, onChange, priority, sourceIp, subcategory]); + + return isEdit ? ( +
+ + + + <> + + + onChangeCb('destIp', e.target.checked)} + /> + + + onChangeCb('sourceIp', e.target.checked)} + /> + + + + + onChangeCb('malwareUrl', e.target.checked)} + /> + + + onChangeCb('malwareHash', e.target.checked)} + /> + + + + + + + + + + onChangeCb('priority', e.target.value)} + /> + + + + + + + onChangeCb('category', e.target.value)} + /> + + + + + onChangeCb('subcategory', e.target.value)} + /> + + + +
+ ) : ( + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { ServiceNowSIRFieldsComponent as default }; diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/translations.ts b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/translations.ts new file mode 100644 index 0000000000000..0867dc41eeb78 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/translations.ts @@ -0,0 +1,99 @@ +/* + * 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 URGENCY = i18n.translate( + 'xpack.securitySolution.components.connectors.serviceNow.urgencySelectFieldLabel', + { + defaultMessage: 'Urgency', + } +); + +export const SEVERITY = i18n.translate( + 'xpack.securitySolution.components.connectors.serviceNow.severitySelectFieldLabel', + { + defaultMessage: 'Severity', + } +); + +export const IMPACT = i18n.translate( + 'xpack.securitySolution.components.connectors.serviceNow.impactSelectFieldLabel', + { + defaultMessage: 'Impact', + } +); + +export const CHOICES_API_ERROR = i18n.translate( + 'xpack.securitySolution.components.connectors.serviceNow.unableToGetChoicesMessage', + { + defaultMessage: 'Unable to get choices', + } +); + +export const MALWARE_URL = i18n.translate( + 'xpack.securitySolution.components.connectors.serviceNow.malwareURLTitle', + { + defaultMessage: 'Malware URL', + } +); + +export const MALWARE_HASH = i18n.translate( + 'xpack.securitySolution.components.connectors.serviceNow.malwareHashTitle', + { + defaultMessage: 'Malware Hash', + } +); + +export const CATEGORY = i18n.translate( + 'xpack.securitySolution.components.connectors.serviceNow.categoryTitle', + { + defaultMessage: 'Category', + } +); + +export const SUBCATEGORY = i18n.translate( + 'xpack.securitySolution.components.connectors.serviceNow.subcategoryTitle', + { + defaultMessage: 'Subcategory', + } +); + +export const SOURCE_IP = i18n.translate( + 'xpack.securitySolution.components.connectors.serviceNow.sourceIPTitle', + { + defaultMessage: 'Source IP', + } +); + +export const DEST_IP = i18n.translate( + 'xpack.securitySolution.components.connectors.serviceNow.destinationIPTitle', + { + defaultMessage: 'Destination IP', + } +); + +export const PRIORITY = i18n.translate( + 'xpack.securitySolution.components.connectors.serviceNow.prioritySelectFieldTitle', + { + defaultMessage: 'Priority', + } +); + +export const ALERT_FIELDS_LABEL = i18n.translate( + 'xpack.securitySolution.components.connectors.serviceNow.alertFieldsTitle', + { + defaultMessage: 'Fields associated with alerts', + } +); + +export const ALERT_FIELD_ENABLED_TEXT = i18n.translate( + 'xpack.securitySolution.components.connectors.serviceNow.alertFieldEnabledText', + { + defaultMessage: 'Yes', + } +); diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/types.ts b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/types.ts new file mode 100644 index 0000000000000..deceeed29482b --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/types.ts @@ -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 { EuiSelectOption } from '@elastic/eui'; + +export interface Choice { + value: string; + label: string; + dependent_value: string; + element: string; +} + +export type Fields = Record; +export type Options = Record; diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/use_get_choices.test.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/use_get_choices.test.tsx new file mode 100644 index 0000000000000..2492fbaaf5a83 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/use_get_choices.test.tsx @@ -0,0 +1,144 @@ +/* + * 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 { useKibana } from '../../../../common/lib/kibana'; +import { ActionConnector } from '../../../containers/types'; +import { choices } from '../mock'; +import { useGetChoices, UseGetChoices, UseGetChoicesProps } from './use_get_choices'; +import * as api from './api'; + +jest.mock('./api'); +jest.mock('../../../../common/lib/kibana'); + +const useKibanaMock = useKibana as jest.Mocked; +const onSuccess = jest.fn(); +const fields = ['priority']; + +const connector = { + secrets: { + username: 'user', + password: 'pass', + }, + id: 'test', + actionTypeId: '.servicenow', + name: 'ServiceNow', + isPreconfigured: false, + config: { + apiUrl: 'https://dev94428.service-now.com/', + }, +} as ActionConnector; + +describe('useGetChoices', () => { + const { services } = useKibanaMock(); + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('init', async () => { + const { result, waitForNextUpdate } = renderHook(() => + useGetChoices({ + http: services.http, + connector, + toastNotifications: services.notifications.toasts, + fields, + onSuccess, + }) + ); + + await waitForNextUpdate(); + + expect(result.current).toEqual({ + isLoading: false, + choices, + }); + }); + + it('returns an empty array when connector is not presented', async () => { + const { result } = renderHook(() => + useGetChoices({ + http: services.http, + connector: undefined, + toastNotifications: services.notifications.toasts, + fields, + onSuccess, + }) + ); + + expect(result.current).toEqual({ + isLoading: false, + choices: [], + }); + }); + + it('it calls onSuccess', async () => { + const { waitForNextUpdate } = renderHook(() => + useGetChoices({ + http: services.http, + connector, + toastNotifications: services.notifications.toasts, + fields, + onSuccess, + }) + ); + + await waitForNextUpdate(); + + expect(onSuccess).toHaveBeenCalledWith(choices); + }); + + it('it displays an error when service fails', async () => { + const spyOnGetChoices = jest.spyOn(api, 'getChoices'); + spyOnGetChoices.mockResolvedValue( + Promise.resolve({ + actionId: 'test', + status: 'error', + serviceMessage: 'An error occurred', + }) + ); + + const { waitForNextUpdate } = renderHook(() => + useGetChoices({ + http: services.http, + connector, + toastNotifications: services.notifications.toasts, + fields, + onSuccess, + }) + ); + + await waitForNextUpdate(); + + expect(services.notifications.toasts.addDanger).toHaveBeenCalledWith({ + text: 'An error occurred', + title: 'Unable to get choices', + }); + }); + + it('it displays an error when http throws an error', async () => { + const spyOnGetChoices = jest.spyOn(api, 'getChoices'); + spyOnGetChoices.mockImplementation(() => { + throw new Error('An error occurred'); + }); + + renderHook(() => + useGetChoices({ + http: services.http, + connector, + toastNotifications: services.notifications.toasts, + fields, + onSuccess, + }) + ); + + expect(services.notifications.toasts.addDanger).toHaveBeenCalledWith({ + text: 'An error occurred', + title: 'Unable to get choices', + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/use_get_choices.tsx b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/use_get_choices.tsx new file mode 100644 index 0000000000000..16e905bdabfee --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/servicenow/use_get_choices.tsx @@ -0,0 +1,99 @@ +/* + * 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 { useState, useEffect, useRef } from 'react'; +import { HttpSetup, ToastsApi } from 'kibana/public'; +import { ActionConnector } from '../../../containers/types'; +import { getChoices } from './api'; +import { Choice } from './types'; +import * as i18n from './translations'; + +export interface UseGetChoicesProps { + http: HttpSetup; + toastNotifications: Pick< + ToastsApi, + 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' + >; + connector?: ActionConnector; + fields: string[]; + onSuccess?: (choices: Choice[]) => void; +} + +export interface UseGetChoices { + choices: Choice[]; + isLoading: boolean; +} + +export const useGetChoices = ({ + http, + connector, + toastNotifications, + fields, + onSuccess, +}: UseGetChoicesProps): UseGetChoices => { + const [isLoading, setIsLoading] = useState(false); + const [choices, setChoices] = useState([]); + const abortCtrl = useRef(new AbortController()); + + useEffect(() => { + let didCancel = false; + const fetchData = async () => { + if (!connector) { + setIsLoading(false); + return; + } + + abortCtrl.current = new AbortController(); + setIsLoading(true); + + try { + const res = await getChoices({ + http, + signal: abortCtrl.current.signal, + connectorId: connector.id, + fields, + }); + + if (!didCancel) { + setIsLoading(false); + setChoices(res.data ?? []); + if (res.status && res.status === 'error') { + toastNotifications.addDanger({ + title: i18n.CHOICES_API_ERROR, + text: `${res.serviceMessage ?? res.message}`, + }); + } else if (onSuccess) { + onSuccess(res.data ?? []); + } + } + } catch (error) { + if (!didCancel) { + setIsLoading(false); + toastNotifications.addDanger({ + title: i18n.CHOICES_API_ERROR, + text: error.message, + }); + } + } + }; + + abortCtrl.current.abort(); + fetchData(); + + return () => { + didCancel = true; + setIsLoading(false); + abortCtrl.current.abort(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [http, connector, toastNotifications, fields]); + + return { + choices, + isLoading, + }; +}; diff --git a/x-pack/plugins/security_solution/public/cases/components/connectors/types.ts b/x-pack/plugins/security_solution/public/cases/components/connectors/types.ts index 808e185eabb6f..46c707197fdb4 100644 --- a/x-pack/plugins/security_solution/public/cases/components/connectors/types.ts +++ b/x-pack/plugins/security_solution/public/cases/components/connectors/types.ts @@ -5,14 +5,17 @@ * 2.0. */ -import { ActionType } from '../../../../../triggers_actions_ui/public'; +import React from 'react'; import { ActionType as ThirdPartySupportedActions, CaseField, + ActionConnector, + ConnectorTypeFields, } from '../../../../../case/common/api'; export { ThirdPartyField as AllThirdPartyFields } from '../../../../../case/common/api'; +export type CaseActionConnector = ActionConnector; export interface ThirdPartyField { label: string; @@ -21,6 +24,30 @@ export interface ThirdPartyField { defaultActionType: ThirdPartySupportedActions; } -export interface ConnectorConfiguration extends ActionType { +export interface ConnectorConfiguration { + name: string; logo: string; } + +export interface CaseConnector { + id: string; + fieldsComponent: React.LazyExoticComponent< + React.ComponentType> + > | null; +} + +export interface CaseConnectorsRegistry { + has: (id: string) => boolean; + register: ( + connector: CaseConnector + ) => void; + get: (id: string) => CaseConnector; + list: () => CaseConnector[]; +} + +export interface ConnectorFieldsProps { + isEdit?: boolean; + connector: CaseActionConnector; + fields: TFields; + onChange: (fields: TFields) => void; +} diff --git a/x-pack/plugins/security_solution/public/cases/components/create/connector.test.tsx b/x-pack/plugins/security_solution/public/cases/components/create/connector.test.tsx index 2a361a2f6cdce..236c13e5afc08 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/connector.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/connector.test.tsx @@ -14,8 +14,10 @@ import { useForm, Form, FormHook } from '../../../shared_imports'; import { connectorsMock } from '../../containers/mock'; import { Connector } from './connector'; import { useConnectors } from '../../containers/configure/use_connectors'; -import { useGetIncidentTypes } from '../settings/resilient/use_get_incident_types'; -import { useGetSeverity } from '../settings/resilient/use_get_severity'; +import { useGetIncidentTypes } from '../connectors/resilient/use_get_incident_types'; +import { useGetSeverity } from '../connectors/resilient/use_get_severity'; +import { useGetChoices } from '../connectors/servicenow/use_get_choices'; +import { incidentTypes, severity, choices } from '../connectors/mock'; import { schema, FormProps } from './schema'; jest.mock('../../../common/lib/kibana', () => { @@ -29,43 +31,28 @@ jest.mock('../../../common/lib/kibana', () => { }; }); jest.mock('../../containers/configure/use_connectors'); -jest.mock('../settings/resilient/use_get_incident_types'); -jest.mock('../settings/resilient/use_get_severity'); +jest.mock('../connectors/resilient/use_get_incident_types'); +jest.mock('../connectors/resilient/use_get_severity'); +jest.mock('../connectors/servicenow/use_get_choices'); const useConnectorsMock = useConnectors as jest.Mock; const useGetIncidentTypesMock = useGetIncidentTypes as jest.Mock; const useGetSeverityMock = useGetSeverity as jest.Mock; +const useGetChoicesMock = useGetChoices as jest.Mock; const useGetIncidentTypesResponse = { isLoading: false, - incidentTypes: [ - { - id: 19, - name: 'Malware', - }, - { - id: 21, - name: 'Denial of Service', - }, - ], + incidentTypes, }; const useGetSeverityResponse = { isLoading: false, - severity: [ - { - id: 4, - name: 'Low', - }, - { - id: 5, - name: 'Medium', - }, - { - id: 6, - name: 'High', - }, - ], + severity, +}; + +const useGetChoicesResponse = { + isLoading: false, + choices, }; describe('Connector', () => { @@ -90,6 +77,7 @@ describe('Connector', () => { useConnectorsMock.mockReturnValue({ loading: false, connectors: connectorsMock }); useGetIncidentTypesMock.mockReturnValue(useGetIncidentTypesResponse); useGetSeverityMock.mockReturnValue(useGetSeverityResponse); + useGetChoicesMock.mockReturnValue(useGetChoicesResponse); }); it('it renders', async () => { @@ -100,7 +88,7 @@ describe('Connector', () => { ); expect(wrapper.find(`[data-test-subj="caseConnectors"]`).exists()).toBeTruthy(); - expect(wrapper.find(`[data-test-subj="connector-settings"]`).exists()).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="connector-fields"]`).exists()).toBeTruthy(); await waitFor(() => { expect(wrapper.find(`button[data-test-subj="dropdown-connectors"]`).first().text()).toBe( @@ -108,10 +96,10 @@ describe('Connector', () => { ); }); - await waitFor(() => { - wrapper.update(); - expect(wrapper.find(`[data-test-subj="connector-settings-sn"]`).exists()).toBeTruthy(); - }); + // await waitFor(() => { + // wrapper.update(); + // expect(wrapper.find(`[data-test-subj="connector-fields-sn"]`).exists()).toBeTruthy(); + // }); }); it('it is loading when fetching connectors', async () => { @@ -163,7 +151,7 @@ describe('Connector', () => { ); await waitFor(() => { - expect(wrapper.find(`[data-test-subj="connector-settings-resilient"]`).exists()).toBeFalsy(); + expect(wrapper.find(`[data-test-subj="connector-fields-resilient"]`).exists()).toBeFalsy(); wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); wrapper.find(`button[data-test-subj="dropdown-connector-resilient-2"]`).simulate('click'); wrapper.update(); @@ -171,7 +159,7 @@ describe('Connector', () => { await waitFor(() => { wrapper.update(); - expect(wrapper.find(`[data-test-subj="connector-settings-resilient"]`).exists()).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="connector-fields-resilient"]`).exists()).toBeTruthy(); }); act(() => { diff --git a/x-pack/plugins/security_solution/public/cases/components/create/connector.tsx b/x-pack/plugins/security_solution/public/cases/components/create/connector.tsx index 4a8b25f4f7b45..5e7972aec9d4b 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/connector.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/connector.tsx @@ -5,13 +5,13 @@ * 2.0. */ -import React, { memo, useEffect } from 'react'; +import React, { memo, useCallback } from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { UseField, useFormData, FieldHook } from '../../../shared_imports'; +import { UseField, useFormData, FieldHook, useFormContext } from '../../../shared_imports'; import { useConnectors } from '../../containers/configure/use_connectors'; import { ConnectorSelector } from '../connector_selector/form'; -import { SettingFieldsForm } from '../settings/fields_form'; +import { ConnectorFieldsForm } from '../connectors/fields_form'; import { ActionConnector } from '../../containers/types'; import { getConnectorById } from '../configure_cases/utils'; import { FormProps } from './schema'; @@ -20,25 +20,19 @@ interface Props { isLoading: boolean; } -interface SettingsFieldProps { +interface ConnectorsFieldProps { connectors: ActionConnector[]; field: FieldHook; isEdit: boolean; } -const SettingsField = ({ connectors, isEdit, field }: SettingsFieldProps) => { +const ConnectorFields = ({ connectors, isEdit, field }: ConnectorsFieldProps) => { const [{ connectorId }] = useFormData({ watch: ['connectorId'] }); const { setValue } = field; const connector = getConnectorById(connectorId, connectors) ?? null; - useEffect(() => { - if (connectorId) { - setValue(null); - } - }, [setValue, connectorId]); - return ( - { }; const ConnectorComponent: React.FC = ({ isLoading }) => { + const { getFields } = useFormContext(); const { loading: isLoadingConnectors, connectors } = useConnectors(); + const handleConnectorChange = useCallback( + (newConnector) => { + const { fields } = getFields(); + fields.setValue(null); + }, + [getFields] + ); return ( @@ -58,6 +60,7 @@ const ConnectorComponent: React.FC = ({ isLoading }) => { component={ConnectorSelector} componentProps={{ connectors, + handleChange: handleConnectorChange, dataTestSubj: 'caseConnectors', disabled: isLoading || isLoadingConnectors, idAria: 'caseConnectors', @@ -68,7 +71,7 @@ const ConnectorComponent: React.FC = ({ isLoading }) => { { @@ -189,7 +186,7 @@ describe('Create case', () => { connector: { id: 'servicenow-1', name: 'SN', - type: ConnectorTypes.servicenow, + type: ConnectorTypes.serviceNowITSM, fields: null, }, persistLoading: false, @@ -237,7 +234,7 @@ describe('Create case', () => { connector: { id: 'not-exist', name: 'SN', - type: ConnectorTypes.servicenow, + type: ConnectorTypes.serviceNowITSM, fields: null, }, persistLoading: false, @@ -261,7 +258,7 @@ describe('Create case', () => { wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); await waitFor(() => { expect(postCase).toBeCalledWith(sampleData); - expect(postPushToService).not.toHaveBeenCalled(); + expect(pushCaseToExternalService).not.toHaveBeenCalled(); }); }); }); @@ -283,13 +280,13 @@ describe('Create case', () => { ); fillForm(wrapper); - expect(wrapper.find(`[data-test-subj="connector-settings-jira"]`).exists()).toBeFalsy(); + expect(wrapper.find(`[data-test-subj="connector-fields-jira"]`).exists()).toBeFalsy(); wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); wrapper.find(`button[data-test-subj="dropdown-connector-jira-1"]`).simulate('click'); await waitFor(() => { wrapper.update(); - expect(wrapper.find(`[data-test-subj="connector-settings-jira"]`).exists()).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="connector-fields-jira"]`).exists()).toBeTruthy(); }); wrapper @@ -318,17 +315,14 @@ describe('Create case', () => { fields: { issueType: '10007', parent: null, priority: '2' }, }, }); - expect(postPushToService).toHaveBeenCalledWith({ + expect(pushCaseToExternalService).toHaveBeenCalledWith({ caseId: sampleId, - caseServices: {}, connector: { id: 'jira-1', name: 'Jira', type: '.jira', fields: { issueType: '10007', parent: null, priority: '2' }, }, - alerts: {}, - updateCase: noop, }); expect(onFormSubmitSuccess).toHaveBeenCalledWith({ id: sampleId, @@ -353,15 +347,13 @@ describe('Create case', () => { ); fillForm(wrapper); - expect(wrapper.find(`[data-test-subj="connector-settings-resilient"]`).exists()).toBeFalsy(); + expect(wrapper.find(`[data-test-subj="connector-fields-resilient"]`).exists()).toBeFalsy(); wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); wrapper.find(`button[data-test-subj="dropdown-connector-resilient-2"]`).simulate('click'); await waitFor(() => { wrapper.update(); - expect( - wrapper.find(`[data-test-subj="connector-settings-resilient"]`).exists() - ).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="connector-fields-resilient"]`).exists()).toBeTruthy(); }); act(() => { @@ -390,17 +382,14 @@ describe('Create case', () => { }, }); - expect(postPushToService).toHaveBeenCalledWith({ + expect(pushCaseToExternalService).toHaveBeenCalledWith({ caseId: sampleId, - caseServices: {}, connector: { id: 'resilient-2', name: 'My Connector 2', type: '.resilient', fields: { incidentTypes: ['19'], severityCode: '4' }, }, - alerts: {}, - updateCase: noop, }); expect(onFormSubmitSuccess).toHaveBeenCalledWith({ @@ -426,10 +415,10 @@ describe('Create case', () => { ); fillForm(wrapper); - expect(wrapper.find(`[data-test-subj="connector-settings-sn"]`).exists()).toBeFalsy(); + expect(wrapper.find(`[data-test-subj="connector-fields-sn"]`).exists()).toBeFalsy(); wrapper.find('button[data-test-subj="dropdown-connectors"]').simulate('click'); wrapper.find(`button[data-test-subj="dropdown-connector-servicenow-1"]`).simulate('click'); - expect(wrapper.find(`[data-test-subj="connector-settings-sn"]`).exists()).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="connector-fields-sn"]`).exists()).toBeTruthy(); ['severitySelect', 'urgencySelect', 'impactSelect'].forEach((subj) => { wrapper @@ -453,17 +442,14 @@ describe('Create case', () => { }, }); - expect(postPushToService).toHaveBeenCalledWith({ + expect(pushCaseToExternalService).toHaveBeenCalledWith({ caseId: sampleId, - caseServices: {}, connector: { id: 'servicenow-1', name: 'My Connector', type: '.servicenow', fields: { impact: '2', severity: '2', urgency: '2' }, }, - alerts: {}, - updateCase: noop, }); expect(onFormSubmitSuccess).toHaveBeenCalledWith({ diff --git a/x-pack/plugins/security_solution/public/cases/components/create/form_context.tsx b/x-pack/plugins/security_solution/public/cases/components/create/form_context.tsx index 20ec1e9177cd3..cc38e07cf49e4 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/form_context.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/form_context.tsx @@ -6,7 +6,6 @@ */ import React, { useCallback, useEffect, useMemo } from 'react'; -import { noop } from 'lodash/fp'; import { schema, FormProps } from './schema'; import { Form, useForm } from '../../../shared_imports'; import { @@ -38,7 +37,7 @@ export const FormContext: React.FC = ({ children, onSuccess }) => { const { connectors } = useConnectors(); const { connector: configurationConnector } = useCaseConfigure(); const { postCase } = usePostCase(); - const { postPushToService } = usePostPushToService(); + const { pushCaseToExternalService } = usePostPushToService(); const connectorId = useMemo( () => @@ -67,12 +66,9 @@ export const FormContext: React.FC = ({ children, onSuccess }) => { }); if (updatedCase?.id && dataConnectorId !== 'none') { - await postPushToService({ + await pushCaseToExternalService({ caseId: updatedCase.id, - caseServices: {}, connector: connectorToUpdate, - alerts: {}, - updateCase: noop, }); } @@ -81,7 +77,7 @@ export const FormContext: React.FC = ({ children, onSuccess }) => { } } }, - [connectors, postCase, onSuccess, postPushToService] + [connectors, postCase, onSuccess, pushCaseToExternalService] ); const { form } = useForm({ diff --git a/x-pack/plugins/security_solution/public/cases/components/create/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/create/index.test.tsx index f43aecdc123a6..7172d227f492e 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/index.test.tsx @@ -16,10 +16,10 @@ import { useGetTags } from '../../containers/use_get_tags'; import { useConnectors } from '../../containers/configure/use_connectors'; import { useCaseConfigure } from '../../containers/configure/use_configure'; import { Router, routeData, mockHistory, mockLocation } from '../__mock__/router'; -import { useGetIncidentTypes } from '../settings/resilient/use_get_incident_types'; -import { useGetSeverity } from '../settings/resilient/use_get_severity'; -import { useGetIssueTypes } from '../settings/jira/use_get_issue_types'; -import { useGetFieldsByIssueType } from '../settings/jira/use_get_fields_by_issue_type'; +import { useGetIncidentTypes } from '../connectors/resilient/use_get_incident_types'; +import { useGetSeverity } from '../connectors/resilient/use_get_severity'; +import { useGetIssueTypes } from '../connectors/jira/use_get_issue_types'; +import { useGetFieldsByIssueType } from '../connectors/jira/use_get_fields_by_issue_type'; import { useCaseConfigureResponse } from '../configure_cases/__mock__'; import { useInsertTimeline } from '../use_insert_timeline'; import { @@ -37,12 +37,12 @@ jest.mock('../../containers/api'); jest.mock('../../containers/use_get_tags'); jest.mock('../../containers/configure/use_connectors'); jest.mock('../../containers/configure/use_configure'); -jest.mock('../settings/resilient/use_get_incident_types'); -jest.mock('../settings/resilient/use_get_severity'); -jest.mock('../settings/jira/use_get_issue_types'); -jest.mock('../settings/jira/use_get_fields_by_issue_type'); -jest.mock('../settings/jira/use_get_single_issue'); -jest.mock('../settings/jira/use_get_issues'); +jest.mock('../connectors/resilient/use_get_incident_types'); +jest.mock('../connectors/resilient/use_get_severity'); +jest.mock('../connectors/jira/use_get_issue_types'); +jest.mock('../connectors/jira/use_get_fields_by_issue_type'); +jest.mock('../connectors/jira/use_get_single_issue'); +jest.mock('../connectors/jira/use_get_issues'); jest.mock('../use_insert_timeline'); const useConnectorsMock = useConnectors as jest.Mock; diff --git a/x-pack/plugins/security_solution/public/cases/components/edit_connector/index.tsx b/x-pack/plugins/security_solution/public/cases/components/edit_connector/index.tsx index 21a87e3a64ac0..34dcacaf42a98 100644 --- a/x-pack/plugins/security_solution/public/cases/components/edit_connector/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/edit_connector/index.tsx @@ -23,8 +23,8 @@ import { noop } from 'lodash/fp'; import { Form, UseField, useForm } from '../../../shared_imports'; import { ConnectorTypeFields } from '../../../../../case/common/api/connectors'; import { ConnectorSelector } from '../connector_selector/form'; -import { ActionConnector } from '../../../../../case/common/api/cases'; -import { SettingFieldsForm } from '../settings/fields_form'; +import { ActionConnector } from '../../../../../case/common/api'; +import { ConnectorFieldsForm } from '../connectors/fields_form'; import { getConnectorById } from '../configure_cases/utils'; import { CaseUserActions } from '../../containers/types'; import { schema } from './schema'; @@ -244,7 +244,7 @@ export const EditConnector = React.memo( - + {(currentConnector == null || currentConnector?.id === 'none') && // Connector is none or not defined. !(currentConnector === null && selectedConnector !== 'none') && // Connector has not been deleted. !editConnector && ( @@ -252,7 +252,7 @@ export const EditConnector = React.memo( {i18n.NO_CONNECTOR} )} - (getJiraCaseSetting()); - this.caseSettingsRegistry.register(getResilientCaseSetting()); - this.caseSettingsRegistry.register(getServiceNowCaseSetting()); - } - - registry(): CaseSettingsRegistry { - return this.caseSettingsRegistry; - } -} - -const caseSettings = new CaseSettings(); - -export const getCaseSettings = (): GetCaseSettingReturn => { - return { - caseSettingsRegistry: caseSettings.registry(), - }; -}; diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/mock.ts b/x-pack/plugins/security_solution/public/cases/components/settings/mock.ts deleted file mode 100644 index 69f30b488d9a6..0000000000000 --- a/x-pack/plugins/security_solution/public/cases/components/settings/mock.ts +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export const connector = { - id: '123', - name: 'My connector', - actionTypeId: '.jira', - config: {}, - isPreconfigured: false, -}; -export const issues = [ - { id: 'personId', title: 'Person Task', key: 'personKey' }, - { id: 'womanId', title: 'Woman Task', key: 'womanKey' }, - { id: 'manId', title: 'Man Task', key: 'manKey' }, - { id: 'cameraId', title: 'Camera Task', key: 'cameraKey' }, - { id: 'tvId', title: 'TV Task', key: 'tvKey' }, -]; diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/servicenow/fields.tsx b/x-pack/plugins/security_solution/public/cases/components/settings/servicenow/fields.tsx deleted file mode 100644 index 161e4d44cd572..0000000000000 --- a/x-pack/plugins/security_solution/public/cases/components/settings/servicenow/fields.tsx +++ /dev/null @@ -1,130 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { useCallback, useEffect, useMemo } from 'react'; -import { EuiFormRow, EuiSelect, EuiSpacer, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import * as i18n from './translations'; - -import { SettingFieldsProps } from '../types'; -import { ConnectorTypes, ServiceNowFieldsType } from '../../../../../../case/common/api/connectors'; -import { ConnectorCard } from '../card'; - -const selectOptions = [ - { - value: '1', - text: i18n.SEVERITY_HIGH, - }, - { - value: '2', - text: i18n.SEVERITY_MEDIUM, - }, - { - value: '3', - text: i18n.SEVERITY_LOW, - }, -]; - -const ServiceNowSettingFieldsComponent: React.FunctionComponent< - SettingFieldsProps -> = ({ isEdit = true, fields, connector, onChange }) => { - const { severity = null, urgency = null, impact = null } = fields ?? {}; - - const listItems = useMemo( - () => [ - ...(urgency != null && urgency.length > 0 - ? [ - { - title: i18n.URGENCY, - description: selectOptions.find((option) => `${option.value}` === urgency)?.text, - }, - ] - : []), - ...(severity != null && severity.length > 0 - ? [ - { - title: i18n.SEVERITY, - description: selectOptions.find((option) => `${option.value}` === severity)?.text, - }, - ] - : []), - ...(impact != null && impact.length > 0 - ? [ - { - title: i18n.IMPACT, - description: selectOptions.find((option) => `${option.value}` === impact)?.text, - }, - ] - : []), - ], - [urgency, severity, impact] - ); - - // We need to set them up at initialization - useEffect(() => { - onChange({ impact, severity, urgency }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const onChangeCb = useCallback( - (key: keyof ServiceNowFieldsType, value: ServiceNowFieldsType[keyof ServiceNowFieldsType]) => { - onChange({ ...fields, [key]: value }); - }, - [fields, onChange] - ); - - return isEdit ? ( - - - onChangeCb('urgency', e.target.value)} - /> - - - - - - onChangeCb('severity', e.target.value)} - /> - - - - - onChangeCb('impact', e.target.value)} - /> - - - - - ) : ( - - ); -}; - -// eslint-disable-next-line import/no-default-export -export { ServiceNowSettingFieldsComponent as default }; diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/servicenow/index.ts b/x-pack/plugins/security_solution/public/cases/components/settings/servicenow/index.ts deleted file mode 100644 index 70d1bf89ce7c8..0000000000000 --- a/x-pack/plugins/security_solution/public/cases/components/settings/servicenow/index.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { lazy } from 'react'; - -import { CaseSetting } from '../types'; -import { ServiceNowFieldsType } from '../../../../../../case/common/api/connectors'; -import * as i18n from './translations'; - -export const getCaseSetting = (): CaseSetting => { - return { - id: '.servicenow', - caseSettingFieldsComponent: lazy(() => import('./fields')), - }; -}; - -export const fieldLabels = { - impact: i18n.IMPACT, - severity: i18n.SEVERITY, - urgency: i18n.URGENCY, -}; diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/servicenow/translations.ts b/x-pack/plugins/security_solution/public/cases/components/settings/servicenow/translations.ts deleted file mode 100644 index 6db239541851e..0000000000000 --- a/x-pack/plugins/security_solution/public/cases/components/settings/servicenow/translations.ts +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { i18n } from '@kbn/i18n'; - -export const SEVERITY_HIGH = i18n.translate( - 'xpack.securitySolution.components.settings.servicenow.severitySelectHighOptionLabel', - { - defaultMessage: 'High', - } -); -export const SEVERITY_MEDIUM = i18n.translate( - 'xpack.securitySolution.components.settings.servicenow.severitySelectMediumOptionLabel', - { - defaultMessage: 'Medium', - } -); - -export const SEVERITY_LOW = i18n.translate( - 'xpack.securitySolution.components.settings.servicenow.severitySelectLowOptionLabel', - { - defaultMessage: 'Low', - } -); - -export const URGENCY = i18n.translate( - 'xpack.securitySolution.components.settings.serviceNow.urgencySelectFieldLabel', - { - defaultMessage: 'Urgency', - } -); - -export const SEVERITY = i18n.translate( - 'xpack.securitySolution.components.settings.serviceNow.severitySelectFieldLabel', - { - defaultMessage: 'Severity', - } -); - -export const IMPACT = i18n.translate( - 'xpack.securitySolution.components.settings.serviceNow.impactSelectFieldLabel', - { - defaultMessage: 'Impact', - } -); diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/settings_registry.ts b/x-pack/plugins/security_solution/public/cases/components/settings/settings_registry.ts deleted file mode 100644 index a5580aaf587b2..0000000000000 --- a/x-pack/plugins/security_solution/public/cases/components/settings/settings_registry.ts +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { i18n } from '@kbn/i18n'; -import { CaseSetting, CaseSettingsRegistry } from './types'; - -/* eslint-disable @typescript-eslint/no-explicit-any */ - -export const createCaseSettingsRegistry = (): CaseSettingsRegistry => { - const settings: Map> = new Map(); - - const registry: CaseSettingsRegistry = { - has: (id: string) => settings.has(id), - register: (setting: CaseSetting) => { - if (settings.has(setting.id)) { - throw new Error( - i18n.translate( - 'xpack.securitySolution.caseSettingsRegistry.register.duplicateCaseSettingErrorMessage', - { - defaultMessage: 'Object type "{id}" is already registered.', - values: { - id: setting.id, - }, - } - ) - ); - } - - settings.set(setting.id, setting); - }, - get: (id: string): CaseSetting => { - if (!settings.has(id)) { - throw new Error( - i18n.translate( - 'xpack.securitySolution.caseSettingsRegistry.get.missingCaseSettingErrorMessage', - { - defaultMessage: 'Object type "{id}" is not registered.', - values: { - id, - }, - } - ) - ); - } - return settings.get(id)!; - }, - list: () => { - return Array.from(settings).map(([id, setting]) => setting); - }, - }; - - return registry; -}; diff --git a/x-pack/plugins/security_solution/public/cases/components/settings/types.ts b/x-pack/plugins/security_solution/public/cases/components/settings/types.ts deleted file mode 100644 index 9f212b1999e3d..0000000000000 --- a/x-pack/plugins/security_solution/public/cases/components/settings/types.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { ActionConnector } from '../../../../../case/common/api'; - -import { ConnectorTypeFields } from '../../../../../case/common/api/connectors'; -export type CaseSettingsConnector = ActionConnector; - -export interface CaseSetting { - id: string; - caseSettingFieldsComponent: React.LazyExoticComponent< - React.ComponentType> - > | null; -} - -export interface CaseSettingsRegistry { - has: (id: string) => boolean; - register: (setting: CaseSetting) => void; - get: (id: string) => CaseSetting; - list: () => CaseSetting[]; -} - -export interface SettingFieldsProps { - isEdit?: boolean; - connector: CaseSettingsConnector; - fields: TFields; - onChange: (fields: TFields) => void; -} diff --git a/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/index.test.tsx index 63838b1bc6b8d..b8048afb083f1 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/index.test.tsx @@ -39,10 +39,10 @@ jest.mock('../../containers/configure/api'); describe('usePushToService', () => { const caseId = '12345'; const updateCase = jest.fn(); - const postPushToService = jest.fn(); + const pushCaseToExternalService = jest.fn(); const mockPostPush = { isLoading: false, - postPushToService, + pushCaseToExternalService, }; const mockConnector = connectorsMock[0]; @@ -61,7 +61,7 @@ describe('usePushToService', () => { connector: { id: mockConnector.id, name: mockConnector.name, - type: ConnectorTypes.servicenow, + type: ConnectorTypes.serviceNowITSM, fields: null, }, caseId, @@ -71,19 +71,6 @@ describe('usePushToService', () => { updateCase, userCanCrud: true, isValidConnector: true, - alerts: { - 'alert-id-1': { - _id: 'alert-id-1', - _index: 'alert-index-1', - '@timestamp': '2020-11-20T15:35:28.373Z', - rule: { - id: 'rule-id-1', - name: 'Awesome rule', - from: 'now-360s', - to: 'now', - }, - }, - }, }; beforeEach(() => { @@ -105,28 +92,13 @@ describe('usePushToService', () => { ); await waitForNextUpdate(); result.current.pushButton.props.children.props.onClick(); - expect(postPushToService).toBeCalledWith({ + expect(pushCaseToExternalService).toBeCalledWith({ caseId, - caseServices, connector: { fields: null, id: 'servicenow-1', name: 'My Connector', - type: ConnectorTypes.servicenow, - }, - updateCase, - alerts: { - 'alert-id-1': { - _id: 'alert-id-1', - _index: 'alert-index-1', - '@timestamp': '2020-11-20T15:35:28.373Z', - rule: { - id: 'rule-id-1', - name: 'Awesome rule', - from: 'now-360s', - to: 'now', - }, - }, + type: ConnectorTypes.serviceNowITSM, }, }); expect(result.current.pushCallouts).toBeNull(); diff --git a/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/index.tsx b/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/index.tsx index ed03ce36bf26c..21067a3e69969 100644 --- a/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/use_push_to_service/index.tsx @@ -22,7 +22,6 @@ import { CaseServices } from '../../containers/use_get_case_user_actions'; import { LinkAnchor } from '../../../common/components/links'; import { SecurityPageName } from '../../../app/types'; import { ErrorMessage } from '../callout/types'; -import { Alert } from '../case_view'; export interface UsePushToService { caseId: string; @@ -33,7 +32,6 @@ export interface UsePushToService { updateCase: (newCase: Case) => void; userCanCrud: boolean; isValidConnector: boolean; - alerts: Record; } export interface ReturnUsePushToService { @@ -50,25 +48,25 @@ export const usePushToService = ({ updateCase, userCanCrud, isValidConnector, - alerts, }: UsePushToService): ReturnUsePushToService => { const history = useHistory(); const { formatUrl, search: urlSearch } = useFormatUrl(SecurityPageName.case); - const { isLoading, postPushToService } = usePostPushToService(); + const { isLoading, pushCaseToExternalService } = usePostPushToService(); const { isLoading: loadingLicense, actionLicense } = useGetActionLicense(); - const handlePushToService = useCallback(() => { + const handlePushToService = useCallback(async () => { if (connector.id != null && connector.id !== 'none') { - postPushToService({ + const theCase = await pushCaseToExternalService({ caseId, - caseServices, connector, - updateCase, - alerts, }); + + if (theCase != null) { + updateCase(theCase); + } } - }, [alerts, caseId, caseServices, connector, postPushToService, updateCase]); + }, [caseId, connector, pushCaseToExternalService, updateCase]); const goToConfigureCases = useCallback( (ev) => { diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx index c5d7610aed9ba..4a567a38dc9f2 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/index.tsx @@ -24,7 +24,7 @@ import { Case, CaseUserActions } from '../../containers/types'; import { useUpdateComment } from '../../containers/use_update_comment'; import { useCurrentUser } from '../../../common/lib/kibana'; import { AddComment, AddCommentRefObject } from '../add_comment'; -import { ActionConnector, CommentType } from '../../../../../case/common/api/cases'; +import { ActionConnector, CommentType } from '../../../../../case/common/api'; import { CaseServices } from '../../containers/use_get_case_user_actions'; import { parseString } from '../../containers/utils'; import { Alert, OnUpdateFields } from '../case_view'; diff --git a/x-pack/plugins/security_solution/public/cases/containers/__mocks__/api.ts b/x-pack/plugins/security_solution/public/cases/containers/__mocks__/api.ts index 13b9bc670a4fd..ab761309fa6ad 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/__mocks__/api.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/__mocks__/api.ts @@ -25,16 +25,12 @@ import { caseUserActions, pushedCase, respReporters, - serviceConnector, tags, } from '../mock'; import { - CaseExternalServiceRequest, CasePatchRequest, CasePostRequest, CommentRequest, - ServiceConnectorCaseParams, - ServiceConnectorCaseResponse, User, CaseStatuses, } from '../../../../../case/common/api'; @@ -110,15 +106,9 @@ export const deleteCases = async (caseIds: string[], signal: AbortSignal): Promi export const pushCase = async ( caseId: string, - push: CaseExternalServiceRequest, - signal: AbortSignal -): Promise => Promise.resolve(pushedCase); - -export const pushToService = async ( connectorId: string, - casePushParams: ServiceConnectorCaseParams, signal: AbortSignal -): Promise => Promise.resolve(serviceConnector); +): Promise => Promise.resolve(pushedCase); export const getActionLicense = async (signal: AbortSignal): Promise => Promise.resolve(actionLicenses); diff --git a/x-pack/plugins/security_solution/public/cases/containers/api.test.tsx b/x-pack/plugins/security_solution/public/cases/containers/api.test.tsx index b3e92f24ce2b3..ee63749b49435 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/api.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/api.test.tsx @@ -25,7 +25,6 @@ import { postCase, postComment, pushCase, - pushToService, } from './api'; import { @@ -34,26 +33,20 @@ import { basicCase, allCasesSnake, basicCaseSnake, - actionTypeExecutorResult, pushedCaseSnake, casesStatus, casesSnake, cases, caseUserActions, pushedCase, - pushSnake, reporters, respReporters, - serviceConnector, - casePushParams, tags, caseUserActionsSnake, casesStatusSnake, } from './mock'; import { DEFAULT_FILTER_OPTIONS, DEFAULT_QUERY_PARAMS } from './use_get_cases'; -import * as i18n from './translations'; -import { getCaseConfigurePushUrl } from '../../../../case/common/api/helpers'; const abortCtrl = new AbortController(); const mockKibanaServices = KibanaServices.get as jest.Mock; @@ -84,11 +77,13 @@ describe('Case Configuration API', () => { expect(resp).toEqual(''); }); }); + describe('getActionLicense', () => { beforeEach(() => { fetchMock.mockClear(); fetchMock.mockResolvedValue(actionLicenses); }); + test('check url, method, signal', async () => { await getActionLicense(abortCtrl.signal); expect(fetchMock).toHaveBeenCalledWith(`/api/actions/list_action_types`, { @@ -102,6 +97,7 @@ describe('Case Configuration API', () => { expect(resp).toEqual(actionLicenses); }); }); + describe('getCase', () => { beforeEach(() => { fetchMock.mockClear(); @@ -123,6 +119,7 @@ describe('Case Configuration API', () => { expect(resp).toEqual(basicCase); }); }); + describe('getCases', () => { beforeEach(() => { fetchMock.mockClear(); @@ -145,6 +142,7 @@ describe('Case Configuration API', () => { signal: abortCtrl.signal, }); }); + test('correctly applies filters', async () => { await getCases({ filterOptions: { @@ -169,6 +167,7 @@ describe('Case Configuration API', () => { signal: abortCtrl.signal, }); }); + test('tags with weird chars get handled gracefully', async () => { const weirdTags: string[] = ['(', '"double"']; @@ -205,6 +204,7 @@ describe('Case Configuration API', () => { expect(resp).toEqual({ ...allCases }); }); }); + describe('getCasesStatus', () => { beforeEach(() => { fetchMock.mockClear(); @@ -223,6 +223,7 @@ describe('Case Configuration API', () => { expect(resp).toEqual(casesStatus); }); }); + describe('getCaseUserActions', () => { beforeEach(() => { fetchMock.mockClear(); @@ -242,6 +243,7 @@ describe('Case Configuration API', () => { expect(resp).toEqual(caseUserActions); }); }); + describe('getReporters', () => { beforeEach(() => { fetchMock.mockClear(); @@ -261,6 +263,7 @@ describe('Case Configuration API', () => { expect(resp).toEqual(respReporters); }); }); + describe('getTags', () => { beforeEach(() => { fetchMock.mockClear(); @@ -280,6 +283,7 @@ describe('Case Configuration API', () => { expect(resp).toEqual(tags); }); }); + describe('patchCase', () => { beforeEach(() => { fetchMock.mockClear(); @@ -307,6 +311,7 @@ describe('Case Configuration API', () => { expect(resp).toEqual({ ...[basicCase] }); }); }); + describe('patchCasesStatus', () => { beforeEach(() => { fetchMock.mockClear(); @@ -334,6 +339,7 @@ describe('Case Configuration API', () => { expect(resp).toEqual({ ...cases }); }); }); + describe('patchComment', () => { beforeEach(() => { fetchMock.mockClear(); @@ -371,6 +377,7 @@ describe('Case Configuration API', () => { expect(resp).toEqual(basicCase); }); }); + describe('postCase', () => { beforeEach(() => { fetchMock.mockClear(); @@ -405,6 +412,7 @@ describe('Case Configuration API', () => { expect(resp).toEqual(basicCase); }); }); + describe('postComment', () => { beforeEach(() => { fetchMock.mockClear(); @@ -429,88 +437,30 @@ describe('Case Configuration API', () => { expect(resp).toEqual(basicCase); }); }); + describe('pushCase', () => { + const connectorId = 'connectorId'; + beforeEach(() => { fetchMock.mockClear(); fetchMock.mockResolvedValue(pushedCaseSnake); }); test('check url, method, signal', async () => { - await pushCase(basicCase.id, pushSnake, abortCtrl.signal); - expect(fetchMock).toHaveBeenCalledWith(`${CASES_URL}/${basicCase.id}/_push`, { - method: 'POST', - body: JSON.stringify(pushSnake), - signal: abortCtrl.signal, - }); + await pushCase(basicCase.id, connectorId, abortCtrl.signal); + expect(fetchMock).toHaveBeenCalledWith( + `${CASES_URL}/${basicCase.id}/connector/${connectorId}/_push`, + { + method: 'POST', + body: JSON.stringify({}), + signal: abortCtrl.signal, + } + ); }); test('happy path', async () => { - const resp = await pushCase(basicCase.id, pushSnake, abortCtrl.signal); + const resp = await pushCase(basicCase.id, connectorId, abortCtrl.signal); expect(resp).toEqual(pushedCase); }); }); - describe('pushToService', () => { - beforeEach(() => { - fetchMock.mockClear(); - fetchMock.mockResolvedValue(actionTypeExecutorResult); - }); - const connectorId = 'connectorId'; - test('check url, method, signal', async () => { - await pushToService(connectorId, ConnectorTypes.jira, casePushParams, abortCtrl.signal); - expect(fetchMock).toHaveBeenCalledWith(`${getCaseConfigurePushUrl(connectorId)}`, { - method: 'POST', - body: JSON.stringify({ - connector_type: ConnectorTypes.jira, - params: casePushParams, - }), - signal: abortCtrl.signal, - }); - }); - - test('happy path', async () => { - const resp = await pushToService( - connectorId, - ConnectorTypes.jira, - casePushParams, - abortCtrl.signal - ); - expect(resp).toEqual(serviceConnector); - }); - - test('unhappy path - serviceMessage', async () => { - const theError = 'the error'; - fetchMock.mockResolvedValue({ - ...actionTypeExecutorResult, - status: 'error', - serviceMessage: theError, - message: 'not it', - }); - await expect( - pushToService(connectorId, ConnectorTypes.jira, casePushParams, abortCtrl.signal) - ).rejects.toMatchObject({ message: theError }); - }); - - test('unhappy path - message', async () => { - const theError = 'the error'; - fetchMock.mockResolvedValue({ - ...actionTypeExecutorResult, - status: 'error', - message: theError, - }); - await expect( - pushToService(connectorId, ConnectorTypes.jira, casePushParams, abortCtrl.signal) - ).rejects.toMatchObject({ message: theError }); - }); - - test('unhappy path - no message', async () => { - const theError = i18n.ERROR_PUSH_TO_SERVICE; - fetchMock.mockResolvedValue({ - ...actionTypeExecutorResult, - status: 'error', - }); - await expect( - pushToService(connectorId, ConnectorTypes.jira, casePushParams, abortCtrl.signal) - ).rejects.toMatchObject({ message: theError }); - }); - }); }); diff --git a/x-pack/plugins/security_solution/public/cases/containers/api.ts b/x-pack/plugins/security_solution/public/cases/containers/api.ts index 22e6c92da8ceb..00a45aadd2ae0 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/api.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/api.ts @@ -6,7 +6,6 @@ */ import { - CaseExternalServiceRequest, CasePatchRequest, CasePostRequest, CaseResponse, @@ -17,8 +16,6 @@ import { CaseUserActionsResponse, CommentRequest, CommentType, - ServiceConnectorCaseParams, - ServiceConnectorCaseResponse, User, } from '../../../../case/common/api'; @@ -32,7 +29,7 @@ import { import { getCaseCommentsUrl, - getCaseConfigurePushUrl, + getCasePushUrl, getCaseDetailsUrl, getCaseUserActionUrl, } from '../../../../case/common/api/helpers'; @@ -59,10 +56,8 @@ import { decodeCasesFindResponse, decodeCasesStatusResponse, decodeCaseUserActionsResponse, - decodeServiceConnectorCaseResponse, } from './utils'; -import * as i18n from './translations'; -import { ActionTypeExecutorResult } from '../../../../actions/common'; + export const getCase = async ( caseId: string, includeComments: boolean = true, @@ -231,41 +226,19 @@ export const deleteCases = async (caseIds: string[], signal: AbortSignal): Promi export const pushCase = async ( caseId: string, - push: CaseExternalServiceRequest, + connectorId: string, signal: AbortSignal ): Promise => { const response = await KibanaServices.get().http.fetch( - `${getCaseDetailsUrl(caseId)}/_push`, + getCasePushUrl(caseId, connectorId), { method: 'POST', - body: JSON.stringify(push), + body: JSON.stringify({}), signal, } ); - return convertToCamelCase(decodeCaseResponse(response)); -}; -export const pushToService = async ( - connectorId: string, - connectorType: string, - casePushParams: ServiceConnectorCaseParams, - signal: AbortSignal -): Promise => { - const response = await KibanaServices.get().http.fetch< - ActionTypeExecutorResult> - >(`${getCaseConfigurePushUrl(connectorId)}`, { - method: 'POST', - body: JSON.stringify({ - connector_type: connectorType, - params: casePushParams, - }), - signal, - }); - - if (response.status === 'error') { - throw new Error(response.serviceMessage ?? response.message ?? i18n.ERROR_PUSH_TO_SERVICE); - } - return decodeServiceConnectorCaseResponse(response.data); + return convertToCamelCase(decodeCaseResponse(response)); }; export const getActionLicense = async (signal: AbortSignal): Promise => { diff --git a/x-pack/plugins/security_solution/public/cases/containers/mock.ts b/x-pack/plugins/security_solution/public/cases/containers/mock.ts index 06983a92b9ea1..444a87a57d251 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/mock.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/mock.ts @@ -9,7 +9,6 @@ import { ActionLicense, AllCases, Case, CasesStatus, CaseUserActions, Comment } import { CommentResponse, - ServiceConnectorCaseResponse, CaseStatuses, UserAction, UserActionField, @@ -29,17 +28,13 @@ const basicCommentId = 'basic-comment-id'; const basicCreatedAt = '2020-02-19T23:06:33.798Z'; const basicUpdatedAt = '2020-02-20T15:02:57.995Z'; const laterTime = '2020-02-28T15:02:57.995Z'; + export const elasticUser = { fullName: 'Leslie Knope', username: 'lknope', email: 'leslie.knope@elastic.co', }; -export const serviceConnectorUser = { - fullName: 'Leslie Knope', - username: 'lknope', -}; - export const tags: string[] = ['coke', 'pepsi']; export const basicComment: Comment = { @@ -136,19 +131,6 @@ export const pushedCase: Case = { externalService: basicPush, }; -export const serviceConnector: ServiceConnectorCaseResponse = { - title: '123', - id: '444', - pushedDate: basicUpdatedAt, - url: 'connector.com', - comments: [ - { - commentId: basicCommentId, - pushedDate: basicUpdatedAt, - }, - ], -}; - const basicAction = { actionAt: basicCreatedAt, actionBy: elasticUser, @@ -158,25 +140,6 @@ const basicAction = { commentId: null, }; -export const casePushParams = { - savedObjectId: basicCaseId, - createdAt: basicCreatedAt, - createdBy: elasticUser, - externalId: null, - title: 'what a cool value', - commentId: null, - updatedAt: basicCreatedAt, - updatedBy: elasticUser, - description: 'nice', - comments: null, -}; - -export const actionTypeExecutorResult = { - actionId: 'string', - status: 'ok', - data: serviceConnector, -}; - export const cases: Case[] = [ basicCase, { ...pushedCase, id: '1', totalComment: 0, comments: [] }, @@ -192,6 +155,7 @@ export const allCases: AllCases = { total: 10, ...casesStatus, }; + export const actionLicenses: ActionLicense[] = [ { id: '.servicenow', @@ -215,6 +179,7 @@ export const elasticUserSnake = { username: 'lknope', email: 'leslie.knope@elastic.co', }; + export const basicCommentSnake: CommentResponse = { comment: 'Solve this fast!', type: CommentType.user, @@ -260,11 +225,13 @@ export const pushSnake = { external_title: 'external title', external_url: 'basicPush.com', }; + export const basicPushSnake = { ...pushSnake, pushed_at: basicUpdatedAt, pushed_by: elasticUserSnake, }; + export const pushedCaseSnake = { ...basicCaseSnake, external_service: basicPushSnake, diff --git a/x-pack/plugins/security_solution/public/cases/containers/translations.ts b/x-pack/plugins/security_solution/public/cases/containers/translations.ts index 9525d125435e7..75939b46b1f77 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/translations.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/translations.ts @@ -62,13 +62,6 @@ export const SUCCESS_SEND_TO_EXTERNAL_SERVICE = (serviceName: string) => defaultMessage: 'Successfully sent to { serviceName }', }); -export const ERROR_PUSH_TO_SERVICE = i18n.translate( - 'xpack.securitySolution.case.configure.errorPushingToService', - { - defaultMessage: 'Error pushing to service', - } -); - export const ERROR_GET_FIELDS = i18n.translate( 'xpack.securitySolution.case.configure.errorGetFields', { diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_post_push_to_service.test.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_post_push_to_service.test.tsx index 8845e285ee910..5f09ac404ca64 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_post_push_to_service.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_post_push_to_service.test.tsx @@ -6,112 +6,22 @@ */ import { renderHook, act } from '@testing-library/react-hooks'; -import { - formatServiceRequestData, - usePostPushToService, - UsePostPushToService, -} from './use_post_push_to_service'; -import { - basicCase, - basicComment, - basicPush, - pushedCase, - serviceConnector, - serviceConnectorUser, -} from './mock'; +import { usePostPushToService, UsePostPushToService } from './use_post_push_to_service'; +import { pushedCase } from './mock'; import * as api from './api'; -import { CaseServices } from './use_get_case_user_actions'; -import { CaseConnector, ConnectorTypes, CommentType } from '../../../../case/common/api'; -import moment from 'moment'; +import { CaseConnector, ConnectorTypes } from '../../../../case/common/api'; + jest.mock('./api'); -jest.mock('../../common/components/link_to', () => { - const originalModule = jest.requireActual('../../common/components/link_to'); - return { - ...originalModule, - getTimelineTabsUrl: jest.fn(), - useFormatUrl: jest.fn().mockReturnValue({ formatUrl: jest.fn(), search: 'urlSearch' }), - }; -}); + describe('usePostPushToService', () => { const abortCtrl = new AbortController(); - const updateCase = jest.fn(); - const formatUrl = jest.fn(); - - const samplePush = { - caseId: pushedCase.id, - caseServices: { - '123': { - ...basicPush, - firstPushIndex: 1, - lastPushIndex: 1, - commentsToUpdate: [basicComment.id], - hasDataToPush: false, - }, - }, - connector: { - id: '123', - name: 'connector name', - type: ConnectorTypes.jira, - fields: { issueType: 'Task', priority: 'Low', parent: null }, - } as CaseConnector, - updateCase, - alerts: { - 'alert-id-1': { - _id: 'alert-id-1', - _index: 'alert-index-1', - '@timestamp': '2020-11-20T15:35:28.373Z', - rule: { - id: 'rule-id-1', - name: 'Awesome rule', - from: 'now-360s', - to: 'now', - }, - }, - }, - }; - - const sampleServiceRequestData = { - savedObjectId: pushedCase.id, - createdAt: pushedCase.createdAt, - createdBy: serviceConnectorUser, - comments: [ - { - commentId: basicComment.id, - comment: basicComment.type === CommentType.user ? basicComment.comment : '', - createdAt: basicComment.createdAt, - createdBy: serviceConnectorUser, - updatedAt: null, - updatedBy: null, - }, - ], - externalId: basicPush.externalId, - description: pushedCase.description, - title: pushedCase.title, - updatedAt: pushedCase.updatedAt, - updatedBy: serviceConnectorUser, - issueType: 'Task', - parent: null, - priority: 'Low', - }; - - const sampleCaseServices = { - '123': { - ...basicPush, - firstPushIndex: 1, - lastPushIndex: 1, - commentsToUpdate: [basicComment.id], - hasDataToPush: true, - }, - '456': { - ...basicPush, - connectorId: '456', - externalId: 'other_external_id', - firstPushIndex: 4, - commentsToUpdate: [basicComment.id], - lastPushIndex: 6, - hasDataToPush: false, - }, - }; + const connector = { + id: '123', + name: 'connector name', + type: ConnectorTypes.jira, + fields: { issueType: 'Task', priority: 'Low', parent: null }, + } as CaseConnector; + const caseId = pushedCase.id; it('init', async () => { await act(async () => { @@ -120,98 +30,24 @@ describe('usePostPushToService', () => { ); await waitForNextUpdate(); expect(result.current).toEqual({ - serviceData: null, - pushedCaseData: null, isLoading: false, isError: false, - postPushToService: result.current.postPushToService, + pushCaseToExternalService: result.current.pushCaseToExternalService, }); }); }); it('calls pushCase with correct arguments', async () => { - const spyOnPushCase = jest.spyOn(api, 'pushCase'); - - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - usePostPushToService() - ); - await waitForNextUpdate(); - result.current.postPushToService(samplePush); - await waitForNextUpdate(); - expect(spyOnPushCase).toBeCalledWith( - samplePush.caseId, - { - connector_id: samplePush.connector.id, - connector_name: samplePush.connector.name, - external_id: serviceConnector.id, - external_title: serviceConnector.title, - external_url: serviceConnector.url, - }, - abortCtrl.signal - ); - }); - }); - - it('calls pushToService with correct arguments', async () => { - const spyOnPushToService = jest.spyOn(api, 'pushToService'); - - await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - usePostPushToService() - ); - await waitForNextUpdate(); - result.current.postPushToService(samplePush); - await waitForNextUpdate(); - expect(spyOnPushToService).toBeCalledWith( - samplePush.connector.id, - samplePush.connector.type, - formatServiceRequestData({ - myCase: basicCase, - connector: samplePush.connector, - caseServices: sampleCaseServices as CaseServices, - alerts: samplePush.alerts, - formatUrl, - }), - abortCtrl.signal - ); - }); - }); - - it('calls pushToService with correct arguments when no push history', async () => { - const samplePush2 = { - caseId: pushedCase.id, - caseServices: {}, - connector: { - name: 'connector name', - id: 'none', - type: ConnectorTypes.none, - fields: null, - }, - alerts: samplePush.alerts, - updateCase, - }; - const spyOnPushToService = jest.spyOn(api, 'pushToService'); + const spyOnPushToService = jest.spyOn(api, 'pushCase'); await act(async () => { const { result, waitForNextUpdate } = renderHook(() => usePostPushToService() ); await waitForNextUpdate(); - result.current.postPushToService(samplePush2); + result.current.pushCaseToExternalService({ caseId, connector }); await waitForNextUpdate(); - expect(spyOnPushToService).toBeCalledWith( - samplePush2.connector.id, - samplePush2.connector.type, - formatServiceRequestData({ - myCase: basicCase, - connector: samplePush2.connector, - caseServices: {}, - alerts: samplePush.alerts, - formatUrl, - }), - abortCtrl.signal - ); + expect(spyOnPushToService).toBeCalledWith(caseId, connector.id, abortCtrl.signal); }); }); @@ -221,120 +57,29 @@ describe('usePostPushToService', () => { usePostPushToService() ); await waitForNextUpdate(); - result.current.postPushToService(samplePush); + result.current.pushCaseToExternalService({ caseId, connector }); await waitForNextUpdate(); expect(result.current).toEqual({ - serviceData: serviceConnector, - pushedCaseData: pushedCase, isLoading: false, isError: false, - postPushToService: result.current.postPushToService, + pushCaseToExternalService: result.current.pushCaseToExternalService, }); }); }); - it('set isLoading to true when deleting cases', async () => { + it('set isLoading to true when pushing case', async () => { await act(async () => { const { result, waitForNextUpdate } = renderHook(() => usePostPushToService() ); await waitForNextUpdate(); - result.current.postPushToService(samplePush); + result.current.pushCaseToExternalService({ caseId, connector }); expect(result.current.isLoading).toBe(true); }); }); - it('formatServiceRequestData - current connector', () => { - const caseServices = sampleCaseServices; - const result = formatServiceRequestData({ - myCase: pushedCase, - connector: samplePush.connector, - caseServices, - alerts: samplePush.alerts, - formatUrl, - }); - expect(result).toEqual(sampleServiceRequestData); - }); - - it('formatServiceRequestData - connector with history', () => { - const caseServices = sampleCaseServices; - const connector = { - id: '456', - name: 'connector 2', - type: ConnectorTypes.jira, - fields: { issueType: 'Task', priority: 'High', parent: 'RJ-01' }, - }; - const result = formatServiceRequestData({ - myCase: pushedCase, - connector: connector as CaseConnector, - caseServices, - alerts: samplePush.alerts, - formatUrl, - }); - expect(result).toEqual({ - ...sampleServiceRequestData, - ...connector.fields, - externalId: 'other_external_id', - }); - }); - - it('formatServiceRequestData - new connector', () => { - const caseServices = { - '123': sampleCaseServices['123'], - }; - - const connector = { - id: '456', - name: 'connector 2', - type: ConnectorTypes.jira, - fields: { issueType: 'Task', priority: 'High', parent: null }, - }; - - const result = formatServiceRequestData({ - myCase: pushedCase, - connector: connector as CaseConnector, - caseServices, - alerts: samplePush.alerts, - formatUrl, - }); - - expect(result).toEqual({ - ...sampleServiceRequestData, - ...connector.fields, - externalId: null, - }); - }); - - it('formatServiceRequestData - Alert comment content', () => { - const mockDuration = moment.duration(1); - jest.spyOn(moment, 'duration').mockReturnValue(mockDuration); - formatUrl.mockReturnValue('https://app.com/detections'); - const caseServices = sampleCaseServices; - const result = formatServiceRequestData({ - myCase: { - ...pushedCase, - comments: [ - { - ...pushedCase.comments[0], - type: CommentType.alert, - alertId: 'alert-id-1', - index: 'alert-index-1', - }, - ], - }, - connector: samplePush.connector, - caseServices, - alerts: samplePush.alerts, - formatUrl, - }); - - expect(result.comments![0].comment).toEqual( - '[Alert](https://app.com/detections?filters=!((%27$state%27:(store:appState),meta:(alias:!n,disabled:!f,key:_id,negate:!f,params:(query:alert-id-1),type:phrase),query:(match:(_id:(query:alert-id-1,type:phrase)))))&sourcerer=(default:!())&timerange=(global:(linkTo:!(timeline),timerange:(from:%272020-11-20T15:35:28.372Z%27,kind:absolute,to:%272020-11-20T15:35:28.373Z%27)),timeline:(linkTo:!(global),timerange:(from:%272020-11-20T15:35:28.372Z%27,kind:absolute,to:%272020-11-20T15:35:28.373Z%27)))) added to case.' - ); - }); - it('unhappy path', async () => { - const spyOnPushToService = jest.spyOn(api, 'pushToService'); + const spyOnPushToService = jest.spyOn(api, 'pushCase'); spyOnPushToService.mockImplementation(() => { throw new Error('Something went wrong'); }); @@ -344,15 +89,12 @@ describe('usePostPushToService', () => { usePostPushToService() ); await waitForNextUpdate(); - result.current.postPushToService(samplePush); - await waitForNextUpdate(); + result.current.pushCaseToExternalService({ caseId, connector }); expect(result.current).toEqual({ - serviceData: null, - pushedCaseData: null, isLoading: false, isError: true, - postPushToService: result.current.postPushToService, + pushCaseToExternalService: result.current.pushCaseToExternalService, }); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_post_push_to_service.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_post_push_to_service.tsx index c5b4f52e73125..03d881d7934e9 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_post_push_to_service.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_post_push_to_service.tsx @@ -5,41 +5,23 @@ * 2.0. */ -import { useReducer, useCallback } from 'react'; -import moment from 'moment'; -import dateMath from '@elastic/datemath'; - -import { - ServiceConnectorCaseResponse, - ServiceConnectorCaseParams, - CaseConnector, - CommentType, -} from '../../../../case/common/api'; -import { SecurityPageName } from '../../app/types'; -import { useFormatUrl, FormatUrl, getRuleDetailsUrl } from '../../common/components/link_to'; +import { useReducer, useCallback, useRef, useEffect } from 'react'; +import { CaseConnector } from '../../../../case/common/api'; import { errorToToaster, useStateToaster, displaySuccessToast, } from '../../common/components/toasters'; -import { Alert } from '../components/case_view'; -import { getCase, pushToService, pushCase } from './api'; +import { pushCase } from './api'; import * as i18n from './translations'; -import { Case, Comment } from './types'; -import { CaseServices } from './use_get_case_user_actions'; +import { Case } from './types'; interface PushToServiceState { - serviceData: ServiceConnectorCaseResponse | null; - pushedCaseData: Case | null; isLoading: boolean; isError: boolean; } -type Action = - | { type: 'FETCH_INIT' } - | { type: 'FETCH_SUCCESS_PUSH_SERVICE'; payload: ServiceConnectorCaseResponse | null } - | { type: 'FETCH_SUCCESS_PUSH_CASE'; payload: Case | null } - | { type: 'FETCH_FAILURE' }; +type Action = { type: 'FETCH_INIT' } | { type: 'FETCH_SUCCESS' } | { type: 'FETCH_FAILURE' }; const dataFetchReducer = (state: PushToServiceState, action: Action): PushToServiceState => { switch (action.type) { @@ -49,19 +31,11 @@ const dataFetchReducer = (state: PushToServiceState, action: Action): PushToServ isLoading: true, isError: false, }; - case 'FETCH_SUCCESS_PUSH_SERVICE': - return { - ...state, - isLoading: false, - isError: false, - serviceData: action.payload ?? null, - }; - case 'FETCH_SUCCESS_PUSH_CASE': + case 'FETCH_SUCCESS': return { ...state, isLoading: false, isError: false, - pushedCaseData: action.payload ?? null, }; case 'FETCH_FAILURE': return { @@ -77,72 +51,45 @@ const dataFetchReducer = (state: PushToServiceState, action: Action): PushToServ interface PushToServiceRequest { caseId: string; connector: CaseConnector; - caseServices: CaseServices; - alerts: Record; - updateCase: (newCase: Case) => void; } export interface UsePostPushToService extends PushToServiceState { - postPushToService: ({ + pushCaseToExternalService: ({ caseId, - caseServices, connector, - alerts, - updateCase, - }: PushToServiceRequest) => void; + }: PushToServiceRequest) => Promise; } export const usePostPushToService = (): UsePostPushToService => { const [state, dispatch] = useReducer(dataFetchReducer, { - serviceData: null, - pushedCaseData: null, isLoading: false, isError: false, }); const [, dispatchToaster] = useStateToaster(); - const { formatUrl } = useFormatUrl(SecurityPageName.detections); + const cancel = useRef(false); + const abortCtrl = useRef(new AbortController()); - const postPushToService = useCallback( - async ({ caseId, caseServices, connector, alerts, updateCase }: PushToServiceRequest) => { - let cancel = false; - const abortCtrl = new AbortController(); + const pushCaseToExternalService = useCallback( + async ({ caseId, connector }: PushToServiceRequest) => { try { dispatch({ type: 'FETCH_INIT' }); - const casePushData = await getCase(caseId, true, abortCtrl.signal); - const responseService = await pushToService( - connector.id, - connector.type, - formatServiceRequestData({ - myCase: casePushData, - connector, - caseServices, - alerts, - formatUrl, - }), - abortCtrl.signal - ); - const responseCase = await pushCase( - caseId, - { - connector_id: connector.id, - connector_name: connector.name, - external_id: responseService.id, - external_title: responseService.title, - external_url: responseService.url, - }, - abortCtrl.signal - ); - if (!cancel) { - dispatch({ type: 'FETCH_SUCCESS_PUSH_SERVICE', payload: responseService }); - dispatch({ type: 'FETCH_SUCCESS_PUSH_CASE', payload: responseCase }); - updateCase(responseCase); + abortCtrl.current.abort(); + cancel.current = false; + abortCtrl.current = new AbortController(); + + const response = await pushCase(caseId, connector.id, abortCtrl.current.signal); + + if (!cancel.current) { + dispatch({ type: 'FETCH_SUCCESS' }); displaySuccessToast( i18n.SUCCESS_SEND_TO_EXTERNAL_SERVICE(connector.name), dispatchToaster ); } + + return response; } catch (error) { - if (!cancel) { + if (!cancel.current) { errorToToaster({ title: i18n.ERROR_TITLE, error: error.body && error.body.message ? new Error(error.body.message) : error, @@ -151,123 +98,17 @@ export const usePostPushToService = (): UsePostPushToService => { dispatch({ type: 'FETCH_FAILURE' }); } } - return () => { - cancel = true; - abortCtrl.abort(); - }; }, // eslint-disable-next-line react-hooks/exhaustive-deps [] ); - return { ...state, postPushToService }; -}; - -export const determineToAndFrom = (alert: Alert) => { - const ellapsedTimeRule = moment.duration( - moment().diff(dateMath.parse(alert.rule?.from != null ? alert.rule.from : 'now-0s')) - ); + useEffect(() => { + return () => { + abortCtrl.current.abort(); + cancel.current = true; + }; + }, []); - const from = moment(alert['@timestamp'] ?? new Date()) - .subtract(ellapsedTimeRule) - .toISOString(); - const to = moment(alert['@timestamp'] ?? new Date()).toISOString(); - - return { to, from }; -}; - -const getAlertFilterUrl = (alert: Alert): string => { - const { to, from } = determineToAndFrom(alert); - return `?filters=!((%27$state%27:(store:appState),meta:(alias:!n,disabled:!f,key:_id,negate:!f,params:(query:${alert._id}),type:phrase),query:(match:(_id:(query:${alert._id},type:phrase)))))&sourcerer=(default:!())&timerange=(global:(linkTo:!(timeline),timerange:(from:%27${from}%27,kind:absolute,to:%27${to}%27)),timeline:(linkTo:!(global),timerange:(from:%27${from}%27,kind:absolute,to:%27${to}%27)))`; -}; - -const getCommentContent = ( - comment: Comment, - alerts: Record, - formatUrl: FormatUrl -): string => { - if (comment.type === CommentType.user) { - return comment.comment; - } else if (comment.type === CommentType.alert) { - const alert = alerts[comment.alertId]; - const ruleDetailsLink = formatUrl(getRuleDetailsUrl(alert.rule.id), { - absolute: true, - skipSearch: true, - }); - - return `[${i18n.ALERT}](${ruleDetailsLink}${getAlertFilterUrl(alert)}) ${ - i18n.ALERT_ADDED_TO_CASE - }.`; - } - - return ''; -}; - -export const formatServiceRequestData = ({ - myCase, - connector, - caseServices, - alerts, - formatUrl, -}: { - myCase: Case; - connector: CaseConnector; - caseServices: CaseServices; - alerts: Record; - formatUrl: FormatUrl; -}): ServiceConnectorCaseParams => { - const { - id: caseId, - createdAt, - createdBy, - comments, - description, - title, - updatedAt, - updatedBy, - } = myCase; - const actualExternalService = caseServices[connector.id] ?? null; - - return { - savedObjectId: caseId, - createdAt, - createdBy: { - fullName: createdBy.fullName ?? null, - username: createdBy?.username ?? '', - }, - comments: comments - .filter( - (c) => - actualExternalService == null || actualExternalService.commentsToUpdate.includes(c.id) - ) - .map((c) => ({ - commentId: c.id, - comment: getCommentContent(c, alerts, formatUrl), - createdAt: c.createdAt, - createdBy: { - fullName: c.createdBy.fullName ?? null, - username: c.createdBy.username ?? '', - }, - updatedAt: c.updatedAt, - updatedBy: - c.updatedBy != null - ? { - fullName: c.updatedBy.fullName ?? null, - username: c.updatedBy.username ?? '', - } - : null, - })), - description, - externalId: actualExternalService?.externalId ?? null, - title, - ...(connector.fields ?? {}), - updatedAt, - updatedBy: - updatedBy != null - ? { - fullName: updatedBy.fullName ?? null, - username: updatedBy.username ?? '', - } - : null, - }; + return { ...state, pushCaseToExternalService }; }; diff --git a/x-pack/plugins/security_solution/public/cases/containers/utils.ts b/x-pack/plugins/security_solution/public/cases/containers/utils.ts index 4311390ae9b49..297c7e35981ac 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/utils.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/utils.ts @@ -26,8 +26,6 @@ import { CaseConfigureResponseRt, CaseUserActionsResponse, CaseUserActionsResponseRt, - ServiceConnectorCaseResponseRt, - ServiceConnectorCaseResponse, CommentType, CasePatchRequest, } from '../../../../case/common/api'; @@ -107,12 +105,6 @@ export const decodeCaseUserActionsResponse = (respUserActions?: CaseUserActionsR fold(throwErrors(createToasterPlainError), identity) ); -export const decodeServiceConnectorCaseResponse = (respPushCase?: ServiceConnectorCaseResponse) => - pipe( - ServiceConnectorCaseResponseRt.decode(respPushCase), - fold(throwErrors(createToasterPlainError), identity) - ); - export const valueToUpdateIsSettings = ( key: UpdateByKey['updateKey'], value: UpdateByKey['updateValue'] diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_body.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_body.tsx index 13b9c9ef4f519..1616c5e84247f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_body.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_body.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { EuiModalBody, EuiModalHeader } from '@elastic/eui'; +import { EuiModalBody, EuiModalHeader, EuiSpacer } from '@elastic/eui'; import React, { Fragment, memo, useMemo } from 'react'; import styled from 'styled-components'; @@ -62,11 +62,10 @@ export const OpenTimelineModalBody = memo( const SearchRowContent = useMemo( () => ( - {!!timelineFilter && timelineFilter} {!!templateTimelineFilter && templateTimelineFilter} ), - [timelineFilter, templateTimelineFilter] + [templateTimelineFilter] ); return ( @@ -84,9 +83,14 @@ export const OpenTimelineModalBody = memo( <> + {!!timelineFilter && ( + <> + {timelineFilter} + + + )} & { */ export const TitleRow = React.memo( ({ children, onAddTimelinesToFavorites, selectedTimelinesCount, title }) => ( - + {onAddTimelinesToFavorites && ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/translations.ts index 84907c74cdace..ae743ad30eef1 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/translations.ts @@ -146,7 +146,7 @@ export const OPEN_TIMELINE = i18n.translate( export const OPEN_TIMELINE_TITLE = i18n.translate( 'xpack.securitySolution.open.timeline.openTimelineTitle', { - defaultMessage: 'Open Timeline', + defaultMessage: 'Open', } ); @@ -274,12 +274,6 @@ export const SUCCESSFULLY_EXPORTED_TIMELINE_TEMPLATES = (totalTimelineTemplates: } ); -export const FILTER_TIMELINES = (timelineType: string) => - i18n.translate('xpack.securitySolution.open.timeline.filterByTimelineTypesTitle', { - values: { timelineType }, - defaultMessage: 'Only {timelineType}', - }); - export const TAB_TIMELINES = i18n.translate( 'xpack.securitySolution.timelines.components.tabs.timelinesTitle', { diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts index ddf567edafe13..ad62bda4c9783 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/types.ts @@ -221,13 +221,11 @@ export enum TimelineTabsStyle { } export interface TimelineTab { - count: number | undefined; disabled: boolean; href: string; id: TimelineTypeLiteral; name: string; onClick: (ev: { preventDefault: () => void }) => void; - withNext: boolean; } export interface TemplateTimelineFilter { diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/use_timeline_types.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/use_timeline_types.test.tsx new file mode 100644 index 0000000000000..1d39dd169ffaa --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/use_timeline_types.test.tsx @@ -0,0 +1,193 @@ +/* + * 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 { fireEvent, render } from '@testing-library/react'; +import { renderHook, act } from '@testing-library/react-hooks'; +import { + useTimelineTypes, + UseTimelineTypesArgs, + UseTimelineTypesResult, +} from './use_timeline_types'; + +jest.mock('react-router-dom', () => { + return { + useParams: jest.fn().mockReturnValue('default'), + useHistory: jest.fn().mockReturnValue([]), + }; +}); + +jest.mock('../../../common/components/link_to', () => { + return { + getTimelineTabsUrl: jest.fn(), + useFormatUrl: jest.fn().mockReturnValue({ + formatUrl: jest.fn(), + search: '', + }), + }; +}); + +describe('useTimelineTypes', () => { + it('init', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook< + UseTimelineTypesArgs, + UseTimelineTypesResult + >(() => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 3 })); + await waitForNextUpdate(); + expect(result.current).toEqual({ + timelineType: 'default', + timelineTabs: result.current.timelineTabs, + timelineFilters: result.current.timelineFilters, + }); + }); + }); + + describe('timelineTabs', () => { + it('render timelineTabs', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook< + UseTimelineTypesArgs, + UseTimelineTypesResult + >(() => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 3 })); + await waitForNextUpdate(); + + const { container } = render(result.current.timelineTabs); + expect( + container.querySelector('[data-test-subj="timeline-tab-default"]') + ).toHaveTextContent('Timelines'); + expect( + container.querySelector('[data-test-subj="timeline-tab-template"]') + ).toHaveTextContent('Templates'); + }); + }); + + it('set timelineTypes correctly', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook< + UseTimelineTypesArgs, + UseTimelineTypesResult + >(() => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 3 })); + await waitForNextUpdate(); + + const { container } = render(result.current.timelineTabs); + + fireEvent( + container.querySelector('[data-test-subj="timeline-tab-template"]')!, + new MouseEvent('click', { + bubbles: true, + cancelable: true, + }) + ); + + expect(result.current).toEqual({ + timelineType: 'template', + timelineTabs: result.current.timelineTabs, + timelineFilters: result.current.timelineFilters, + }); + }); + }); + + it('stays in the same tab if clicking again on current tab', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook< + UseTimelineTypesArgs, + UseTimelineTypesResult + >(() => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 3 })); + await waitForNextUpdate(); + + const { container } = render(result.current.timelineTabs); + + fireEvent( + container.querySelector('[data-test-subj="timeline-tab-default"]')!, + new MouseEvent('click', { + bubbles: true, + cancelable: true, + }) + ); + + expect(result.current).toEqual({ + timelineType: 'default', + timelineTabs: result.current.timelineTabs, + timelineFilters: result.current.timelineFilters, + }); + }); + }); + }); + + describe('timelineFilters', () => { + it('render timelineFilters', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook< + UseTimelineTypesArgs, + UseTimelineTypesResult + >(() => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 3 })); + await waitForNextUpdate(); + + const { container } = render(<>{result.current.timelineFilters}); + expect( + container.querySelector('[data-test-subj="open-timeline-modal-body-filter-default"]') + ).toHaveTextContent('Timelines'); + expect( + container.querySelector('[data-test-subj="open-timeline-modal-body-filter-template"]') + ).toHaveTextContent('Templates'); + }); + }); + + it('set timelineTypes correctly', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook< + UseTimelineTypesArgs, + UseTimelineTypesResult + >(() => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 3 })); + await waitForNextUpdate(); + + const { container } = render(<>{result.current.timelineFilters}); + + fireEvent( + container.querySelector('[data-test-subj="open-timeline-modal-body-filter-template"]')!, + new MouseEvent('click', { + bubbles: true, + cancelable: true, + }) + ); + + expect(result.current).toEqual({ + timelineType: 'template', + timelineTabs: result.current.timelineTabs, + timelineFilters: result.current.timelineFilters, + }); + }); + }); + + it('stays in the same tab if clicking again on current tab', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook< + UseTimelineTypesArgs, + UseTimelineTypesResult + >(() => useTimelineTypes({ defaultTimelineCount: 0, templateTimelineCount: 3 })); + await waitForNextUpdate(); + + const { container } = render(<>{result.current.timelineFilters}); + + fireEvent( + container.querySelector('[data-test-subj="open-timeline-modal-body-filter-default"]')!, + new MouseEvent('click', { + bubbles: true, + cancelable: true, + }) + ); + + expect(result.current).toEqual({ + timelineType: 'default', + timelineTabs: result.current.timelineTabs, + timelineFilters: result.current.timelineFilters, + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/use_timeline_types.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/use_timeline_types.tsx index 728d8b6eeb488..a66fe43d305f1 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/use_timeline_types.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/use_timeline_types.tsx @@ -7,7 +7,7 @@ import React, { useState, useCallback, useMemo } from 'react'; import { useParams, useHistory } from 'react-router-dom'; -import { EuiTabs, EuiTab, EuiSpacer, EuiFilterButton } from '@elastic/eui'; +import { EuiTabs, EuiTab, EuiSpacer } from '@elastic/eui'; import { noop } from 'lodash/fp'; import { TimelineTypeLiteralWithNull, TimelineType } from '../../../../common/types/timeline'; @@ -24,7 +24,7 @@ export interface UseTimelineTypesArgs { export interface UseTimelineTypesResult { timelineType: TimelineTypeLiteralWithNull; timelineTabs: JSX.Element; - timelineFilters: JSX.Element[]; + timelineFilters: JSX.Element; } export const useTimelineTypes = ({ @@ -59,51 +59,28 @@ export const useTimelineTypes = ({ (timelineTabsStyle: TimelineTabsStyle) => [ { id: TimelineType.default, - name: - timelineTabsStyle === TimelineTabsStyle.filter - ? i18n.FILTER_TIMELINES(i18n.TAB_TIMELINES) - : i18n.TAB_TIMELINES, + name: i18n.TAB_TIMELINES, href: formatUrl(getTimelineTabsUrl(TimelineType.default, urlSearch)), disabled: false, - withNext: true, - count: - timelineTabsStyle === TimelineTabsStyle.filter - ? defaultTimelineCount ?? undefined - : undefined, + onClick: timelineTabsStyle === TimelineTabsStyle.tab ? goToTimeline : noop, }, { id: TimelineType.template, - name: - timelineTabsStyle === TimelineTabsStyle.filter - ? i18n.FILTER_TIMELINES(i18n.TAB_TEMPLATES) - : i18n.TAB_TEMPLATES, + name: i18n.TAB_TEMPLATES, href: formatUrl(getTimelineTabsUrl(TimelineType.template, urlSearch)), disabled: false, - withNext: false, - count: - timelineTabsStyle === TimelineTabsStyle.filter - ? templateTimelineCount ?? undefined - : undefined, + onClick: timelineTabsStyle === TimelineTabsStyle.tab ? goToTemplateTimeline : noop, }, ], - [ - defaultTimelineCount, - templateTimelineCount, - urlSearch, - formatUrl, - goToTimeline, - goToTemplateTimeline, - ] + [urlSearch, formatUrl, goToTimeline, goToTemplateTimeline] ); const onFilterClicked = useCallback( (tabId, tabStyle: TimelineTabsStyle) => { setTimelineTypes((prevTimelineTypes) => { - if (tabId === prevTimelineTypes && tabStyle === TimelineTabsStyle.filter) { - return tabId === TimelineType.default ? TimelineType.template : TimelineType.default; - } else if (prevTimelineTypes !== tabId) { + if (prevTimelineTypes !== tabId) { setTimelineTypes(tabId); } return prevTimelineTypes; @@ -139,21 +116,23 @@ export const useTimelineTypes = ({ }, [tabName]); const timelineFilters = useMemo(() => { - return getFilterOrTabs(TimelineTabsStyle.filter).map((tab: TimelineTab) => ( - void }) => { - tab.onClick(ev); - onFilterClicked(tab.id, TimelineTabsStyle.filter); - }} - withNext={tab.withNext} - > - {tab.name} - - )); + return ( + + {getFilterOrTabs(TimelineTabsStyle.filter).map((tab: TimelineTab) => ( + void }) => { + tab.onClick(ev); + onFilterClicked(tab.id, TimelineTabsStyle.filter); + }} + > + {tab.name} + + ))} + + ); }, [timelineType, getFilterOrTabs, onFilterClicked]); return { diff --git a/x-pack/plugins/stack_alerts/public/alert_types/components/index_select_popover.test.tsx b/x-pack/plugins/stack_alerts/public/alert_types/components/index_select_popover.test.tsx index 64c085a823478..3b7baac9b80e6 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/components/index_select_popover.test.tsx +++ b/x-pack/plugins/stack_alerts/public/alert_types/components/index_select_popover.test.tsx @@ -9,54 +9,58 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import { mountWithIntl, nextTick } from '@kbn/test/jest'; import { IndexSelectPopover } from './index_select_popover'; +import { EuiComboBox } from '@elastic/eui'; -jest.mock('../../../../triggers_actions_ui/public', () => ({ - getIndexPatterns: () => { - return ['index1', 'index2']; - }, - firstFieldOption: () => { - return { text: 'Select a field', value: '' }; - }, - getTimeFieldOptions: () => { - return [ - { - text: '@timestamp', - value: '@timestamp', - }, - ]; - }, - getFields: () => { - return Promise.resolve([ - { - name: '@timestamp', - type: 'date', - }, - { - name: 'field', - type: 'text', - }, - ]); - }, - getIndexOptions: () => { - return Promise.resolve([ - { - label: 'indexOption', - options: [ - { - label: 'index1', - value: 'index1', - }, - { - label: 'index2', - value: 'index2', - }, - ], - }, - ]); - }, -})); +jest.mock('../../../../triggers_actions_ui/public', () => { + const original = jest.requireActual('../../../../triggers_actions_ui/public'); + return { + ...original, + getIndexPatterns: () => { + return ['index1', 'index2']; + }, + getTimeFieldOptions: () => { + return [ + { + text: '@timestamp', + value: '@timestamp', + }, + ]; + }, + getFields: () => { + return Promise.resolve([ + { + name: '@timestamp', + type: 'date', + }, + { + name: 'field', + type: 'text', + }, + ]); + }, + getIndexOptions: () => { + return Promise.resolve([ + { + label: 'indexOption', + options: [ + { + label: 'index1', + value: 'index1', + }, + { + label: 'index2', + value: 'index2', + }, + ], + }, + ]); + }, + }; +}); describe('IndexSelectPopover', () => { + const onIndexChange = jest.fn(); + const onTimeFieldChange = jest.fn(); const props = { index: [], esFields: [], @@ -65,8 +69,8 @@ describe('IndexSelectPopover', () => { index: [], timeField: [], }, - onIndexChange: jest.fn(), - onTimeFieldChange: jest.fn(), + onIndexChange, + onTimeFieldChange, }; beforeEach(() => { @@ -106,10 +110,62 @@ describe('IndexSelectPopover', () => { const indexComboBox = wrapper.find('#indexSelectSearchBox'); indexComboBox.first().simulate('click'); - const event = { target: { value: 'indexPattern1' } }; - indexComboBox.find('input').first().simulate('change', event); + + await act(async () => { + const event = { target: { value: 'indexPattern1' } }; + indexComboBox.find('input').first().simulate('change', event); + await nextTick(); + wrapper.update(); + }); const updatedIndexSearchValue = wrapper.find('[data-test-subj="comboBoxSearchInput"]'); expect(updatedIndexSearchValue.first().props().value).toEqual('indexPattern1'); + + const thresholdComboBox = wrapper + .find(EuiComboBox) + .filter('[data-test-subj="thresholdIndexesComboBox"]'); + const thresholdOptions = thresholdComboBox.prop('options'); + expect(thresholdOptions.length > 0).toBeTruthy(); + + await act(async () => { + thresholdComboBox.prop('onChange')!([thresholdOptions[0].options![0]]); + await nextTick(); + wrapper.update(); + }); + expect(onIndexChange).toHaveBeenCalledWith( + [thresholdOptions[0].options![0]].map((opt) => opt.value) + ); + + const timeFieldSelect = wrapper.find('select[data-test-subj="thresholdAlertTimeFieldSelect"]'); + await act(async () => { + timeFieldSelect.simulate('change', { target: { value: '@timestamp' } }); + await nextTick(); + wrapper.update(); + }); + expect(onTimeFieldChange).toHaveBeenCalledWith('@timestamp'); + }); + + test('renders index and timeField if defined', async () => { + const index = 'test-index'; + const timeField = '@timestamp'; + const indexSelectProps = { + ...props, + index: [index], + timeField, + }; + const wrapper = mountWithIntl(); + expect(wrapper.find('button[data-test-subj="selectIndexExpression"]').text()).toEqual( + `index ${index}` + ); + + wrapper.find('[data-test-subj="selectIndexExpression"]').first().simulate('click'); + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect( + wrapper.find('EuiSelect[data-test-subj="thresholdAlertTimeFieldSelect"]').text() + ).toEqual(`Select a field${timeField}`); }); }); diff --git a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression.test.tsx b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression.test.tsx index 3349de086d982..0a9f94f8efae2 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression.test.tsx +++ b/x-pack/plugins/stack_alerts/public/alert_types/es_query/expression.test.tsx @@ -58,9 +58,6 @@ jest.mock('../../../../triggers_actions_ui/public', () => { getIndexPatterns: () => { return ['index1', 'index2']; }, - firstFieldOption: () => { - return { text: 'Select a field', value: '' }; - }, getTimeFieldOptions: () => { return [ { diff --git a/x-pack/plugins/stack_alerts/public/alert_types/threshold/expression.test.tsx b/x-pack/plugins/stack_alerts/public/alert_types/threshold/expression.test.tsx new file mode 100644 index 0000000000000..01c2bc18f35e8 --- /dev/null +++ b/x-pack/plugins/stack_alerts/public/alert_types/threshold/expression.test.tsx @@ -0,0 +1,218 @@ +/* + * 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 { mountWithIntl, nextTick } from '@kbn/test/jest'; +import { act } from 'react-dom/test-utils'; +import IndexThresholdAlertTypeExpression, { DEFAULT_VALUES } from './expression'; +import { dataPluginMock } from 'src/plugins/data/public/mocks'; +import { chartPluginMock } from 'src/plugins/charts/public/mocks'; +import { IndexThresholdAlertParams } from './types'; +import { validateExpression } from './validation'; +import { + builtInAggregationTypes, + builtInComparators, + getTimeUnitLabel, + TIME_UNITS, +} from '../../../../triggers_actions_ui/public'; + +jest.mock('../../../../triggers_actions_ui/public', () => { + const original = jest.requireActual('../../../../triggers_actions_ui/public'); + return { + ...original, + getIndexPatterns: () => { + return ['index1', 'index2']; + }, + getTimeFieldOptions: () => { + return [ + { + text: '@timestamp', + value: '@timestamp', + }, + ]; + }, + getFields: () => { + return Promise.resolve([ + { + name: '@timestamp', + type: 'date', + }, + { + name: 'field', + type: 'text', + }, + ]); + }, + getIndexOptions: () => { + return Promise.resolve([ + { + label: 'indexOption', + options: [ + { + label: 'index1', + value: 'index1', + }, + { + label: 'index2', + value: 'index2', + }, + ], + }, + ]); + }, + }; +}); + +const dataMock = dataPluginMock.createStartContract(); +const chartsStartMock = chartPluginMock.createStartContract(); + +describe('IndexThresholdAlertTypeExpression', () => { + function getAlertParams(overrides = {}) { + return { + index: 'test-index', + aggType: 'count', + thresholdComparator: '>', + threshold: [0], + timeWindowSize: 15, + timeWindowUnit: 's', + ...overrides, + }; + } + async function setup(alertParams: IndexThresholdAlertParams) { + const { errors } = validateExpression(alertParams); + + const wrapper = mountWithIntl( + {}} + setAlertProperty={() => {}} + errors={errors} + data={dataMock} + defaultActionGroupId="" + actionGroups={[]} + charts={chartsStartMock} + /> + ); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + return wrapper; + } + + test(`should render IndexThresholdAlertTypeExpression with expected components when aggType doesn't require field`, async () => { + const wrapper = await setup(getAlertParams()); + expect(wrapper.find('[data-test-subj="indexSelectPopover"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="whenExpression"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="groupByExpression"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="aggTypeExpression"]').exists()).toBeFalsy(); + expect(wrapper.find('[data-test-subj="thresholdExpression"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="forLastExpression"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="visualizationPlaceholder"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="thresholdVisualization"]').exists()).toBeFalsy(); + }); + + test(`should render IndexThresholdAlertTypeExpression with expected components when aggType does require field`, async () => { + const wrapper = await setup(getAlertParams({ aggType: 'avg' })); + expect(wrapper.find('[data-test-subj="indexSelectPopover"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="whenExpression"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="groupByExpression"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="aggTypeExpression"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="thresholdExpression"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="forLastExpression"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="visualizationPlaceholder"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="thresholdVisualization"]').exists()).toBeFalsy(); + }); + + test(`should render IndexThresholdAlertTypeExpression with visualization when there are no expression errors`, async () => { + const wrapper = await setup(getAlertParams({ timeField: '@timestamp' })); + expect(wrapper.find('[data-test-subj="visualizationPlaceholder"]').exists()).toBeFalsy(); + expect(wrapper.find('[data-test-subj="thresholdVisualization"]').exists()).toBeTruthy(); + }); + + test(`should set default alert params when params are undefined`, async () => { + const wrapper = await setup( + getAlertParams({ + aggType: undefined, + thresholdComparator: undefined, + timeWindowSize: undefined, + timeWindowUnit: undefined, + groupBy: undefined, + threshold: undefined, + }) + ); + + expect(wrapper.find('button[data-test-subj="selectIndexExpression"]').text()).toEqual( + 'index test-index' + ); + expect(wrapper.find('button[data-test-subj="whenExpression"]').text()).toEqual( + `when ${builtInAggregationTypes[DEFAULT_VALUES.AGGREGATION_TYPE].text}` + ); + expect(wrapper.find('button[data-test-subj="groupByExpression"]').text()).toEqual( + `over ${DEFAULT_VALUES.GROUP_BY} documents ` + ); + expect(wrapper.find('[data-test-subj="aggTypeExpression"]').exists()).toBeFalsy(); + expect(wrapper.find('button[data-test-subj="thresholdPopover"]').text()).toEqual( + `${builtInComparators[DEFAULT_VALUES.THRESHOLD_COMPARATOR].text} ` + ); + expect(wrapper.find('button[data-test-subj="forLastExpression"]').text()).toEqual( + `for the last ${DEFAULT_VALUES.TIME_WINDOW_SIZE} ${getTimeUnitLabel( + DEFAULT_VALUES.TIME_WINDOW_UNIT as TIME_UNITS, + DEFAULT_VALUES.TIME_WINDOW_SIZE.toString() + )}` + ); + expect( + wrapper.find('EuiEmptyPrompt[data-test-subj="visualizationPlaceholder"]').text() + ).toEqual(`Complete the expression to generate a preview.`); + }); + + test(`should use alert params when params are defined`, async () => { + const aggType = 'avg'; + const thresholdComparator = 'between'; + const timeWindowSize = 987; + const timeWindowUnit = 's'; + const threshold = [3, 1003]; + const groupBy = 'top'; + const termSize = '27'; + const termField = 'host.name'; + const wrapper = await setup( + getAlertParams({ + aggType, + thresholdComparator, + timeWindowSize, + timeWindowUnit, + termSize, + termField, + groupBy, + threshold, + }) + ); + + expect(wrapper.find('button[data-test-subj="whenExpression"]').text()).toEqual( + `when ${builtInAggregationTypes[aggType].text}` + ); + expect(wrapper.find('button[data-test-subj="groupByExpression"]').text()).toEqual( + `grouped over ${groupBy} ${termSize} '${termField}'` + ); + + expect(wrapper.find('button[data-test-subj="thresholdPopover"]').text()).toEqual( + `${builtInComparators[thresholdComparator].text} ${threshold[0]} AND ${threshold[1]}` + ); + expect(wrapper.find('button[data-test-subj="forLastExpression"]').text()).toEqual( + `for the last ${timeWindowSize} ${getTimeUnitLabel( + timeWindowUnit as TIME_UNITS, + timeWindowSize.toString() + )}` + ); + expect( + wrapper.find('EuiEmptyPrompt[data-test-subj="visualizationPlaceholder"]').text() + ).toEqual(`Complete the expression to generate a preview.`); + }); +}); diff --git a/x-pack/plugins/stack_alerts/public/alert_types/threshold/expression.tsx b/x-pack/plugins/stack_alerts/public/alert_types/threshold/expression.tsx index aed115a53fa26..380e2793043f8 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/threshold/expression.tsx +++ b/x-pack/plugins/stack_alerts/public/alert_types/threshold/expression.tsx @@ -28,7 +28,7 @@ import { IndexThresholdAlertParams } from './types'; import './expression.scss'; import { IndexSelectPopover } from '../components/index_select_popover'; -const DEFAULT_VALUES = { +export const DEFAULT_VALUES = { AGGREGATION_TYPE: 'count', TERM_SIZE: 5, THRESHOLD_COMPARATOR: COMPARATORS.GREATER_THAN, @@ -100,7 +100,7 @@ export const IndexThresholdAlertTypeExpression: React.FunctionComponent< alertParams[errorKey as keyof IndexThresholdAlertParams] !== undefined ); - const canShowVizualization = !!Object.keys(errors).find( + const cannotShowVisualization = !!Object.keys(errors).find( (errorKey) => expressionFieldsWithValidation.includes(errorKey) && errors[errorKey].length >= 1 ); @@ -158,6 +158,7 @@ export const IndexThresholdAlertTypeExpression: React.FunctionComponent< setAlertParams('aggType', selectedAggType) @@ -196,6 +198,7 @@ export const IndexThresholdAlertTypeExpression: React.FunctionComponent< {aggType && builtInAggregationTypes[aggType].fieldRequired ? ( @@ -258,9 +264,10 @@ export const IndexThresholdAlertTypeExpression: React.FunctionComponent< />
- {canShowVizualization ? ( + {cannotShowVisualization ? ( @@ -275,6 +282,7 @@ export const IndexThresholdAlertTypeExpression: React.FunctionComponent< ) : ( ({ + getThresholdAlertVisualizationData: jest.fn(() => + Promise.resolve({ + results: [ + { group: 'a', metrics: [['b', 2]] }, + { group: 'a', metrics: [['b', 10]] }, + ], + }) + ), +})); + +const { getThresholdAlertVisualizationData } = jest.requireMock('./index_threshold_api'); + +const dataMock = dataPluginMock.createStartContract(); +const chartsStartMock = chartPluginMock.createStartContract(); +dataMock.fieldFormats = ({ + getDefaultInstance: jest.fn(() => ({ + convert: jest.fn((s: unknown) => JSON.stringify(s)), + })), +} as unknown) as DataPublicPluginStart['fieldFormats']; + +describe('ThresholdVisualization', () => { + beforeAll(() => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + uiSettings: uiSettingsServiceMock.createSetupContract(), + }, + }); + }); + + const alertParams = { + index: 'test-index', + aggType: 'count', + thresholdComparator: '>', + threshold: [0], + timeWindowSize: 15, + timeWindowUnit: 's', + }; + + async function setup() { + const wrapper = mountWithIntl( + + ); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + return wrapper; + } + + test('periodically requests visualization data', async () => { + const refreshRate = 10; + jest.useFakeTimers(); + + const wrapper = mountWithIntl( + + ); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + expect(getThresholdAlertVisualizationData).toHaveBeenCalledTimes(1); + + for (let i = 1; i <= 5; i++) { + await act(async () => { + jest.advanceTimersByTime(refreshRate); + await nextTick(); + wrapper.update(); + }); + expect(getThresholdAlertVisualizationData).toHaveBeenCalledTimes(i + 1); + } + }); + + test('renders loading message on initial load', async () => { + const wrapper = mountWithIntl( + + ); + expect(wrapper.find('[data-test-subj="firstLoad"]').exists()).toBeTruthy(); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find('[data-test-subj="firstLoad"]').exists()).toBeFalsy(); + expect(getThresholdAlertVisualizationData).toHaveBeenCalled(); + }); + + test('renders chart when visualization results are available', async () => { + const wrapper = await setup(); + + expect(wrapper.find('[data-test-subj="alertVisualizationChart"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="noDataCallout"]').exists()).toBeFalsy(); + expect(wrapper.find(Chart)).toHaveLength(1); + expect(wrapper.find(LineSeries)).toHaveLength(1); + expect(wrapper.find(LineAnnotation)).toHaveLength(1); + }); + + test('renders multiple line series chart when visualization results contain multiple groups', async () => { + getThresholdAlertVisualizationData.mockImplementation(() => + Promise.resolve({ + results: [ + { group: 'a', metrics: [['b', 2]] }, + { group: 'a', metrics: [['b', 10]] }, + { group: 'c', metrics: [['d', 1]] }, + ], + }) + ); + + const wrapper = await setup(); + + expect(wrapper.find('[data-test-subj="alertVisualizationChart"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="noDataCallout"]').exists()).toBeFalsy(); + expect(wrapper.find(Chart)).toHaveLength(1); + expect(wrapper.find(LineSeries)).toHaveLength(2); + expect(wrapper.find(LineAnnotation)).toHaveLength(1); + }); + + test('renders error message when getting visualization fails', async () => { + const errorMessage = 'oh no'; + getThresholdAlertVisualizationData.mockImplementation(() => Promise.reject(errorMessage)); + const wrapper = await setup(); + + expect(wrapper.find('[data-test-subj="errorCallout"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="errorCallout"]').first().text()).toBe( + `Cannot load alert visualization${errorMessage}` + ); + }); + + test('renders no data message when visualization results are empty', async () => { + getThresholdAlertVisualizationData.mockImplementation(() => Promise.resolve({ results: [] })); + const wrapper = await setup(); + + expect(wrapper.find('[data-test-subj="alertVisualizationChart"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="noDataCallout"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="noDataCallout"]').first().text()).toBe( + `No data matches this queryCheck that your time range and filters are correct.` + ); + }); +}); diff --git a/x-pack/plugins/stack_alerts/public/alert_types/threshold/visualization.tsx b/x-pack/plugins/stack_alerts/public/alert_types/threshold/visualization.tsx index 7401d0e26be68..40736f7350b1b 100644 --- a/x-pack/plugins/stack_alerts/public/alert_types/threshold/visualization.tsx +++ b/x-pack/plugins/stack_alerts/public/alert_types/threshold/visualization.tsx @@ -202,6 +202,7 @@ export const ThresholdVisualization: React.FunctionComponent = ({ if (loadingState === LoadingStateType.FirstLoad) { return ( } body={ @@ -220,6 +221,7 @@ export const ThresholdVisualization: React.FunctionComponent = ({ = ({ ) : ( { return { - id: connectorConfiguration.id, + id: '.jira', iconClass: logo, selectMessage: i18n.JIRA_DESC, - actionTypeTitle: connectorConfiguration.name, + actionTypeTitle: i18n.JIRA_TITLE, validateConnector, actionConnectorFields: lazy(() => import('./jira_connectors')), validateParams: (actionParams: JiraActionParams): GenericValidationResult => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/config.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/config.ts deleted file mode 100644 index 03b434283cd6e..0000000000000 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/config.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import * as i18n from './translations'; -import logo from './logo.svg'; - -export const connectorConfiguration = { - id: '.resilient', - name: i18n.TITLE, - logo, - enabled: true, - enabledInConfig: true, - enabledInLicense: true, - minimumLicenseRequired: 'platinum', -}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient.tsx index 3e1eafdfebca8..a8fe5e8ae4b6a 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/resilient/resilient.tsx @@ -11,7 +11,6 @@ import { ActionTypeModel, ConnectorValidationResult, } from '../../../../types'; -import { connectorConfiguration } from './config'; import logo from './logo.svg'; import { ResilientActionConnector, @@ -72,10 +71,10 @@ export function getActionType(): ActionTypeModel< ResilientActionParams > { return { - id: connectorConfiguration.id, + id: '.resilient', iconClass: logo, selectMessage: i18n.DESC, - actionTypeTitle: connectorConfiguration.name, + actionTypeTitle: i18n.TITLE, validateConnector, actionConnectorFields: lazy(() => import('./resilient_connectors')), validateParams: (actionParams: ResilientActionParams): GenericValidationResult => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/config.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/config.ts deleted file mode 100644 index 3e629261a29ba..0000000000000 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/config.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import * as i18n from './translations'; -import logo from './logo.svg'; - -export const serviceNowITSMConfiguration = { - id: '.servicenow', - name: i18n.SERVICENOW_ITSM_TITLE, - desc: i18n.SERVICENOW_ITSM_DESC, - logo, - enabled: true, - enabledInConfig: true, - enabledInLicense: true, - minimumLicenseRequired: 'platinum', -}; - -export const serviceNowSIRConfiguration = { - id: '.servicenow-sir', - name: i18n.SERVICENOW_SIR_TITLE, - desc: i18n.SERVICENOW_SIR_DESC, - logo, - enabled: true, - enabledInConfig: true, - enabledInLicense: true, - minimumLicenseRequired: 'platinum', -}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.tsx index 82d7f028a3e3d..b1664656c0d14 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow.tsx @@ -11,7 +11,6 @@ import { ActionTypeModel, ConnectorValidationResult, } from '../../../../types'; -import { serviceNowITSMConfiguration, serviceNowSIRConfiguration } from './config'; import logo from './logo.svg'; import { ServiceNowActionConnector, @@ -68,10 +67,10 @@ export function getServiceNowITSMActionType(): ActionTypeModel< ServiceNowITSMActionParams > { return { - id: serviceNowITSMConfiguration.id, + id: '.servicenow', iconClass: logo, - selectMessage: serviceNowITSMConfiguration.desc, - actionTypeTitle: serviceNowITSMConfiguration.name, + selectMessage: i18n.SERVICENOW_ITSM_DESC, + actionTypeTitle: i18n.SERVICENOW_ITSM_TITLE, validateConnector, actionConnectorFields: lazy(() => import('./servicenow_connectors')), validateParams: ( @@ -103,10 +102,10 @@ export function getServiceNowSIRActionType(): ActionTypeModel< ServiceNowSIRActionParams > { return { - id: serviceNowSIRConfiguration.id, + id: '.servicenow-sir', iconClass: logo, - selectMessage: serviceNowSIRConfiguration.desc, - actionTypeTitle: serviceNowSIRConfiguration.name, + selectMessage: i18n.SERVICENOW_SIR_DESC, + actionTypeTitle: i18n.SERVICENOW_SIR_TITLE, validateConnector, actionConnectorFields: lazy(() => import('./servicenow_connectors')), validateParams: (actionParams: ServiceNowSIRActionParams): GenericValidationResult => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.test.tsx index a55811ffa8ffd..bfc32ef67e46f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_itsm_params.test.tsx @@ -153,49 +153,22 @@ describe('ServiceNowITSMParamsFields renders', () => { }); }); - test('it transforms the urgencies to options correctly', async () => { + test('it transforms the options correctly', async () => { const wrapper = mount(); act(() => { onChoices(useGetChoicesResponse.choices); }); wrapper.update(); - expect(wrapper.find('[data-test-subj="urgencySelect"]').first().prop('options')).toEqual([ - { value: '1', text: '1 - Critical' }, - { value: '2', text: '2 - High' }, - { value: '3', text: '3 - Moderate' }, - { value: '4', text: '4 - Low' }, - ]); - }); - - test('it transforms the severities to options correctly', async () => { - const wrapper = mount(); - act(() => { - onChoices(useGetChoicesResponse.choices); - }); - - wrapper.update(); - expect(wrapper.find('[data-test-subj="severitySelect"]').first().prop('options')).toEqual([ - { value: '1', text: '1 - Critical' }, - { value: '2', text: '2 - High' }, - { value: '3', text: '3 - Moderate' }, - { value: '4', text: '4 - Low' }, - ]); - }); - - test('it transforms the impacts to options correctly', async () => { - const wrapper = mount(); - act(() => { - onChoices(useGetChoicesResponse.choices); - }); - - wrapper.update(); - expect(wrapper.find('[data-test-subj="impactSelect"]').first().prop('options')).toEqual([ - { value: '1', text: '1 - Critical' }, - { value: '2', text: '2 - High' }, - { value: '3', text: '3 - Moderate' }, - { value: '4', text: '4 - Low' }, - ]); + const testers = ['severity', 'urgency', 'impact']; + testers.forEach((subj) => + expect(wrapper.find(`[data-test-subj="${subj}Select"]`).first().prop('options')).toEqual([ + { value: '1', text: '1 - Critical' }, + { value: '2', text: '2 - High' }, + { value: '3', text: '3 - Moderate' }, + { value: '4', text: '4 - Low' }, + ]) + ); }); describe('UI updates', () => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts index 1e1ba99633995..288b6e629112d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/translations.ts @@ -17,7 +17,7 @@ export const SERVICENOW_ITSM_DESC = i18n.translate( export const SERVICENOW_SIR_DESC = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNowSIR.selectMessageText', { - defaultMessage: 'Create an incident in ServiceNow SIR.', + defaultMessage: 'Create an incident in ServiceNow SecOps.', } ); @@ -31,7 +31,7 @@ export const SERVICENOW_ITSM_TITLE = i18n.translate( export const SERVICENOW_SIR_TITLE = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.serviceNowSIR.actionTypeTitle', { - defaultMessage: 'ServiceNow SIR', + defaultMessage: 'ServiceNow SecOps', } ); @@ -172,7 +172,7 @@ export const MALWARE_URL_LABEL = i18n.translate( export const MALWARE_HASH_LABEL = i18n.translate( 'xpack.triggersActionsUI.components.builtinActionTypes.servicenow.malwareHashTitle', { - defaultMessage: 'Malware hash', + defaultMessage: 'Malware Hash', } ); diff --git a/x-pack/plugins/triggers_actions_ui/public/common/expression_items/for_the_last.tsx b/x-pack/plugins/triggers_actions_ui/public/common/expression_items/for_the_last.tsx index ccc8e6e2080a7..b0113cdd70451 100644 --- a/x-pack/plugins/triggers_actions_ui/public/common/expression_items/for_the_last.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/common/expression_items/for_the_last.tsx @@ -66,6 +66,7 @@ export const ForLastExpression = ({ defaultMessage: 'for the last', } )} + data-test-subj="forLastExpression" value={`${timeWindowSize} ${getTimeUnitLabel( timeWindowUnit as TIME_UNITS, (timeWindowSize ?? '').toString() diff --git a/x-pack/plugins/triggers_actions_ui/public/common/expression_items/group_by_over.tsx b/x-pack/plugins/triggers_actions_ui/public/common/expression_items/group_by_over.tsx index 5eb942b560b77..37894e6f5be98 100644 --- a/x-pack/plugins/triggers_actions_ui/public/common/expression_items/group_by_over.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/common/expression_items/group_by_over.tsx @@ -96,6 +96,7 @@ export const GroupByExpression = ({ } ) }`} + data-test-subj="groupByExpression" value={`${groupByTypes[groupBy].text} ${ groupByTypes[groupBy].sizeRequired ? `${termSize} ${termField ? `'${termField}'` : ''}` diff --git a/x-pack/plugins/triggers_actions_ui/public/common/index.ts b/x-pack/plugins/triggers_actions_ui/public/common/index.ts index f16f1dc1bc1cf..01470bdddf4d7 100644 --- a/x-pack/plugins/triggers_actions_ui/public/common/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/common/index.ts @@ -11,6 +11,9 @@ export * from './index_controls'; export * from './lib'; export * from './types'; -export { serviceNowITSMConfiguration as ServiceNowITSMConnectorConfiguration } from '../application/components/builtin_action_types/servicenow/config'; -export { connectorConfiguration as JiraConnectorConfiguration } from '../application/components/builtin_action_types/jira/config'; -export { connectorConfiguration as ResilientConnectorConfiguration } from '../application/components/builtin_action_types/resilient/config'; +export { + getServiceNowITSMActionType, + getServiceNowSIRActionType, +} from '../application/components/builtin_action_types/servicenow'; +export { getJiraActionType } from '../application/components/builtin_action_types/jira'; +export { getResilientActionType } from '../application/components/builtin_action_types/resilient'; diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts index 7c4ee7b9b0de7..878507bcf4afc 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts @@ -39,6 +39,9 @@ export function getAllExternalServiceSimulatorPaths(): string[] { getExternalServiceSimulatorPath(service) ); allPaths.push(`/api/_${NAME}/${ExternalServiceSimulator.SERVICENOW}/api/now/v2/table/incident`); + allPaths.push( + `/api/_${NAME}/${ExternalServiceSimulator.SERVICENOW}/api/now/v2/table/incident/123` + ); allPaths.push(`/api/_${NAME}/${ExternalServiceSimulator.SERVICENOW}/api/now/v2/table/sys_choice`); allPaths.push( `/api/_${NAME}/${ExternalServiceSimulator.SERVICENOW}/api/now/v2/table/sys_dictionary` diff --git a/x-pack/test/case_api_integration/basic/tests/cases/push_case.ts b/x-pack/test/case_api_integration/basic/tests/cases/push_case.ts index fe891dc6c5f34..ef7c57b3b4749 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/push_case.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/push_case.ts @@ -59,7 +59,7 @@ export default ({ getService }: FtrProviderContext): void => { .expect(200); actionsRemover.add('default', connector.id, 'action', 'actions'); - const { body: configure } = await supertest + await supertest .post(CASE_CONFIGURE_URL) .set('kbn-xsrf', 'true') .send( @@ -70,6 +70,7 @@ export default ({ getService }: FtrProviderContext): void => { }) ) .expect(200); + const { body: postedCase } = await supertest .post(CASES_URL) .set('kbn-xsrf', 'true') @@ -79,25 +80,34 @@ export default ({ getService }: FtrProviderContext): void => { id: connector.id, name: connector.name, type: connector.actionTypeId, - fields: { urgency: null, impact: null, severity: null }, + fields: { urgency: '2', impact: '2', severity: '2' }, }).connector, }) .expect(200); const { body } = await supertest - .post(`${CASES_URL}/${postedCase.id}/_push`) + .post(`${CASES_URL}/${postedCase.id}/connector/${connector.id}/_push`) .set('kbn-xsrf', 'true') - .send({ - connector_id: configure.connector.id, - connector_name: configure.connector.name, - external_id: 'external_id', - external_title: 'external_title', - external_url: 'external_url', - }) + .send({}) .expect(200); - expect(body.connector.id).to.eql(configure.connector.id); - expect(body.external_service.pushed_by).to.eql(defaultUser); + // eslint-disable-next-line @typescript-eslint/naming-convention + const { pushed_at, external_url, ...rest } = body.external_service; + + expect(rest).to.eql({ + pushed_by: defaultUser, + connector_id: connector.id, + connector_name: connector.name, + external_id: '123', + external_title: 'INC01', + }); + + // external_url is of the form http://elastic:changeme@localhost:5620 which is different between various environments like Jekins + expect( + external_url.includes( + 'api/_actions-FTS-external-service-simulators/servicenow/nav_to.do?uri=incident.do?sys_id=123' + ) + ).to.equal(true); }); it('pushes a comment appropriately', async () => { @@ -112,7 +122,7 @@ export default ({ getService }: FtrProviderContext): void => { actionsRemover.add('default', connector.id, 'action', 'actions'); - const { body: configure } = await supertest + await supertest .post(CASE_CONFIGURE_URL) .set('kbn-xsrf', 'true') .send( @@ -133,79 +143,134 @@ export default ({ getService }: FtrProviderContext): void => { id: connector.id, name: connector.name, type: connector.actionTypeId, - fields: { urgency: null, impact: null, severity: null }, + fields: { urgency: '2', impact: '2', severity: '2' }, }).connector, }) .expect(200); await supertest - .post(`${CASES_URL}/${postedCase.id}/_push`) + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentUserReq) + .expect(200); + + const { body } = await supertest + .post(`${CASES_URL}/${postedCase.id}/connector/${connector.id}/_push`) + .set('kbn-xsrf', 'true') + .send({}) + .expect(200); + + expect(body.comments[0].pushed_by).to.eql(defaultUser); + }); + + it('should pushes a case and closes when closure_type: close-by-pushing', async () => { + const { body: connector } = await supertest + .post('/api/actions/action') .set('kbn-xsrf', 'true') .send({ - connector_id: configure.connector.id, - connector_name: configure.connector.name, - external_id: 'external_id', - external_title: 'external_title', - external_url: 'external_url', + ...getServiceNowConnector(), + config: { apiUrl: servicenowSimulatorURL }, }) .expect(200); + actionsRemover.add('default', connector.id, 'action', 'actions'); await supertest - .post(`${CASES_URL}/${postedCase.id}/comments`) + .post(CASE_CONFIGURE_URL) .set('kbn-xsrf', 'true') - .send(postCommentUserReq) + .send({ + ...getConfiguration({ + id: connector.id, + name: connector.name, + type: connector.actionTypeId, + }), + closure_type: 'close-by-pushing', + }) .expect(200); - const { body } = await supertest - .post(`${CASES_URL}/${postedCase.id}/_push`) + const { body: postedCase } = await supertest + .post(CASES_URL) .set('kbn-xsrf', 'true') .send({ - connector_id: configure.connector.id, - connector_name: configure.connector.name, - external_id: 'external_id', - external_title: 'external_title', - external_url: 'external_url', + ...postCaseReq, + connector: getConfiguration({ + id: connector.id, + name: connector.name, + type: connector.actionTypeId, + fields: { urgency: '2', impact: '2', severity: '2' }, + }).connector, }) .expect(200); - expect(body.comments[0].pushed_by).to.eql(defaultUser); + const { body } = await supertest + .post(`${CASES_URL}/${postedCase.id}/connector/${connector.id}/_push`) + .set('kbn-xsrf', 'true') + .send({}) + .expect(200); + + expect(body.status).to.eql('closed'); }); it('unhappy path - 404s when case does not exist', async () => { await supertest - .post(`${CASES_URL}/fake-id/_push`) + .post(`${CASES_URL}/fake-id/connector/fake-connector/_push`) .set('kbn-xsrf', 'true') - .send({ - connector_id: 'connector_id', - connector_name: 'connector_name', - external_id: 'external_id', - external_title: 'external_title', - external_url: 'external_url', - }) + .send({}) .expect(404); }); - it('unhappy path - 400s when bad data supplied', async () => { - await supertest - .post(`${CASES_URL}/fake-id/_push`) + it('unhappy path - 404s when connector does not exist', async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) .set('kbn-xsrf', 'true') .send({ - badKey: 'connector_id', + ...postCaseReq, + connector: getConfiguration().connector, }) - .expect(400); + .expect(200); + + await supertest + .post(`${CASES_URL}/${postedCase.id}/connector/fake-connector/_push`) + .set('kbn-xsrf', 'true') + .send({}) + .expect(404); }); it('unhappy path = 409s when case is closed', async () => { - const { body: configure } = await supertest + const { body: connector } = await supertest + .post('/api/actions/action') + .set('kbn-xsrf', 'true') + .send({ + ...getServiceNowConnector(), + config: { apiUrl: servicenowSimulatorURL }, + }) + .expect(200); + + actionsRemover.add('default', connector.id, 'action', 'actions'); + + await supertest .post(CASE_CONFIGURE_URL) .set('kbn-xsrf', 'true') - .send(getConfiguration()) + .send( + getConfiguration({ + id: connector.id, + name: connector.name, + type: connector.actionTypeId, + }) + ) .expect(200); const { body: postedCase } = await supertest .post(CASES_URL) .set('kbn-xsrf', 'true') - .send(postCaseReq) + .send({ + ...postCaseReq, + connector: getConfiguration({ + id: connector.id, + name: connector.name, + type: connector.actionTypeId, + fields: { urgency: '2', impact: '2', severity: '2' }, + }).connector, + }) .expect(200); await supertest @@ -223,15 +288,9 @@ export default ({ getService }: FtrProviderContext): void => { .expect(200); await supertest - .post(`${CASES_URL}/${postedCase.id}/_push`) + .post(`${CASES_URL}/${postedCase.id}/connector/${connector.id}/_push`) .set('kbn-xsrf', 'true') - .send({ - connector_id: configure.connector.id, - connector_name: configure.connector.name, - external_id: 'external_id', - external_title: 'external_title', - external_url: 'external_url', - }) + .send({}) .expect(409); }); }); diff --git a/x-pack/test/case_api_integration/basic/tests/cases/user_actions/get_all_user_actions.ts b/x-pack/test/case_api_integration/basic/tests/cases/user_actions/get_all_user_actions.ts index d0b6ae53cbcd0..d83d87da1e7af 100644 --- a/x-pack/test/case_api_integration/basic/tests/cases/user_actions/get_all_user_actions.ts +++ b/x-pack/test/case_api_integration/basic/tests/cases/user_actions/get_all_user_actions.ts @@ -359,21 +359,15 @@ export default ({ getService }: FtrProviderContext): void => { id: connector.id, name: connector.name, type: connector.actionTypeId, - fields: { urgency: null, impact: null, severity: null }, + fields: { urgency: '2', impact: '2', severity: '2' }, }).connector, }) .expect(200); await supertest - .post(`${CASES_URL}/${postedCase.id}/_push`) + .post(`${CASES_URL}/${postedCase.id}/connector/${connector.id}/_push`) .set('kbn-xsrf', 'true') - .send({ - connector_id: configure.connector.id, - connector_name: configure.connector.name, - external_id: 'external_id', - external_title: 'external_title', - external_url: 'external_url', - }) + .send({}) .expect(200); const { body } = await supertest diff --git a/x-pack/test/case_api_integration/common/lib/utils.ts b/x-pack/test/case_api_integration/common/lib/utils.ts index 7115576ccccbd..27a49c3f05869 100644 --- a/x-pack/test/case_api_integration/common/lib/utils.ts +++ b/x-pack/test/case_api_integration/common/lib/utils.ts @@ -14,8 +14,8 @@ import { } from '../../../../plugins/case/common/api'; export const getConfiguration = ({ - id = 'connector-1', - name = 'Connector 1', + id = 'none', + name = 'none', type = ConnectorTypes.none, fields = null, }: Partial = {}): CasesConfigureRequest => { diff --git a/x-pack/test/fleet_api_integration/apis/agents/enroll.ts b/x-pack/test/fleet_api_integration/apis/agents/enroll.ts index 96c472697801e..3358d045fe69b 100644 --- a/x-pack/test/fleet_api_integration/apis/agents/enroll.ts +++ b/x-pack/test/fleet_api_integration/apis/agents/enroll.ts @@ -18,8 +18,9 @@ export default function (providerContext: FtrProviderContext) { const esArchiver = getService('esArchiver'); const esClient = getService('es'); const kibanaServer = getService('kibanaServer'); - + const supertestWithAuth = getService('supertest'); const supertest = getSupertestWithoutAuth(providerContext); + let apiKey: { id: string; api_key: string }; let kibanaVersion: string; @@ -58,6 +59,51 @@ export default function (providerContext: FtrProviderContext) { await esArchiver.unload('fleet/agents'); }); + it('should not allow enrolling in a managed policy', async () => { + // update existing policy to managed + await supertestWithAuth + .put(`/api/fleet/agent_policies/policy1`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'Test policy', + namespace: 'default', + is_managed: true, + }) + .expect(200); + + // try to enroll in managed policy + const { body } = await supertest + .post(`/api/fleet/agents/enroll`) + .set('kbn-xsrf', 'xxx') + .set( + 'Authorization', + `ApiKey ${Buffer.from(`${apiKey.id}:${apiKey.api_key}`).toString('base64')}` + ) + .send({ + type: 'PERMANENT', + metadata: { + local: { + elastic: { agent: { version: kibanaVersion } }, + }, + user_provided: {}, + }, + }) + .expect(400); + + expect(body.message).to.contain('Cannot enroll in managed policy'); + + // restore to original (unmanaged) + await supertestWithAuth + .put(`/api/fleet/agent_policies/policy1`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'Test policy', + namespace: 'default', + is_managed: false, + }) + .expect(200); + }); + it('should not allow to enroll an agent with a invalid enrollment', async () => { await supertest .post(`/api/fleet/agents/enroll`) diff --git a/x-pack/test/functional/apps/status_page/status_page.ts b/x-pack/test/functional/apps/status_page/status_page.ts index d2d6a24bdccd1..55a54245cf832 100644 --- a/x-pack/test/functional/apps/status_page/status_page.ts +++ b/x-pack/test/functional/apps/status_page/status_page.ts @@ -14,7 +14,8 @@ export default function statusPageFunctonalTests({ const esArchiver = getService('esArchiver'); const PageObjects = getPageObjects(['security', 'statusPage', 'home']); - describe('Status Page', function () { + // FLAKY: https://github.com/elastic/kibana/issues/50448 + describe.skip('Status Page', function () { this.tags(['skipCloud', 'includeFirefox']); before(async () => await esArchiver.load('empty_kibana')); after(async () => await esArchiver.unload('empty_kibana')); diff --git a/x-pack/test/functional/apps/transform/feature_controls/transform_security.ts b/x-pack/test/functional/apps/transform/feature_controls/transform_security.ts index 46e0c01afcc38..b8d6b88e4ed9a 100644 --- a/x-pack/test/functional/apps/transform/feature_controls/transform_security.ts +++ b/x-pack/test/functional/apps/transform/feature_controls/transform_security.ts @@ -15,7 +15,8 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const appsMenu = getService('appsMenu'); const managementMenu = getService('managementMenu'); - describe('security', () => { + // FLAKY: https://github.com/elastic/kibana/issues/90576 + describe.skip('security', () => { before(async () => { await esArchiver.load('empty_kibana'); await PageObjects.security.forceLogout(); diff --git a/x-pack/test/functional_with_es_ssl/config.ts b/x-pack/test/functional_with_es_ssl/config.ts index 5be8eee3155b9..a7259f2410d6b 100644 --- a/x-pack/test/functional_with_es_ssl/config.ts +++ b/x-pack/test/functional_with_es_ssl/config.ts @@ -5,6 +5,7 @@ * 2.0. */ +import Fs from 'fs'; import { resolve, join } from 'path'; import { CA_CERT_PATH } from '@kbn/dev-utils'; import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; @@ -33,6 +34,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { elasticsearch: { ...xpackFunctionalConfig.get('servers.elasticsearch'), protocol: 'https', + certificateAuthorities: [Fs.readFileSync(CA_CERT_PATH)], }, }; diff --git a/x-pack/test/tsconfig.json b/x-pack/test/tsconfig.json index 2981346e80e1d..7ba5c00a71b37 100644 --- a/x-pack/test/tsconfig.json +++ b/x-pack/test/tsconfig.json @@ -45,6 +45,11 @@ { "path": "../plugins/code/tsconfig.json" }, { "path": "../plugins/console_extensions/tsconfig.json" }, { "path": "../plugins/dashboard_mode/tsconfig.json" }, + { "path": "../plugins/enterprise_search/tsconfig.json" }, + { "path": "../plugins/fleet/tsconfig.json" }, + { "path": "../plugins/global_search/tsconfig.json" }, + { "path": "../plugins/global_search_providers/tsconfig.json" }, + { "path": "../plugins/features/tsconfig.json" }, { "path": "../plugins/data_enhanced/tsconfig.json" }, { "path": "../plugins/embeddable_enhanced/tsconfig.json" }, { "path": "../plugins/encrypted_saved_objects/tsconfig.json" }, diff --git a/x-pack/tsconfig.json b/x-pack/tsconfig.json index 740bac3f1b0de..3afbb027e7fde 100644 --- a/x-pack/tsconfig.json +++ b/x-pack/tsconfig.json @@ -14,6 +14,7 @@ "plugins/discover_enhanced/**/*", "plugins/dashboard_mode/**/*", "plugins/dashboard_enhanced/**/*", + "plugins/fleet/**/*", "plugins/global_search/**/*", "plugins/global_search_providers/**/*", "plugins/graph/**/*", diff --git a/x-pack/tsconfig.refs.json b/x-pack/tsconfig.refs.json index 7a2eebc78b69b..54cee9b124237 100644 --- a/x-pack/tsconfig.refs.json +++ b/x-pack/tsconfig.refs.json @@ -18,6 +18,7 @@ { "path": "./plugins/event_log/tsconfig.json" }, { "path": "./plugins/features/tsconfig.json" }, { "path": "./plugins/file_upload/tsconfig.json" }, + { "path": "./plugins/fleet/tsconfig.json" }, { "path": "./plugins/global_search_bar/tsconfig.json" }, { "path": "./plugins/global_search_providers/tsconfig.json" }, { "path": "./plugins/global_search/tsconfig.json" },