diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 58431e405ea8..4aff1c81c40f 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -158,6 +158,12 @@ export const showAllOthersBucket: string[] = [ /** * CreateTemplateTimelineBtn + * https://github.com/elastic/kibana/pull/66613 * Remove the comment here to enable template timeline */ -export const disableTemplate = true; +export const disableTemplate = false; + +/* + * This should be set to true after https://github.com/elastic/kibana/pull/67496 is merged + */ +export const enableElasticFilter = false; diff --git a/x-pack/plugins/security_solution/common/types/timeline/index.ts b/x-pack/plugins/security_solution/common/types/timeline/index.ts index 4f255bb6d683..2cf5930a83be 100644 --- a/x-pack/plugins/security_solution/common/types/timeline/index.ts +++ b/x-pack/plugins/security_solution/common/types/timeline/index.ts @@ -137,11 +137,13 @@ const SavedSortRuntimeType = runtimeTypes.partial({ export enum TimelineStatus { active = 'active', draft = 'draft', + immutable = 'immutable', } export const TimelineStatusLiteralRt = runtimeTypes.union([ runtimeTypes.literal(TimelineStatus.active), runtimeTypes.literal(TimelineStatus.draft), + runtimeTypes.literal(TimelineStatus.immutable), ]); const TimelineStatusLiteralWithNullRt = unionWithNullType(TimelineStatusLiteralRt); @@ -151,6 +153,29 @@ export type TimelineStatusLiteralWithNull = runtimeTypes.TypeOf< typeof TimelineStatusLiteralWithNullRt >; +/** + * Template timeline type + */ + +export enum TemplateTimelineType { + elastic = 'elastic', + custom = 'custom', +} + +export const TemplateTimelineTypeLiteralRt = runtimeTypes.union([ + runtimeTypes.literal(TemplateTimelineType.elastic), + runtimeTypes.literal(TemplateTimelineType.custom), +]); + +export const TemplateTimelineTypeLiteralWithNullRt = unionWithNullType( + TemplateTimelineTypeLiteralRt +); + +export type TemplateTimelineTypeLiteral = runtimeTypes.TypeOf; +export type TemplateTimelineTypeLiteralWithNull = runtimeTypes.TypeOf< + typeof TemplateTimelineTypeLiteralWithNullRt +>; + /* * Timeline Types */ @@ -273,6 +298,13 @@ export const TimelineResponseType = runtimeTypes.type({ }), }); +export const TimelineErrorResponseType = runtimeTypes.type({ + status_code: runtimeTypes.number, + message: runtimeTypes.string, +}); + +export interface TimelineErrorResponse + extends runtimeTypes.TypeOf {} export interface TimelineResponse extends runtimeTypes.TypeOf {} /** diff --git a/x-pack/plugins/security_solution/public/alerts/components/alerts_table/actions.test.tsx b/x-pack/plugins/security_solution/public/alerts/components/alerts_table/actions.test.tsx index 2fa7cfeedcd1..bd62b79a3c54 100644 --- a/x-pack/plugins/security_solution/public/alerts/components/alerts_table/actions.test.tsx +++ b/x-pack/plugins/security_solution/public/alerts/components/alerts_table/actions.test.tsx @@ -215,8 +215,8 @@ describe('alert actions', () => { columnId: '@timestamp', sortDirection: 'desc', }, - status: TimelineStatus.draft, - title: '', + status: TimelineStatus.active, + title: 'Test rule - Duplicate', timelineType: TimelineType.default, templateTimelineId: null, templateTimelineVersion: null, diff --git a/x-pack/plugins/security_solution/public/common/components/add_filter_to_global_search_bar/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/add_filter_to_global_search_bar/index.test.tsx index c7015ed81701..9c08e05ddfa3 100644 --- a/x-pack/plugins/security_solution/public/common/components/add_filter_to_global_search_bar/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/add_filter_to_global_search_bar/index.test.tsx @@ -12,6 +12,7 @@ import { mockGlobalState, TestProviders, SUB_PLUGINS_REDUCER, + kibanaObservable, createSecuritySolutionStorageMock, } from '../../mock'; import { createStore, State } from '../../store'; @@ -35,10 +36,22 @@ jest.mock('../../lib/kibana', () => ({ describe('AddFilterToGlobalSearchBar Component', () => { const state: State = mockGlobalState; const { storage } = createSecuritySolutionStorageMock(); - let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + let store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); beforeEach(() => { - store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); mockAddFilters.mockClear(); }); diff --git a/x-pack/plugins/security_solution/public/common/components/error_toast_dispatcher/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/error_toast_dispatcher/index.test.tsx index 4bc77555f09b..45b75d0f33ac 100644 --- a/x-pack/plugins/security_solution/public/common/components/error_toast_dispatcher/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/error_toast_dispatcher/index.test.tsx @@ -12,6 +12,7 @@ import { apolloClientObservable, mockGlobalState, SUB_PLUGINS_REDUCER, + kibanaObservable, createSecuritySolutionStorageMock, } from '../../mock'; import { createStore } from '../../store/store'; @@ -22,10 +23,22 @@ import { State } from '../../store/types'; describe('Error Toast Dispatcher', () => { const state: State = mockGlobalState; const { storage } = createSecuritySolutionStorageMock(); - let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + let store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); beforeEach(() => { - store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); }); describe('rendering', () => { diff --git a/x-pack/plugins/security_solution/public/common/components/inspect/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/inspect/index.test.tsx index 45397921a665..f2b7d4597262 100644 --- a/x-pack/plugins/security_solution/public/common/components/inspect/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/inspect/index.test.tsx @@ -14,6 +14,7 @@ import { mockGlobalState, apolloClientObservable, SUB_PLUGINS_REDUCER, + kibanaObservable, createSecuritySolutionStorageMock, } from '../../mock'; import { createStore, State } from '../../store'; @@ -36,13 +37,25 @@ describe('Inspect Button', () => { state: state.inputs, }; - let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + let store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); describe('Render', () => { beforeEach(() => { const myState = cloneDeep(state); myState.inputs = upsertQuery(newQuery); - store = createStore(myState, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + store = createStore( + myState, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); }); test('Eui Empty Button', () => { const wrapper = mount( @@ -146,7 +159,13 @@ describe('Inspect Button', () => { response: ['my response'], }; myState.inputs = upsertQuery(myQuery); - store = createStore(myState, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + store = createStore( + myState, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); }); test('Open Inspect Modal', () => { const wrapper = mount( diff --git a/x-pack/plugins/security_solution/public/common/components/stat_items/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/stat_items/index.test.tsx index 50721ef3b26a..f548275b36e7 100644 --- a/x-pack/plugins/security_solution/public/common/components/stat_items/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/stat_items/index.test.tsx @@ -34,6 +34,7 @@ import { mockGlobalState, apolloClientObservable, SUB_PLUGINS_REDUCER, + kibanaObservable, createSecuritySolutionStorageMock, } from '../../mock'; import { State, createStore } from '../../store'; @@ -55,7 +56,13 @@ describe('Stat Items Component', () => { const theme = () => ({ eui: euiDarkVars, darkMode: true }); const state: State = mockGlobalState; const { storage } = createSecuritySolutionStorageMock(); - const store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + const store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); describe.each([ [ diff --git a/x-pack/plugins/security_solution/public/common/components/super_date_picker/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/super_date_picker/index.test.tsx index 19321622d75f..164ca177ee91 100644 --- a/x-pack/plugins/security_solution/public/common/components/super_date_picker/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/super_date_picker/index.test.tsx @@ -14,6 +14,7 @@ import { apolloClientObservable, mockGlobalState, SUB_PLUGINS_REDUCER, + kibanaObservable, createSecuritySolutionStorageMock, } from '../../mock'; import { createUseUiSetting$Mock } from '../../mock/kibana_react'; @@ -81,11 +82,23 @@ describe('SIEM Super Date Picker', () => { describe('#SuperDatePicker', () => { const state: State = mockGlobalState; const { storage } = createSecuritySolutionStorageMock(); - let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + let store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); beforeEach(() => { jest.clearAllMocks(); - store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); mockUseUiSetting$.mockImplementation((key, defaultValue) => { const useUiSetting$Mock = createUseUiSetting$Mock(); diff --git a/x-pack/plugins/security_solution/public/common/components/top_n/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/top_n/index.test.tsx index ae25e66b2af8..336f906b3bed 100644 --- a/x-pack/plugins/security_solution/public/common/components/top_n/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/top_n/index.test.tsx @@ -13,6 +13,7 @@ import { mockGlobalState, TestProviders, SUB_PLUGINS_REDUCER, + kibanaObservable, createSecuritySolutionStorageMock, } from '../../mock'; import { createKibanaCoreStartMock } from '../../mock/kibana_core'; @@ -156,7 +157,13 @@ const state: State = { }; const { storage } = createSecuritySolutionStorageMock(); -const store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); +const store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage +); describe('StatefulTopN', () => { // Suppress warnings about "react-beautiful-dnd" diff --git a/x-pack/plugins/security_solution/public/common/lib/compose/kibana_compose.tsx b/x-pack/plugins/security_solution/public/common/lib/compose/kibana_compose.tsx index 47834f148c91..342db7f43943 100644 --- a/x-pack/plugins/security_solution/public/common/lib/compose/kibana_compose.tsx +++ b/x-pack/plugins/security_solution/public/common/lib/compose/kibana_compose.tsx @@ -9,10 +9,10 @@ import ApolloClient from 'apollo-client'; import { ApolloLink } from 'apollo-link'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { CoreStart } from '../../../../../../../src/core/public'; import introspectionQueryResultData from '../../../graphql/introspection.json'; import { AppFrontendLibs } from '../lib'; import { getLinks } from './helpers'; +import { CoreStart } from '../../../../../../../src/core/public'; export function composeLibs(core: CoreStart): AppFrontendLibs { const cache = new InMemoryCache({ diff --git a/x-pack/plugins/security_solution/public/common/mock/endpoint/app_context_render.tsx b/x-pack/plugins/security_solution/public/common/mock/endpoint/app_context_render.tsx index 1db63897a886..779d5eff0b97 100644 --- a/x-pack/plugins/security_solution/public/common/mock/endpoint/app_context_render.tsx +++ b/x-pack/plugins/security_solution/public/common/mock/endpoint/app_context_render.tsx @@ -13,7 +13,7 @@ import { coreMock } from '../../../../../../../src/core/public/mocks'; import { StartPlugins } from '../../../types'; import { depsStartMock } from './dependencies_start_mock'; import { MiddlewareActionSpyHelper, createSpyMiddleware } from '../../store/test_utils'; -import { apolloClientObservable } from '../test_providers'; +import { apolloClientObservable, kibanaObservable } from '../test_providers'; import { createStore, State, substateMiddlewareFactory } from '../../store'; import { alertMiddlewareFactory } from '../../../endpoint_alerts/store/middleware'; import { AppRootProvider } from './app_root_provider'; @@ -58,14 +58,21 @@ export const createAppRootMockRenderer = (): AppContextTestRender => { const middlewareSpy = createSpyMiddleware(); const { storage } = createSecuritySolutionStorageMock(); - const store = createStore(mockGlobalState, SUB_PLUGINS_REDUCER, apolloClientObservable, storage, [ - substateMiddlewareFactory( - (globalState) => globalState.alertList, - alertMiddlewareFactory(coreStart, depsStart) - ), - ...managementMiddlewareFactory(coreStart, depsStart), - middlewareSpy.actionSpyMiddleware, - ]); + const store = createStore( + mockGlobalState, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage, + [ + substateMiddlewareFactory( + (globalState) => globalState.alertList, + alertMiddlewareFactory(coreStart, depsStart) + ), + ...managementMiddlewareFactory(coreStart, depsStart), + middlewareSpy.actionSpyMiddleware, + ] + ); const MockKibanaContextProvider = createKibanaContextProviderMock(); diff --git a/x-pack/plugins/security_solution/public/common/mock/kibana_react.ts b/x-pack/plugins/security_solution/public/common/mock/kibana_react.ts index 2b639bfdc14f..c5d50e137948 100644 --- a/x-pack/plugins/security_solution/public/common/mock/kibana_react.ts +++ b/x-pack/plugins/security_solution/public/common/mock/kibana_react.ts @@ -26,6 +26,7 @@ import { DEFAULT_INDEX_PATTERN, } from '../../../common/constants'; import { createKibanaCoreStartMock, createKibanaPluginsStartMock } from './kibana_core'; +import { StartServices } from '../../types'; import { createSecuritySolutionStorageMock } from './mock_local_storage'; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -71,6 +72,8 @@ export const createUseUiSetting$Mock = () => { ): [T, () => void] | undefined => [useUiSettingMock(key, defaultValue), jest.fn()]; }; +export const createKibanaObservable$Mock = createKibanaCoreStartMock; + export const createUseKibanaMock = () => { const core = createKibanaCoreStartMock(); const plugins = createKibanaPluginsStartMock(); @@ -90,6 +93,36 @@ export const createUseKibanaMock = () => { return () => ({ services }); }; +export const createStartServices = () => { + const core = createKibanaCoreStartMock(); + const plugins = createKibanaPluginsStartMock(); + const security = { + authc: { + getCurrentUser: jest.fn(), + areAPIKeysEnabled: jest.fn(), + }, + sessionTimeout: { + start: jest.fn(), + stop: jest.fn(), + extend: jest.fn(), + }, + license: { + isEnabled: jest.fn(), + getFeatures: jest.fn(), + features$: jest.fn(), + }, + __legacyCompat: { logoutUrl: 'logoutUrl', tenant: 'tenant' }, + }; + + const services = ({ + ...core, + ...plugins, + security, + } as unknown) as StartServices; + + return services; +}; + export const createWithKibanaMock = () => { const kibana = createUseKibanaMock()(); diff --git a/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx b/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx index 0573f049c35c..297dc235a2a5 100644 --- a/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx +++ b/x-pack/plugins/security_solution/public/common/mock/test_providers.tsx @@ -19,7 +19,7 @@ import { ThemeProvider } from 'styled-components'; import { createStore, State } from '../store'; import { mockGlobalState } from './global_state'; -import { createKibanaContextProviderMock } from './kibana_react'; +import { createKibanaContextProviderMock, createStartServices } from './kibana_react'; import { FieldHook, useForm } from '../../shared_imports'; import { SUB_PLUGINS_REDUCER } from './utils'; import { createSecuritySolutionStorageMock, localStorageMock } from './mock_local_storage'; @@ -38,6 +38,7 @@ export const apolloClient = new ApolloClient({ }); export const apolloClientObservable = new BehaviorSubject(apolloClient); +export const kibanaObservable = new BehaviorSubject(createStartServices()); Object.defineProperty(window, 'localStorage', { value: localStorageMock(), @@ -49,7 +50,13 @@ const { storage } = createSecuritySolutionStorageMock(); /** A utility for wrapping children in the providers required to run most tests */ const TestProvidersComponent: React.FC = ({ children, - store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage), + store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ), onDragEnd = jest.fn(), }) => ( @@ -69,7 +76,13 @@ export const TestProviders = React.memo(TestProvidersComponent); const TestProviderWithoutDragAndDropComponent: React.FC = ({ children, - store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage), + store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ), }) => ( {children} diff --git a/x-pack/plugins/security_solution/public/common/store/store.ts b/x-pack/plugins/security_solution/public/common/store/store.ts index 5f53724b287d..a39c9f18bcdb 100644 --- a/x-pack/plugins/security_solution/public/common/store/store.ts +++ b/x-pack/plugins/security_solution/public/common/store/store.ts @@ -29,6 +29,7 @@ import { AppAction } from './actions'; import { Immutable } from '../../../common/endpoint/types'; import { State } from './types'; import { Storage } from '../../../../../../src/plugins/kibana_utils/public'; +import { CoreStart } from '../../../../../../src/core/public'; type ComposeType = typeof compose; declare global { @@ -49,6 +50,7 @@ export const createStore = ( state: PreloadedState, pluginsReducer: SubPluginsInitReducer, apolloClient: Observable, + kibana: Observable, storage: Storage, additionalMiddleware?: Array>>> ): Store => { @@ -56,6 +58,7 @@ export const createStore = ( const middlewareDependencies = { apolloClient$: apolloClient, + kibana$: kibana, selectAllTimelineQuery: inputsSelectors.globalQueryByIdSelector, selectNotesByIdSelector: appSelectors.selectNotesByIdSelector, timelineByIdSelector: timelineSelectors.timelineByIdSelector, diff --git a/x-pack/plugins/security_solution/public/common/store/types.ts b/x-pack/plugins/security_solution/public/common/store/types.ts index 2b92451e3011..d1e8df0f982c 100644 --- a/x-pack/plugins/security_solution/public/common/store/types.ts +++ b/x-pack/plugins/security_solution/public/common/store/types.ts @@ -19,23 +19,22 @@ import { NetworkPluginState } from '../../network/store'; import { EndpointAlertsPluginState } from '../../endpoint_alerts'; import { ManagementPluginState } from '../../management'; +export type StoreState = HostsPluginState & + NetworkPluginState & + TimelinePluginState & + EndpointAlertsPluginState & + ManagementPluginState & { + app: AppState; + dragAndDrop: DragAndDropState; + inputs: InputsState; + }; /** * The redux `State` type for the Security App. * We use `CombinedState` to wrap our shape because we create our reducer using `combineReducers`. * `combineReducers` returns a type wrapped in `CombinedState`. * `CombinedState` is required for redux to know what keys to make optional when preloaded state into a store. */ -export type State = CombinedState< - HostsPluginState & - NetworkPluginState & - TimelinePluginState & - EndpointAlertsPluginState & - ManagementPluginState & { - app: AppState; - dragAndDrop: DragAndDropState; - inputs: InputsState; - } ->; +export type State = CombinedState; export type KueryFilterQueryKind = 'kuery' | 'lucene'; diff --git a/x-pack/plugins/security_solution/public/endpoint_alerts/view/test_helpers/render_alert_page.tsx b/x-pack/plugins/security_solution/public/endpoint_alerts/view/test_helpers/render_alert_page.tsx index acfe3f228c21..f03c72518305 100644 --- a/x-pack/plugins/security_solution/public/endpoint_alerts/view/test_helpers/render_alert_page.tsx +++ b/x-pack/plugins/security_solution/public/endpoint_alerts/view/test_helpers/render_alert_page.tsx @@ -19,6 +19,7 @@ import { SUB_PLUGINS_REDUCER, mockGlobalState, apolloClientObservable, + kibanaObservable, createSecuritySolutionStorageMock, } from '../../../common/mock'; @@ -31,7 +32,13 @@ export const alertPageTestRender = () => { * Create a store, with the middleware disabled. We don't want side effects being created by our code in this test. */ const { storage } = createSecuritySolutionStorageMock(); - const store = createStore(mockGlobalState, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + const store = createStore( + mockGlobalState, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); const depsStart = depsStartMock(); depsStart.data.ui.SearchBar.mockImplementation(() =>
); diff --git a/x-pack/plugins/security_solution/public/graphql/introspection.json b/x-pack/plugins/security_solution/public/graphql/introspection.json index 48547212bb6c..69356f8fc8aa 100644 --- a/x-pack/plugins/security_solution/public/graphql/introspection.json +++ b/x-pack/plugins/security_solution/public/graphql/introspection.json @@ -255,6 +255,18 @@ "description": "", "type": { "kind": "ENUM", "name": "TimelineType", "ofType": null }, "defaultValue": null + }, + { + "name": "templateTimelineType", + "description": "", + "type": { "kind": "ENUM", "name": "TemplateTimelineType", "ofType": null }, + "defaultValue": null + }, + { + "name": "status", + "description": "", + "type": { "kind": "ENUM", "name": "TimelineStatus", "ofType": null }, + "defaultValue": null } ], "type": { @@ -10405,7 +10417,13 @@ "interfaces": null, "enumValues": [ { "name": "active", "description": "", "isDeprecated": false, "deprecationReason": null }, - { "name": "draft", "description": "", "isDeprecated": false, "deprecationReason": null } + { "name": "draft", "description": "", "isDeprecated": false, "deprecationReason": null }, + { + "name": "immutable", + "description": "", + "isDeprecated": false, + "deprecationReason": null + } ], "possibleTypes": null }, @@ -10529,6 +10547,24 @@ ], "possibleTypes": null }, + { + "kind": "ENUM", + "name": "TemplateTimelineType", + "description": "", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "elastic", + "description": "", + "isDeprecated": false, + "deprecationReason": null + }, + { "name": "custom", "description": "", "isDeprecated": false, "deprecationReason": null } + ], + "possibleTypes": null + }, { "kind": "OBJECT", "name": "ResponseTimelines", @@ -10557,6 +10593,46 @@ "type": { "kind": "SCALAR", "name": "Float", "ofType": null }, "isDeprecated": false, "deprecationReason": null + }, + { + "name": "defaultTimelineCount", + "description": "", + "args": [], + "type": { "kind": "SCALAR", "name": "Float", "ofType": null }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "templateTimelineCount", + "description": "", + "args": [], + "type": { "kind": "SCALAR", "name": "Float", "ofType": null }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "elasticTemplateTimelineCount", + "description": "", + "args": [], + "type": { "kind": "SCALAR", "name": "Float", "ofType": null }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customTemplateTimelineCount", + "description": "", + "args": [], + "type": { "kind": "SCALAR", "name": "Float", "ofType": null }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "favoriteCount", + "description": "", + "args": [], + "type": { "kind": "SCALAR", "name": "Float", "ofType": null }, + "isDeprecated": false, + "deprecationReason": null } ], "inputFields": null, diff --git a/x-pack/plugins/security_solution/public/graphql/types.ts b/x-pack/plugins/security_solution/public/graphql/types.ts index b5088fe51b44..1171e9379353 100644 --- a/x-pack/plugins/security_solution/public/graphql/types.ts +++ b/x-pack/plugins/security_solution/public/graphql/types.ts @@ -345,6 +345,7 @@ export enum TlsFields { export enum TimelineStatus { active = 'active', draft = 'draft', + immutable = 'immutable', } export enum TimelineType { @@ -359,6 +360,11 @@ export enum SortFieldTimeline { created = 'created', } +export enum TemplateTimelineType { + elastic = 'elastic', + custom = 'custom', +} + export enum NetworkDirectionEcs { inbound = 'inbound', outbound = 'outbound', @@ -2117,6 +2123,16 @@ export interface ResponseTimelines { timeline: (Maybe)[]; totalCount?: Maybe; + + defaultTimelineCount?: Maybe; + + templateTimelineCount?: Maybe; + + elasticTemplateTimelineCount?: Maybe; + + customTemplateTimelineCount?: Maybe; + + favoriteCount?: Maybe; } export interface Mutation { @@ -2254,6 +2270,10 @@ export interface GetAllTimelineQueryArgs { onlyUserFavorite?: Maybe; timelineType?: Maybe; + + templateTimelineType?: Maybe; + + status?: Maybe; } export interface AuthenticationsSourceArgs { timerange: TimerangeInput; @@ -4315,6 +4335,8 @@ export namespace GetAllTimeline { sort?: Maybe; onlyUserFavorite?: Maybe; timelineType?: Maybe; + templateTimelineType?: Maybe; + status?: Maybe; }; export type Query = { @@ -4328,6 +4350,16 @@ export namespace GetAllTimeline { totalCount: Maybe; + defaultTimelineCount: Maybe; + + templateTimelineCount: Maybe; + + elasticTemplateTimelineCount: Maybe; + + customTemplateTimelineCount: Maybe; + + favoriteCount: Maybe; + timeline: (Maybe)[]; }; diff --git a/x-pack/plugins/security_solution/public/hosts/components/authentications_table/index.test.tsx b/x-pack/plugins/security_solution/public/hosts/components/authentications_table/index.test.tsx index 3809d848759c..9603f30615a1 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/authentications_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/authentications_table/index.test.tsx @@ -13,6 +13,7 @@ import { apolloClientObservable, mockGlobalState, SUB_PLUGINS_REDUCER, + kibanaObservable, createSecuritySolutionStorageMock, } from '../../../common/mock'; import { createStore, State } from '../../../common/store'; @@ -26,10 +27,22 @@ describe('Authentication Table Component', () => { const state: State = mockGlobalState; const { storage } = createSecuritySolutionStorageMock(); - let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + let store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); beforeEach(() => { - store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); }); describe('rendering', () => { diff --git a/x-pack/plugins/security_solution/public/hosts/components/hosts_table/index.test.tsx b/x-pack/plugins/security_solution/public/hosts/components/hosts_table/index.test.tsx index 1231c35f2146..ab00e77a4fa4 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/hosts_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/hosts/components/hosts_table/index.test.tsx @@ -15,6 +15,7 @@ import { mockGlobalState, TestProviders, SUB_PLUGINS_REDUCER, + kibanaObservable, createSecuritySolutionStorageMock, } from '../../../common/mock'; import { useMountAppended } from '../../../common/utils/use_mount_appended'; @@ -40,11 +41,23 @@ describe('Hosts Table', () => { const state: State = mockGlobalState; const { storage } = createSecuritySolutionStorageMock(); - let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + let store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); const mount = useMountAppended(); beforeEach(() => { - store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); }); describe('rendering', () => { diff --git a/x-pack/plugins/security_solution/public/hosts/pages/hosts.test.tsx b/x-pack/plugins/security_solution/public/hosts/pages/hosts.test.tsx index ea0b32137eb3..1ea3a3020a1d 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/hosts.test.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/hosts.test.tsx @@ -16,6 +16,7 @@ import { TestProviders, mockGlobalState, SUB_PLUGINS_REDUCER, + kibanaObservable, createSecuritySolutionStorageMock, } from '../../common/mock'; import { SiemNavigation } from '../../common/components/navigation'; @@ -154,7 +155,13 @@ describe('Hosts - rendering', () => { }); const myState: State = mockGlobalState; const { storage } = createSecuritySolutionStorageMock(); - const myStore = createStore(myState, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + const myStore = createStore( + myState, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); const wrapper = mount( diff --git a/x-pack/plugins/security_solution/public/network/components/ip_overview/index.test.tsx b/x-pack/plugins/security_solution/public/network/components/ip_overview/index.test.tsx index 553cb8c63db9..b8d97f06bf85 100644 --- a/x-pack/plugins/security_solution/public/network/components/ip_overview/index.test.tsx +++ b/x-pack/plugins/security_solution/public/network/components/ip_overview/index.test.tsx @@ -14,6 +14,7 @@ import { mockGlobalState, TestProviders, SUB_PLUGINS_REDUCER, + kibanaObservable, createSecuritySolutionStorageMock, } from '../../../common/mock'; import { createStore, State } from '../../../common/store'; @@ -28,10 +29,22 @@ describe('IP Overview Component', () => { const state: State = mockGlobalState; const { storage } = createSecuritySolutionStorageMock(); - let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + let store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); beforeEach(() => { - store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); }); describe('rendering', () => { diff --git a/x-pack/plugins/security_solution/public/network/components/kpi_network/index.test.tsx b/x-pack/plugins/security_solution/public/network/components/kpi_network/index.test.tsx index 580a5420f1c3..8acd17d2ce76 100644 --- a/x-pack/plugins/security_solution/public/network/components/kpi_network/index.test.tsx +++ b/x-pack/plugins/security_solution/public/network/components/kpi_network/index.test.tsx @@ -12,6 +12,7 @@ import { apolloClientObservable, mockGlobalState, SUB_PLUGINS_REDUCER, + kibanaObservable, createSecuritySolutionStorageMock, } from '../../../common/mock'; import { createStore, State } from '../../../common/store'; @@ -25,10 +26,22 @@ describe('KpiNetwork Component', () => { const narrowDateRange = jest.fn(); const { storage } = createSecuritySolutionStorageMock(); - let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + let store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); beforeEach(() => { - store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); }); describe('rendering', () => { diff --git a/x-pack/plugins/security_solution/public/network/components/network_dns_table/index.test.tsx b/x-pack/plugins/security_solution/public/network/components/network_dns_table/index.test.tsx index 036ebedd6b88..bbbe56715d34 100644 --- a/x-pack/plugins/security_solution/public/network/components/network_dns_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/network/components/network_dns_table/index.test.tsx @@ -15,6 +15,7 @@ import { mockGlobalState, TestProviders, SUB_PLUGINS_REDUCER, + kibanaObservable, createSecuritySolutionStorageMock, } from '../../../common/mock'; import { State, createStore } from '../../../common/store'; @@ -28,11 +29,23 @@ describe('NetworkTopNFlow Table Component', () => { const loadPage = jest.fn(); const state: State = mockGlobalState; const { storage } = createSecuritySolutionStorageMock(); - let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + let store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); const mount = useMountAppended(); beforeEach(() => { - store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); }); describe('rendering', () => { diff --git a/x-pack/plugins/security_solution/public/network/components/network_http_table/index.test.tsx b/x-pack/plugins/security_solution/public/network/components/network_http_table/index.test.tsx index ac37aaf30915..72c932c575be 100644 --- a/x-pack/plugins/security_solution/public/network/components/network_http_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/network/components/network_http_table/index.test.tsx @@ -15,6 +15,7 @@ import { mockGlobalState, TestProviders, SUB_PLUGINS_REDUCER, + kibanaObservable, createSecuritySolutionStorageMock, } from '../../../common/mock'; import { useMountAppended } from '../../../common/utils/use_mount_appended'; @@ -31,11 +32,23 @@ describe('NetworkHttp Table Component', () => { const state: State = mockGlobalState; const { storage } = createSecuritySolutionStorageMock(); - let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + let store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); const mount = useMountAppended(); beforeEach(() => { - store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); }); describe('rendering', () => { diff --git a/x-pack/plugins/security_solution/public/network/components/network_top_countries_table/index.test.tsx b/x-pack/plugins/security_solution/public/network/components/network_top_countries_table/index.test.tsx index 8b1dbc8c558b..a1ee0574d8b0 100644 --- a/x-pack/plugins/security_solution/public/network/components/network_top_countries_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/network/components/network_top_countries_table/index.test.tsx @@ -17,6 +17,7 @@ import { mockIndexPattern, TestProviders, SUB_PLUGINS_REDUCER, + kibanaObservable, createSecuritySolutionStorageMock, } from '../../../common/mock'; import { useMountAppended } from '../../../common/utils/use_mount_appended'; @@ -32,10 +33,22 @@ describe('NetworkTopCountries Table Component', () => { const mount = useMountAppended(); const { storage } = createSecuritySolutionStorageMock(); - let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + let store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); beforeEach(() => { - store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); }); describe('rendering', () => { diff --git a/x-pack/plugins/security_solution/public/network/components/network_top_n_flow_table/index.test.tsx b/x-pack/plugins/security_solution/public/network/components/network_top_n_flow_table/index.test.tsx index b14d411810de..100ecaa51f4a 100644 --- a/x-pack/plugins/security_solution/public/network/components/network_top_n_flow_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/network/components/network_top_n_flow_table/index.test.tsx @@ -16,6 +16,7 @@ import { mockGlobalState, TestProviders, SUB_PLUGINS_REDUCER, + kibanaObservable, createSecuritySolutionStorageMock, } from '../../../common/mock'; import { useMountAppended } from '../../../common/utils/use_mount_appended'; @@ -31,11 +32,23 @@ describe('NetworkTopNFlow Table Component', () => { const state: State = mockGlobalState; const { storage } = createSecuritySolutionStorageMock(); - let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + let store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); const mount = useMountAppended(); beforeEach(() => { - store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); }); describe('rendering', () => { diff --git a/x-pack/plugins/security_solution/public/network/components/tls_table/index.test.tsx b/x-pack/plugins/security_solution/public/network/components/tls_table/index.test.tsx index acbe974f914d..cd2dc926c03b 100644 --- a/x-pack/plugins/security_solution/public/network/components/tls_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/network/components/tls_table/index.test.tsx @@ -15,6 +15,7 @@ import { mockGlobalState, TestProviders, SUB_PLUGINS_REDUCER, + kibanaObservable, createSecuritySolutionStorageMock, } from '../../../common/mock'; import { useMountAppended } from '../../../common/utils/use_mount_appended'; @@ -28,11 +29,23 @@ describe('Tls Table Component', () => { const state: State = mockGlobalState; const { storage } = createSecuritySolutionStorageMock(); - let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + let store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); const mount = useMountAppended(); beforeEach(() => { - store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); }); describe('Rendering', () => { diff --git a/x-pack/plugins/security_solution/public/network/components/users_table/index.test.tsx b/x-pack/plugins/security_solution/public/network/components/users_table/index.test.tsx index f0d4d7fbeefc..3f1762cadd65 100644 --- a/x-pack/plugins/security_solution/public/network/components/users_table/index.test.tsx +++ b/x-pack/plugins/security_solution/public/network/components/users_table/index.test.tsx @@ -16,6 +16,7 @@ import { mockGlobalState, TestProviders, SUB_PLUGINS_REDUCER, + kibanaObservable, createSecuritySolutionStorageMock, } from '../../../common/mock'; import { useMountAppended } from '../../../common/utils/use_mount_appended'; @@ -30,11 +31,23 @@ describe('Users Table Component', () => { const state: State = mockGlobalState; const { storage } = createSecuritySolutionStorageMock(); - let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + let store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); const mount = useMountAppended(); beforeEach(() => { - store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); }); describe('Rendering', () => { diff --git a/x-pack/plugins/security_solution/public/network/pages/ip_details/index.test.tsx b/x-pack/plugins/security_solution/public/network/pages/ip_details/index.test.tsx index a87eb3d05744..962a6269f848 100644 --- a/x-pack/plugins/security_solution/public/network/pages/ip_details/index.test.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/ip_details/index.test.tsx @@ -18,6 +18,7 @@ import { mockGlobalState, TestProviders, SUB_PLUGINS_REDUCER, + kibanaObservable, createSecuritySolutionStorageMock, } from '../../../common/mock'; import { useMountAppended } from '../../../common/utils/use_mount_appended'; @@ -90,7 +91,6 @@ const getMockProps = (ip: string) => ({ describe('Ip Details', () => { const mount = useMountAppended(); - beforeAll(() => { (useWithSource as jest.Mock).mockReturnValue({ indicesExist: false, @@ -107,15 +107,27 @@ describe('Ip Details', () => { }); afterAll(() => { - delete (global as GlobalWithFetch).fetch; + jest.resetAllMocks(); }); const state: State = mockGlobalState; const { storage } = createSecuritySolutionStorageMock(); - let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + let store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); beforeEach(() => { - store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); }); test('it renders', () => { diff --git a/x-pack/plugins/security_solution/public/network/pages/network.test.tsx b/x-pack/plugins/security_solution/public/network/pages/network.test.tsx index 7cdfdbf0af69..af84e1d42b45 100644 --- a/x-pack/plugins/security_solution/public/network/pages/network.test.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/network.test.tsx @@ -16,6 +16,7 @@ import { mockGlobalState, apolloClientObservable, SUB_PLUGINS_REDUCER, + kibanaObservable, createSecuritySolutionStorageMock, } from '../../common/mock'; import { State, createStore } from '../../common/store'; @@ -139,7 +140,13 @@ describe('rendering - rendering', () => { }); const myState: State = mockGlobalState; const { storage } = createSecuritySolutionStorageMock(); - const myStore = createStore(myState, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + const myStore = createStore( + myState, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); const wrapper = mount( diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_host/index.test.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_host/index.test.tsx index d29efa2d44c1..2b21385004a7 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_host/index.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_host/index.test.tsx @@ -14,6 +14,7 @@ import { mockGlobalState, TestProviders, SUB_PLUGINS_REDUCER, + kibanaObservable, createSecuritySolutionStorageMock, } from '../../../common/mock'; @@ -95,11 +96,23 @@ describe('OverviewHost', () => { const state: State = mockGlobalState; const { storage } = createSecuritySolutionStorageMock(); - let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + let store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); beforeEach(() => { const myState = cloneDeep(state); - store = createStore(myState, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + store = createStore( + myState, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); }); test('it renders the expected widget title', () => { diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_network/index.test.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_network/index.test.tsx index b4b685465dbd..7a9834ee3ea9 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_network/index.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_network/index.test.tsx @@ -14,6 +14,7 @@ import { TestProviders, SUB_PLUGINS_REDUCER, createSecuritySolutionStorageMock, + kibanaObservable, } from '../../../common/mock'; import { OverviewNetwork } from '.'; @@ -86,11 +87,23 @@ describe('OverviewNetwork', () => { const state: State = mockGlobalState; const { storage } = createSecuritySolutionStorageMock(); - let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + let store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); beforeEach(() => { const myState = cloneDeep(state); - store = createStore(myState, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + store = createStore( + myState, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); }); test('it renders the expected widget title', () => { diff --git a/x-pack/plugins/security_solution/public/overview/components/recent_timelines/index.tsx b/x-pack/plugins/security_solution/public/overview/components/recent_timelines/index.tsx index 9c149a850bec..8f2b3c7495f0 100644 --- a/x-pack/plugins/security_solution/public/overview/components/recent_timelines/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/recent_timelines/index.tsx @@ -24,6 +24,7 @@ import { RecentTimelines } from './recent_timelines'; import * as i18n from './translations'; import { FilterMode } from './types'; import { LoadingPlaceholders } from '../loading_placeholders'; +import { useTimelineStatus } from '../../../timelines/components/open_timeline/use_timeline_status'; import { useKibana } from '../../../common/lib/kibana'; import { SecurityPageName } from '../../../app/types'; import { APP_ID } from '../../../../common/constants'; @@ -83,25 +84,25 @@ const StatefulRecentTimelinesComponent = React.memo( ); const { fetchAllTimeline, timelines, loading } = useGetAllTimeline(); - - useEffect( - () => - fetchAllTimeline({ - pageInfo: { - pageIndex: 1, - pageSize: PAGE_SIZE, - }, - search: '', - sort: { - sortField: SortFieldTimeline.updated, - sortOrder: Direction.desc, - }, - onlyUserFavorite: filterBy === 'favorites', - timelineType: TimelineType.default, - }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [filterBy] - ); + const timelineType = TimelineType.default; + const { templateTimelineType, timelineStatus } = useTimelineStatus({ timelineType }); + useEffect(() => { + fetchAllTimeline({ + pageInfo: { + pageIndex: 1, + pageSize: PAGE_SIZE, + }, + search: '', + sort: { + sortField: SortFieldTimeline.updated, + sortOrder: Direction.desc, + }, + onlyUserFavorite: filterBy === 'favorites', + status: timelineStatus, + timelineType, + templateTimelineType, + }); + }, [fetchAllTimeline, filterBy, timelineStatus, timelineType, templateTimelineType]); return ( <> diff --git a/x-pack/plugins/security_solution/public/plugin.tsx b/x-pack/plugins/security_solution/public/plugin.tsx index b247170a4a5d..d7e29a466cbf 100644 --- a/x-pack/plugins/security_solution/public/plugin.tsx +++ b/x-pack/plugins/security_solution/public/plugin.tsx @@ -23,7 +23,14 @@ import { FeatureCatalogueCategory } from '../../../../src/plugins/home/public'; import { initTelemetry } from './common/lib/telemetry'; import { KibanaServices } from './common/lib/kibana/services'; import { serviceNowActionType, jiraActionType } from './common/lib/connectors'; -import { PluginSetup, PluginStart, SetupPlugins, StartPlugins, StartServices } from './types'; +import { + PluginSetup, + PluginStart, + SetupPlugins, + StartPlugins, + StartServices, + AppObservableLibs, +} from './types'; import { APP_ID, APP_ICON, @@ -120,6 +127,7 @@ export class Plugin implements IPlugin( notesById, status, timelineId, + timelineType, title, toggleLock, updateDescription, @@ -66,6 +67,7 @@ const StatefulFlyoutHeader = React.memo( noteIds={noteIds} status={status} timelineId={timelineId} + timelineType={timelineType} title={title} toggleLock={toggleLock} updateDescription={updateDescription} @@ -100,6 +102,7 @@ const makeMapStateToProps = () => { title = '', noteIds = emptyNotesId, status, + timelineType, } = timeline; const history = emptyHistory; // TODO: get history from store via selector @@ -116,6 +119,7 @@ const makeMapStateToProps = () => { notesById: getNotesByIds(state), status, title, + timelineType, }; }; return mapStateToProps; diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/header_with_close_button/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/flyout/header_with_close_button/__snapshots__/index.test.tsx.snap index df96f2a1f7eb..d0d7a1cd7f5d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/header_with_close_button/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/header_with_close_button/__snapshots__/index.test.tsx.snap @@ -4,6 +4,7 @@ exports[`FlyoutHeaderWithCloseButton renders correctly against snapshot 1`] = ` { }); describe('FlyoutHeaderWithCloseButton', () => { + const props = { + onClose: jest.fn(), + timelineId: 'test', + timelineType: TimelineType.default, + usersViewing: ['elastic'], + }; test('renders correctly against snapshot', () => { const EmptyComponent = shallow( - + ); expect(EmptyComponent.find('FlyoutHeaderWithCloseButton')).toMatchSnapshot(); @@ -55,13 +58,13 @@ describe('FlyoutHeaderWithCloseButton', () => { test('it should invoke onClose when the close button is clicked', () => { const closeMock = jest.fn(); + const testProps = { + ...props, + onClose: closeMock, + }; const wrapper = mount( - + ); wrapper.find('[data-test-subj="close-timeline"] button').first().simulate('click'); diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/index.test.tsx index 932cde32f3d4..50578ef0a8e4 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/index.test.tsx @@ -14,6 +14,7 @@ import { mockGlobalState, TestProviders, SUB_PLUGINS_REDUCER, + kibanaObservable, createSecuritySolutionStorageMock, } from '../../../common/mock'; import { createStore, State } from '../../../common/store'; @@ -62,6 +63,7 @@ describe('Flyout', () => { stateShowIsTrue, SUB_PLUGINS_REDUCER, apolloClientObservable, + kibanaObservable, storage ); @@ -86,6 +88,7 @@ describe('Flyout', () => { stateWithDataProviders, SUB_PLUGINS_REDUCER, apolloClientObservable, + kibanaObservable, storage ); @@ -108,6 +111,7 @@ describe('Flyout', () => { stateWithDataProviders, SUB_PLUGINS_REDUCER, apolloClientObservable, + kibanaObservable, storage ); @@ -142,6 +146,7 @@ describe('Flyout', () => { stateWithDataProviders, SUB_PLUGINS_REDUCER, apolloClientObservable, + kibanaObservable, storage ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/add_note/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/add_note/index.test.tsx index 1ddf298110a5..570c0028e0f5 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/add_note/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/notes/add_note/index.test.tsx @@ -8,52 +8,39 @@ import { mount, shallow } from 'enzyme'; import React from 'react'; import { AddNote } from '.'; +import { TimelineStatus } from '../../../../../common/types/timeline'; describe('AddNote', () => { const note = 'The contents of a new note'; + const props = { + associateNote: jest.fn(), + getNewNoteId: jest.fn(), + newNote: note, + onCancelAddNote: jest.fn(), + updateNewNote: jest.fn(), + updateNote: jest.fn(), + status: TimelineStatus.active, + }; test('renders correctly', () => { - const wrapper = shallow( - - ); + const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); }); test('it renders the Cancel button when onCancelAddNote is provided', () => { - const wrapper = mount( - - ); + const wrapper = mount(); expect(wrapper.find('[data-test-subj="cancel"]').exists()).toEqual(true); }); test('it invokes onCancelAddNote when the Cancel button is clicked', () => { const onCancelAddNote = jest.fn(); + const testProps = { + ...props, + onCancelAddNote, + }; - const wrapper = mount( - - ); + const wrapper = mount(); wrapper.find('[data-test-subj="cancel"]').first().simulate('click'); @@ -62,17 +49,12 @@ describe('AddNote', () => { test('it does NOT invoke associateNote when the Cancel button is clicked', () => { const associateNote = jest.fn(); + const testProps = { + ...props, + associateNote, + }; - const wrapper = mount( - - ); + const wrapper = mount(); wrapper.find('[data-test-subj="cancel"]').first().simulate('click'); @@ -80,47 +62,29 @@ describe('AddNote', () => { }); test('it does NOT render the Cancel button when onCancelAddNote is NOT provided', () => { - const wrapper = mount( - - ); + const testProps = { + ...props, + onCancelAddNote: undefined, + }; + const wrapper = mount(); expect(wrapper.find('[data-test-subj="cancel"]').exists()).toEqual(false); }); test('it renders the contents of the note', () => { - const wrapper = mount( - - ); + const wrapper = mount(); expect(wrapper.find('[data-test-subj="add-a-note"]').first().text()).toEqual(note); }); test('it invokes associateNote when the Add Note button is clicked', () => { const associateNote = jest.fn(); - - const wrapper = mount( - - ); + const testProps = { + ...props, + newNote: note, + associateNote, + }; + const wrapper = mount(); wrapper.find('[data-test-subj="add-note"]').first().simulate('click'); @@ -129,17 +93,12 @@ describe('AddNote', () => { test('it invokes getNewNoteId when the Add Note button is clicked', () => { const getNewNoteId = jest.fn(); + const testProps = { + ...props, + getNewNoteId, + }; - const wrapper = mount( - - ); + const wrapper = mount(); wrapper.find('[data-test-subj="add-note"]').first().simulate('click'); @@ -148,17 +107,12 @@ describe('AddNote', () => { test('it invokes updateNewNote when the Add Note button is clicked', () => { const updateNewNote = jest.fn(); + const testProps = { + ...props, + updateNewNote, + }; - const wrapper = mount( - - ); + const wrapper = mount(); wrapper.find('[data-test-subj="add-note"]').first().simulate('click'); @@ -167,17 +121,11 @@ describe('AddNote', () => { test('it invokes updateNote when the Add Note button is clicked', () => { const updateNote = jest.fn(); - - const wrapper = mount( - - ); + const testProps = { + ...props, + updateNote, + }; + const wrapper = mount(); wrapper.find('[data-test-subj="add-note"]').first().simulate('click'); @@ -185,16 +133,11 @@ describe('AddNote', () => { }); test('it does NOT display the markdown formatting hint when a note has NOT been entered', () => { - const wrapper = mount( - - ); + const testProps = { + ...props, + newNote: '', + }; + const wrapper = mount(); expect(wrapper.find('[data-test-subj="markdown-hint"]').first()).toHaveStyleRule( 'visibility', @@ -203,16 +146,11 @@ describe('AddNote', () => { }); test('it displays the markdown formatting hint when a note has been entered', () => { - const wrapper = mount( - - ); + const testProps = { + ...props, + newNote: 'We should see a formatting hint now', + }; + const wrapper = mount(); expect(wrapper.find('[data-test-subj="markdown-hint"]').first()).toHaveStyleRule( 'visibility', diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/add_note/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/add_note/index.tsx index d3db1a619600..7c211aafdf8c 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/add_note/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/notes/add_note/index.tsx @@ -61,7 +61,6 @@ export const AddNote = React.memo<{ }), [associateNote, getNewNoteId, newNote, updateNewNote, updateNote] ); - return ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/index.tsx index 42f28f034067..957b37a0bd1c 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/notes/index.tsx @@ -21,12 +21,14 @@ import { AddNote } from './add_note'; import { columns } from './columns'; import { AssociateNote, GetNewNoteId, NotesCount, search, UpdateNote } from './helpers'; import { NOTES_PANEL_WIDTH, NOTES_PANEL_HEIGHT } from '../timeline/properties/notes_size'; +import { TimelineStatusLiteral, TimelineStatus } from '../../../../common/types/timeline'; interface Props { associateNote: AssociateNote; getNotesByIds: (noteIds: string[]) => Note[]; getNewNoteId: GetNewNoteId; noteIds: string[]; + status: TimelineStatusLiteral; updateNote: UpdateNote; } @@ -53,8 +55,9 @@ InMemoryTable.displayName = 'InMemoryTable'; /** A view for entering and reviewing notes */ export const Notes = React.memo( - ({ associateNote, getNotesByIds, getNewNoteId, noteIds, updateNote }) => { + ({ associateNote, getNotesByIds, getNewNoteId, noteIds, status, updateNote }) => { const [newNote, setNewNote] = useState(''); + const isImmutable = status === TimelineStatus.immutable; return ( @@ -63,13 +66,15 @@ export const Notes = React.memo( - + {!isImmutable && ( + + )} { const noteIds = ['abc', 'def']; @@ -38,18 +39,21 @@ describe('NoteCards', () => { }, ]; + const props = { + associateNote: jest.fn(), + getNotesByIds, + getNewNoteId: jest.fn(), + noteIds, + showAddNote: true, + status: TimelineStatus.active, + toggleShowAddNote: jest.fn(), + updateNote: jest.fn(), + }; + test('it renders the notes column when noteIds are specified', () => { const wrapper = mountWithIntl( - + ); @@ -57,17 +61,10 @@ describe('NoteCards', () => { }); test('it does NOT render the notes column when noteIds are NOT specified', () => { + const testProps = { ...props, noteIds: [] }; const wrapper = mountWithIntl( - + ); @@ -77,15 +74,7 @@ describe('NoteCards', () => { test('renders note cards', () => { const wrapper = mountWithIntl( - + ); @@ -102,15 +91,7 @@ describe('NoteCards', () => { test('it shows controls for adding notes when showAddNote is true', () => { const wrapper = mountWithIntl( - + ); @@ -118,17 +99,11 @@ describe('NoteCards', () => { }); test('it does NOT show controls for adding notes when showAddNote is false', () => { + const testProps = { ...props, showAddNote: false }; + const wrapper = mountWithIntl( - + ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/notes/note_cards/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/notes/note_cards/index.tsx index 3c8fc50e93b8..9d9055e3ad74 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/notes/note_cards/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/notes/note_cards/index.tsx @@ -12,6 +12,7 @@ import { Note } from '../../../../common/lib/note'; import { AddNote } from '../add_note'; import { AssociateNote, GetNewNoteId, UpdateNote } from '../helpers'; import { NoteCard } from '../note_card'; +import { TimelineStatusLiteral } from '../../../../../common/types/timeline'; const AddNoteContainer = styled.div``; AddNoteContainer.displayName = 'AddNoteContainer'; @@ -49,6 +50,7 @@ interface Props { getNewNoteId: GetNewNoteId; noteIds: string[]; showAddNote: boolean; + status: TimelineStatusLiteral; toggleShowAddNote: () => void; updateNote: UpdateNote; } @@ -61,6 +63,7 @@ export const NoteCards = React.memo( getNewNoteId, noteIds, showAddNote, + status, toggleShowAddNote, updateNote, }) => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/edit_timeline_batch_actions.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/edit_timeline_batch_actions.tsx index 4d45b74e9b1b..15c078e17535 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/edit_timeline_batch_actions.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/edit_timeline_batch_actions.tsx @@ -6,7 +6,9 @@ import { EuiContextMenuPanel, EuiContextMenuItem, EuiBasicTable } from '@elastic/eui'; import React, { useCallback, useMemo } from 'react'; -import { isEmpty } from 'lodash/fp'; + +import { TimelineStatus } from '../../../../common/types/timeline'; + import * as i18n from './translations'; import { DeleteTimelines, OpenTimelineResult } from './types'; import { EditTimelineActions } from './export_timeline'; @@ -63,7 +65,7 @@ export const useEditTimelineBatchActions = ({ const getBatchItemsPopoverContent = useCallback( (closePopover: () => void) => { - const isDisabled = isEmpty(selectedItems); + const disabled = selectedItems?.some((item) => item.status === TimelineStatus.immutable); return ( <> , ); }, + // eslint-disable-next-line react-hooks/exhaustive-deps [ deleteTimelines, isEnableDownloader, diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/export_timeline/export_timeline.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/export_timeline/export_timeline.test.tsx index d377b10a55c2..b8a7cfd59d22 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/export_timeline/export_timeline.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/export_timeline/export_timeline.test.tsx @@ -8,7 +8,6 @@ import React from 'react'; import { TimelineDownloader } from './export_timeline'; import { mockSelectedTimeline } from './mocks'; import { ReactWrapper, mount } from 'enzyme'; -import { useExportTimeline } from '.'; jest.mock('../translations', () => { return { @@ -32,19 +31,6 @@ describe('TimelineDownloader', () => { onComplete: jest.fn(), }; describe('should not render a downloader', () => { - beforeAll(() => { - ((useExportTimeline as unknown) as jest.Mock).mockReturnValue({ - enableDownloader: false, - setEnableDownloader: jest.fn(), - exportedIds: {}, - getExportedData: jest.fn(), - }); - }); - - afterAll(() => { - ((useExportTimeline as unknown) as jest.Mock).mockReset(); - }); - test('Without exportedIds', () => { const testProps = { ...defaultTestProps, @@ -65,19 +51,6 @@ describe('TimelineDownloader', () => { }); describe('should render a downloader', () => { - beforeAll(() => { - ((useExportTimeline as unknown) as jest.Mock).mockReturnValue({ - enableDownloader: false, - setEnableDownloader: jest.fn(), - exportedIds: {}, - getExportedData: jest.fn(), - }); - }); - - afterAll(() => { - ((useExportTimeline as unknown) as jest.Mock).mockReset(); - }); - test('With selectedItems and exportedIds is given and isEnableDownloader is true', () => { const testProps = { ...defaultTestProps, diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/export_timeline/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/export_timeline/index.test.tsx index 674cd6dad5f7..72f149174253 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/export_timeline/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/export_timeline/index.test.tsx @@ -5,31 +5,41 @@ */ import React from 'react'; -import { mount } from 'enzyme'; -import { useExportTimeline, ExportTimeline } from '.'; +import { shallow } from 'enzyme'; +import { EditTimelineActionsComponent } from '.'; -describe('useExportTimeline', () => { - describe('call with selected timelines', () => { - let exportTimelineRes: ExportTimeline; - const TestHook = () => { - exportTimelineRes = useExportTimeline(); - return
; +describe('EditTimelineActionsComponent', () => { + describe('render', () => { + const props = { + deleteTimelines: jest.fn(), + ids: ['id1'], + isEnableDownloader: false, + isDeleteTimelineModalOpen: false, + onComplete: jest.fn(), + title: 'mockTitle', }; - beforeAll(() => { - mount(); + test('should render timelineDownloader', () => { + const wrapper = shallow(); + + expect(wrapper.find('[data-test-subj="TimelineDownloader"]').exists()).toBeTruthy(); }); - test('Downloader should be disabled by default', () => { - expect(exportTimelineRes.isEnableDownloader).toBeFalsy(); + test('Should render DeleteTimelineModalOverlay if deleteTimelines is given', () => { + const wrapper = shallow(); + + expect(wrapper.find('[data-test-subj="DeleteTimelineModalOverlay"]').exists()).toBeTruthy(); }); - test('Should include disableExportTimelineDownloader in return value', () => { - expect(exportTimelineRes).toHaveProperty('disableExportTimelineDownloader'); - }); - - test('Should include enableExportTimelineDownloader in return value', () => { - expect(exportTimelineRes).toHaveProperty('enableExportTimelineDownloader'); + test('Should not render DeleteTimelineModalOverlay if deleteTimelines is not given', () => { + const newProps = { + ...props, + deleteTimelines: undefined, + }; + const wrapper = shallow(); + expect( + wrapper.find('[data-test-subj="DeleteTimelineModalOverlay"]').exists() + ).not.toBeTruthy(); }); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/export_timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/export_timeline/index.tsx index 7bac3229c817..2ad4aa9d208c 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/export_timeline/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/export_timeline/index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useState, useCallback } from 'react'; +import React from 'react'; import { DeleteTimelines } from '../types'; import { TimelineDownloader } from './export_timeline'; @@ -17,25 +17,7 @@ export interface ExportTimeline { isEnableDownloader: boolean; } -export const useExportTimeline = (): ExportTimeline => { - const [isEnableDownloader, setIsEnableDownloader] = useState(false); - - const enableExportTimelineDownloader = useCallback(() => { - setIsEnableDownloader(true); - }, []); - - const disableExportTimelineDownloader = useCallback(() => { - setIsEnableDownloader(false); - }, []); - - return { - disableExportTimelineDownloader, - enableExportTimelineDownloader, - isEnableDownloader, - }; -}; - -const EditTimelineActionsComponent: React.FC<{ +export const EditTimelineActionsComponent: React.FC<{ deleteTimelines: DeleteTimelines | undefined; ids: string[]; isEnableDownloader: boolean; @@ -52,6 +34,7 @@ const EditTimelineActionsComponent: React.FC<{ }) => ( <> {deleteTimelines != null && ( { } }; +const setTimelineColumn = (col: ColumnHeaderResult) => { + const timelineCols: ColumnHeaderOptions = { + ...col, + columnHeaderType: defaultColumnHeaderType, + id: col.id != null ? col.id : 'unknown', + placeholder: col.placeholder != null ? col.placeholder : undefined, + category: col.category != null ? col.category : undefined, + description: col.description != null ? col.description : undefined, + example: col.example != null ? col.example : undefined, + type: col.type != null ? col.type : undefined, + aggregatable: col.aggregatable != null ? col.aggregatable : undefined, + width: col.id === '@timestamp' ? DEFAULT_DATE_COLUMN_MIN_WIDTH : DEFAULT_COLUMN_MIN_WIDTH, + }; + return timelineCols; +}; + +const setTimelineFilters = (filter: FilterTimelineResult) => ({ + $state: { + store: 'appState', + }, + meta: { + ...filter.meta, + ...(filter.meta && filter.meta.field != null ? { params: parseString(filter.meta.field) } : {}), + ...(filter.meta && filter.meta.params != null + ? { params: parseString(filter.meta.params) } + : {}), + ...(filter.meta && filter.meta.value != null ? { value: parseString(filter.meta.value) } : {}), + }, + ...(filter.exists != null ? { exists: parseString(filter.exists) } : {}), + ...(filter.match_all != null ? { exists: parseString(filter.match_all) } : {}), + ...(filter.missing != null ? { exists: parseString(filter.missing) } : {}), + ...(filter.query != null ? { query: parseString(filter.query) } : {}), + ...(filter.range != null ? { range: parseString(filter.range) } : {}), + ...(filter.script != null ? { exists: parseString(filter.script) } : {}), +}); + +const setEventIdToNoteIds = ( + duplicate: boolean, + eventIdToNoteIds: NoteResult[] | null | undefined +) => + duplicate + ? {} + : eventIdToNoteIds != null + ? eventIdToNoteIds.reduce((acc, note) => { + if (note.eventId != null) { + const eventNotes = getOr([], note.eventId, acc); + return { ...acc, [note.eventId]: [...eventNotes, note.noteId] }; + } + return acc; + }, {}) + : {}; + +const setPinnedEventsSaveObject = ( + duplicate: boolean, + pinnedEventsSaveObject: PinnedEvent[] | null | undefined +) => + duplicate + ? {} + : pinnedEventsSaveObject != null + ? pinnedEventsSaveObject.reduce( + (acc, pinnedEvent) => ({ + ...acc, + ...(pinnedEvent.eventId != null ? { [pinnedEvent.eventId]: pinnedEvent } : {}), + }), + {} + ) + : {}; + +const setPinnedEventIds = (duplicate: boolean, pinnedEventIds: string[] | null | undefined) => + duplicate + ? {} + : pinnedEventIds != null + ? pinnedEventIds.reduce((acc, pinnedEventId) => ({ ...acc, [pinnedEventId]: true }), {}) + : {}; + +// eslint-disable-next-line complexity export const defaultTimelineToTimelineModel = ( timeline: TimelineResult, duplicate: boolean ): TimelineModel => { - return Object.entries({ + const isTemplate = timeline.timelineType === TimelineType.template; + const timelineEntries = { ...timeline, - columns: - timeline.columns != null - ? timeline.columns.map((col) => { - const timelineCols: ColumnHeaderOptions = { - ...col, - columnHeaderType: defaultColumnHeaderType, - id: col.id != null ? col.id : 'unknown', - placeholder: col.placeholder != null ? col.placeholder : undefined, - category: col.category != null ? col.category : undefined, - description: col.description != null ? col.description : undefined, - example: col.example != null ? col.example : undefined, - type: col.type != null ? col.type : undefined, - aggregatable: col.aggregatable != null ? col.aggregatable : undefined, - width: - col.id === '@timestamp' ? DEFAULT_DATE_COLUMN_MIN_WIDTH : DEFAULT_COLUMN_MIN_WIDTH, - }; - return timelineCols; - }) - : defaultHeaders, - eventIdToNoteIds: duplicate - ? {} - : timeline.eventIdToNoteIds != null - ? timeline.eventIdToNoteIds.reduce((acc, note) => { - if (note.eventId != null) { - const eventNotes = getOr([], note.eventId, acc); - return { ...acc, [note.eventId]: [...eventNotes, note.noteId] }; - } - return acc; - }, {}) - : {}, - filters: - timeline.filters != null - ? timeline.filters.map((filter) => ({ - $state: { - store: 'appState', - }, - meta: { - ...filter.meta, - ...(filter.meta && filter.meta.field != null - ? { params: parseString(filter.meta.field) } - : {}), - ...(filter.meta && filter.meta.params != null - ? { params: parseString(filter.meta.params) } - : {}), - ...(filter.meta && filter.meta.value != null - ? { value: parseString(filter.meta.value) } - : {}), - }, - ...(filter.exists != null ? { exists: parseString(filter.exists) } : {}), - ...(filter.match_all != null ? { exists: parseString(filter.match_all) } : {}), - ...(filter.missing != null ? { exists: parseString(filter.missing) } : {}), - ...(filter.query != null ? { query: parseString(filter.query) } : {}), - ...(filter.range != null ? { range: parseString(filter.range) } : {}), - ...(filter.script != null ? { exists: parseString(filter.script) } : {}), - })) - : [], + columns: timeline.columns != null ? timeline.columns.map(setTimelineColumn) : defaultHeaders, + eventIdToNoteIds: setEventIdToNoteIds(duplicate, timeline.eventIdToNoteIds), + filters: timeline.filters != null ? timeline.filters.map(setTimelineFilters) : [], isFavorite: duplicate ? false : timeline.favorite != null ? timeline.favorite.length > 0 : false, noteIds: duplicate ? [] : timeline.noteIds != null ? timeline.noteIds : [], - pinnedEventIds: duplicate - ? {} - : timeline.pinnedEventIds != null - ? timeline.pinnedEventIds.reduce( - (acc, pinnedEventId) => ({ ...acc, [pinnedEventId]: true }), - {} - ) - : {}, - pinnedEventsSaveObject: duplicate - ? {} - : timeline.pinnedEventsSaveObject != null - ? timeline.pinnedEventsSaveObject.reduce( - (acc, pinnedEvent) => ({ - ...acc, - ...(pinnedEvent.eventId != null ? { [pinnedEvent.eventId]: pinnedEvent } : {}), - }), - {} - ) - : {}, + pinnedEventIds: setPinnedEventIds(duplicate, timeline.pinnedEventIds), + pinnedEventsSaveObject: setPinnedEventsSaveObject(duplicate, timeline.pinnedEventsSaveObject), id: duplicate ? '' : timeline.savedObjectId, + status: duplicate ? TimelineStatus.active : timeline.status, savedObjectId: duplicate ? null : timeline.savedObjectId, version: duplicate ? null : timeline.version, - title: duplicate ? '' : timeline.title || '', - templateTimelineId: duplicate ? null : timeline.templateTimelineId, - templateTimelineVersion: duplicate ? null : timeline.templateTimelineVersion, - }).reduce((acc: TimelineModel, [key, value]) => (value != null ? set(key, value, acc) : acc), { - ...timelineDefaults, - id: '', - }); + title: duplicate ? `${timeline.title} - Duplicate` : timeline.title || '', + templateTimelineId: duplicate && isTemplate ? uuid.v4() : timeline.templateTimelineId, + templateTimelineVersion: duplicate && isTemplate ? 1 : timeline.templateTimelineVersion, + }; + return Object.entries(timelineEntries).reduce( + (acc: TimelineModel, [key, value]) => (value != null ? set(key, value, acc) : acc), + { + ...timelineDefaults, + id: '', + } + ); }; export const formatTimelineResultToModel = ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.tsx index 24dee1460810..ea63f2b7b071 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.tsx @@ -9,9 +9,9 @@ import React, { useEffect, useState, useCallback } from 'react'; import { connect, ConnectedProps } from 'react-redux'; import { Dispatch } from 'redux'; -import { defaultHeaders } from '../timeline/body/column_headers/default_headers'; -import { deleteTimelineMutation } from '../../containers/delete/persist.gql_query'; -import { useGetAllTimeline } from '../../containers/all'; + +import { disableTemplate } from '../../../../common/constants'; + import { DeleteTimelineMutation, SortFieldTimeline, Direction } from '../../../graphql/types'; import { State } from '../../../common/store'; import { ColumnHeaderOptions, TimelineModel } from '../../../timelines/store/timeline/model'; @@ -21,6 +21,12 @@ import { createTimeline as dispatchCreateNewTimeline, updateIsLoading as dispatchUpdateIsLoading, } from '../../../timelines/store/timeline/actions'; + +import { deleteTimelineMutation } from '../../containers/delete/persist.gql_query'; +import { useGetAllTimeline } from '../../containers/all'; + +import { defaultHeaders } from '../timeline/body/column_headers/default_headers'; + import { OpenTimeline } from './open_timeline'; import { OPEN_TIMELINE_CLASS_NAME, queryTimelineById, dispatchUpdateTimeline } from './helpers'; import { OpenTimelineModalBody } from './open_timeline_modal/open_timeline_modal_body'; @@ -42,7 +48,7 @@ import { } from './types'; import { DEFAULT_SORT_FIELD, DEFAULT_SORT_DIRECTION } from './constants'; import { useTimelineTypes } from './use_timeline_types'; -import { disableTemplate } from '../../../../common/constants'; +import { useTimelineStatus } from './use_timeline_status'; interface OwnProps { apolloClient: ApolloClient; @@ -106,28 +112,54 @@ export const StatefulOpenTimelineComponent = React.memo( /** The requested field to sort on */ const [sortField, setSortField] = useState(DEFAULT_SORT_FIELD); - const { timelineType, timelineTabs, timelineFilters } = useTimelineTypes(); - const { fetchAllTimeline, timelines, loading, totalCount } = useGetAllTimeline(); - - const refetch = useCallback( - () => - fetchAllTimeline({ - pageInfo: { - pageIndex: pageIndex + 1, - pageSize, - }, - search, - sort: { - sortField: sortField as SortFieldTimeline, - sortOrder: sortDirection as Direction, - }, - onlyUserFavorite: onlyFavorites, - timelineType, - }), - - // eslint-disable-next-line react-hooks/exhaustive-deps - [pageIndex, pageSize, search, sortField, sortDirection, timelineType, onlyFavorites] - ); + const { + customTemplateTimelineCount, + defaultTimelineCount, + elasticTemplateTimelineCount, + favoriteCount, + fetchAllTimeline, + timelines, + loading, + totalCount, + templateTimelineCount, + } = useGetAllTimeline(); + const { timelineType, timelineTabs, timelineFilters } = useTimelineTypes({ + defaultTimelineCount, + templateTimelineCount, + }); + const { timelineStatus, templateTimelineType, templateTimelineFilter } = useTimelineStatus({ + timelineType, + customTemplateTimelineCount, + elasticTemplateTimelineCount, + }); + const refetch = useCallback(() => { + fetchAllTimeline({ + pageInfo: { + pageIndex: pageIndex + 1, + pageSize, + }, + search, + sort: { + sortField: sortField as SortFieldTimeline, + sortOrder: sortDirection as Direction, + }, + onlyUserFavorite: onlyFavorites, + timelineType, + templateTimelineType, + status: timelineStatus, + }); + }, [ + fetchAllTimeline, + pageIndex, + pageSize, + search, + sortField, + sortDirection, + timelineType, + timelineStatus, + templateTimelineType, + onlyFavorites, + ]); /** Invoked when the user presses enters to submit the text in the search input */ const onQueryChange: OnQueryChange = useCallback((query: EuiSearchBarQuery) => { @@ -264,6 +296,7 @@ export const StatefulOpenTimelineComponent = React.memo( data-test-subj={'open-timeline'} deleteTimelines={onDeleteOneTimeline} defaultPageSize={defaultPageSize} + favoriteCount={favoriteCount} isLoading={loading} itemIdToExpandedNotesRowMap={itemIdToExpandedNotesRowMap} importDataModalToggle={importDataModalToggle} @@ -285,7 +318,9 @@ export const StatefulOpenTimelineComponent = React.memo( selectedItems={selectedItems} sortDirection={sortDirection} sortField={sortField} - tabs={!disableTemplate ? timelineTabs : undefined} + templateTimelineFilter={!disableTemplate ? templateTimelineFilter : null} + timelineType={timelineType} + timelineFilter={!disableTemplate ? timelineTabs : null} title={title} totalSearchResultsCount={totalCount} /> @@ -294,6 +329,7 @@ export const StatefulOpenTimelineComponent = React.memo( data-test-subj={'open-timeline-modal'} deleteTimelines={onDeleteOneTimeline} defaultPageSize={defaultPageSize} + favoriteCount={favoriteCount} hideActions={hideActions} isLoading={loading} itemIdToExpandedNotesRowMap={itemIdToExpandedNotesRowMap} @@ -312,7 +348,9 @@ export const StatefulOpenTimelineComponent = React.memo( selectedItems={selectedItems} sortDirection={sortDirection} sortField={sortField} - tabs={!disableTemplate ? timelineFilters : undefined} + templateTimelineFilter={!disableTemplate ? templateTimelineFilter : null} + timelineType={timelineType} + timelineFilter={!disableTemplate ? timelineFilters : null} title={title} totalSearchResultsCount={totalCount} /> diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.test.tsx index a331c62ec475..f42914c86f46 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.test.tsx @@ -16,6 +16,7 @@ import { TimelinesTableProps } from './timelines_table'; import { mockTimelineResults } from '../../../common/mock/timeline_results'; import { OpenTimeline } from './open_timeline'; import { DEFAULT_SORT_DIRECTION, DEFAULT_SORT_FIELD } from './constants'; +import { TimelineType } from '../../../../common/types/timeline'; jest.mock('../../../common/lib/kibana'); @@ -46,8 +47,9 @@ describe('OpenTimeline', () => { selectedItems: [], sortDirection: DEFAULT_SORT_DIRECTION, sortField: DEFAULT_SORT_FIELD, - tabs:
, title, + timelineType: TimelineType.default, + templateTimelineFilter: [
], totalSearchResultsCount: mockSearchResults.length, }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.tsx index 4894b1b2577a..849143894efe 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline.tsx @@ -4,17 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiPanel, EuiBasicTable } from '@elastic/eui'; +import { EuiPanel, EuiBasicTable, EuiCallOut, EuiSpacer } from '@elastic/eui'; import React, { useCallback, useMemo, useRef } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; -import { OPEN_TIMELINE_CLASS_NAME } from './helpers'; -import { OpenTimelineProps, OpenTimelineResult, ActionTimelineToShow } from './types'; -import { SearchRow } from './search_row'; -import { TimelinesTable } from './timelines_table'; -import { ImportDataModal } from '../../../common/components/import_data_modal'; -import * as i18n from './translations'; -import { importTimelines } from '../../containers/api'; +import { ImportDataModal } from '../../../common/components/import_data_modal'; import { UtilityBarGroup, UtilityBarText, @@ -22,14 +16,23 @@ import { UtilityBarSection, UtilityBarAction, } from '../../../common/components/utility_bar'; + +import { importTimelines } from '../../containers/api'; + import { useEditTimelineBatchActions } from './edit_timeline_batch_actions'; import { useEditTimelineActions } from './edit_timeline_actions'; import { EditOneTimelineAction } from './export_timeline'; +import { SearchRow } from './search_row'; +import { TimelinesTable } from './timelines_table'; +import * as i18n from './translations'; +import { OPEN_TIMELINE_CLASS_NAME } from './helpers'; +import { OpenTimelineProps, OpenTimelineResult, ActionTimelineToShow } from './types'; export const OpenTimeline = React.memo( ({ deleteTimelines, defaultPageSize, + favoriteCount, isLoading, itemIdToExpandedNotesRowMap, importDataModalToggle, @@ -51,11 +54,12 @@ export const OpenTimeline = React.memo( sortDirection, setImportDataModalToggle, sortField, - tabs, + timelineType, + timelineFilter, + templateTimelineFilter, totalSearchResultsCount, }) => { const tableRef = useRef>(); - const { actionItem, enableExportTimelineDownloader, @@ -124,6 +128,8 @@ export const OpenTimeline = React.memo( [onDeleteSelected, deleteTimelines] ); + const SearchRowContent = useMemo(() => <>{templateTimelineFilter}, [templateTimelineFilter]); + return ( <> ( /> - {!!tabs && tabs} + + + {!!timelineFilter && timelineFilter} + > + {SearchRowContent} + @@ -206,6 +217,7 @@ export const OpenTimeline = React.memo( showExtendedColumns={true} sortDirection={sortDirection} sortField={sortField} + timelineType={timelineType} tableRef={tableRef} totalSearchResultsCount={totalSearchResultsCount} /> diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_body.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_body.test.tsx index 42a3f9a44d4b..1d08f0296ce0 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_body.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/open_timeline_modal/open_timeline_modal_body.test.tsx @@ -16,6 +16,7 @@ import { TimelinesTableProps } from '../timelines_table'; import { mockTimelineResults } from '../../../../common/mock/timeline_results'; import { OpenTimelineModalBody } from './open_timeline_modal_body'; import { DEFAULT_SORT_DIRECTION, DEFAULT_SORT_FIELD } from '../constants'; +import { TimelineType } from '../../../../../common/types/timeline'; jest.mock('../../../../common/lib/kibana'); @@ -45,7 +46,8 @@ describe('OpenTimelineModal', () => { selectedItems: [], sortDirection: DEFAULT_SORT_DIRECTION, sortField: DEFAULT_SORT_FIELD, - tabs:
, + timelineType: TimelineType.default, + templateTimelineFilter: [
], title, totalSearchResultsCount: mockSearchResults.length, }); 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 9eab64d6fcf5..bf66d9a52ff2 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 @@ -23,6 +23,7 @@ export const OpenTimelineModalBody = memo( ({ deleteTimelines, defaultPageSize, + favoriteCount, hideActions = [], isLoading, itemIdToExpandedNotesRowMap, @@ -42,7 +43,9 @@ export const OpenTimelineModalBody = memo( selectedItems, sortDirection, sortField, - tabs, + timelineFilter, + timelineType, + templateTimelineFilter, title, totalSearchResultsCount, }) => { @@ -54,6 +57,16 @@ export const OpenTimelineModalBody = memo( return actions.filter((action) => !hideActions.includes(action)); }, [onDeleteSelected, deleteTimelines, hideActions]); + const SearchRowContent = useMemo( + () => ( + <> + {!!timelineFilter && timelineFilter} + {!!templateTimelineFilter && templateTimelineFilter} + + ), + [timelineFilter, templateTimelineFilter] + ); + return ( <> @@ -67,13 +80,15 @@ export const OpenTimelineModalBody = memo( <> + > + {SearchRowContent} + @@ -96,6 +111,7 @@ export const OpenTimelineModalBody = memo( showExtendedColumns={false} sortDirection={sortDirection} sortField={sortField} + timelineType={timelineType} totalSearchResultsCount={totalSearchResultsCount} /> diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/search_row/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/search_row/index.tsx index 557649aa3aa4..6f9178664ccf 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/search_row/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/search_row/index.tsx @@ -34,8 +34,13 @@ SearchRowFlexGroup.displayName = 'SearchRowFlexGroup'; type Props = Pick< OpenTimelineProps, - 'onlyFavorites' | 'onQueryChange' | 'onToggleOnlyFavorites' | 'query' | 'totalSearchResultsCount' -> & { tabs?: JSX.Element }; + | 'favoriteCount' + | 'onlyFavorites' + | 'onQueryChange' + | 'onToggleOnlyFavorites' + | 'query' + | 'totalSearchResultsCount' +> & { children?: JSX.Element | null }; const searchBox = { placeholder: i18n.SEARCH_PLACEHOLDER, @@ -47,12 +52,13 @@ const searchBox = { */ export const SearchRow = React.memo( ({ + favoriteCount, onlyFavorites, onQueryChange, onToggleOnlyFavorites, query, totalSearchResultsCount, - tabs, + children, }) => { return ( @@ -68,10 +74,11 @@ export const SearchRow = React.memo( data-test-subj="only-favorites-toggle" hasActiveFilters={onlyFavorites} onClick={onToggleOnlyFavorites} + numFilters={favoriteCount ?? undefined} > {i18n.ONLY_FAVORITES} - {tabs} + {!!children && children} diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/actions_columns.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/actions_columns.tsx index c92e241c0fe7..5b8eb8fd0365 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/actions_columns.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/actions_columns.tsx @@ -16,6 +16,7 @@ import { TimelineActionsOverflowColumns, } from '../types'; import * as i18n from '../translations'; +import { TimelineStatus } from '../../../../../common/types/timeline'; /** * Returns the action columns (e.g. delete, open duplicate timeline) @@ -54,7 +55,9 @@ export const getActionsColumns = ({ onClick: (selectedTimeline: OpenTimelineResult) => { if (enableExportTimelineDownloader != null) enableExportTimelineDownloader(selectedTimeline); }, - enabled: ({ savedObjectId }: OpenTimelineResult) => savedObjectId != null, + enabled: (timeline: OpenTimelineResult) => { + return timeline.savedObjectId != null && timeline.status !== TimelineStatus.immutable; + }, description: i18n.EXPORT_SELECTED, 'data-test-subj': 'export-timeline', }; @@ -65,7 +68,8 @@ export const getActionsColumns = ({ onClick: (selectedTimeline: OpenTimelineResult) => { if (onOpenDeleteTimelineModal != null) onOpenDeleteTimelineModal(selectedTimeline); }, - enabled: ({ savedObjectId }: OpenTimelineResult) => savedObjectId != null, + enabled: ({ savedObjectId, status }: OpenTimelineResult) => + savedObjectId != null && status !== TimelineStatus.immutable, description: i18n.DELETE_SELECTED, 'data-test-subj': 'delete-timeline', }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/icon_header_columns.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/icon_header_columns.tsx index 5b0f3ded7d71..e07c6b6b4614 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/icon_header_columns.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/icon_header_columns.tsx @@ -13,55 +13,68 @@ import { ACTION_COLUMN_WIDTH } from './common_styles'; import { getNotesCount, getPinnedEventCount } from '../helpers'; import * as i18n from '../translations'; import { FavoriteTimelineResult, OpenTimelineResult } from '../types'; +import { TimelineTypeLiteralWithNull, TimelineType } from '../../../../../common/types/timeline'; /** * Returns the columns that have icon headers */ -export const getIconHeaderColumns = () => [ - { - align: 'center', - field: 'pinnedEventIds', - name: ( - - - - ), - render: (_: Record | null | undefined, timelineResult: OpenTimelineResult) => ( - {`${getPinnedEventCount(timelineResult)}`} - ), - sortable: false, - width: ACTION_COLUMN_WIDTH, - }, - { - align: 'center', - field: 'eventIdToNoteIds', - name: ( - - - - ), - render: ( - _: Record | null | undefined, - timelineResult: OpenTimelineResult - ) => {getNotesCount(timelineResult)}, - sortable: false, - width: ACTION_COLUMN_WIDTH, - }, - { - align: 'center', - field: 'favorite', - name: ( - - - - ), - render: (favorite: FavoriteTimelineResult[] | null | undefined) => { - const isFavorite = favorite != null && favorite.length > 0; - const fill = isFavorite ? 'starFilled' : 'starEmpty'; - - return ; +export const getIconHeaderColumns = ({ + timelineType, +}: { + timelineType: TimelineTypeLiteralWithNull; +}) => { + const columns = { + note: { + align: 'center', + field: 'eventIdToNoteIds', + name: ( + + + + ), + render: ( + _: Record | null | undefined, + timelineResult: OpenTimelineResult + ) => {getNotesCount(timelineResult)}, + sortable: false, + width: ACTION_COLUMN_WIDTH, }, - sortable: false, - width: ACTION_COLUMN_WIDTH, - }, -]; + pinnedEvent: { + align: 'center', + field: 'pinnedEventIds', + name: ( + + + + ), + render: ( + _: Record | null | undefined, + timelineResult: OpenTimelineResult + ) => ( + {`${getPinnedEventCount(timelineResult)}`} + ), + sortable: false, + width: ACTION_COLUMN_WIDTH, + }, + favorite: { + align: 'center', + field: 'favorite', + name: ( + + + + ), + render: (favorite: FavoriteTimelineResult[] | null | undefined) => { + const isFavorite = favorite != null && favorite.length > 0; + const fill = isFavorite ? 'starFilled' : 'starEmpty'; + + return ; + }, + sortable: false, + width: ACTION_COLUMN_WIDTH, + }, + }; + const templateColumns = [columns.note, columns.favorite]; + const defaultColumns = [columns.pinnedEvent, columns.note, columns.favorite]; + return timelineType === TimelineType.template ? templateColumns : defaultColumns; +}; diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/index.tsx index 7091ef1f0a1f..fdba3247afb3 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/index.tsx @@ -24,6 +24,7 @@ import { getActionsColumns } from './actions_columns'; import { getCommonColumns } from './common_columns'; import { getExtendedColumns } from './extended_columns'; import { getIconHeaderColumns } from './icon_header_columns'; +import { TimelineTypeLiteralWithNull } from '../../../../../common/types/timeline'; // there are a number of type mismatches across this file const EuiBasicTable: any = _EuiBasicTable; // eslint-disable-line @typescript-eslint/no-explicit-any @@ -58,6 +59,7 @@ export const getTimelinesTableColumns = ({ onOpenTimeline, onToggleShowNotes, showExtendedColumns, + timelineType, }: { actionTimelineToShow: ActionTimelineToShow[]; deleteTimelines?: DeleteTimelines; @@ -68,6 +70,7 @@ export const getTimelinesTableColumns = ({ onSelectionChange: OnSelectionChange; onToggleShowNotes: OnToggleShowNotes; showExtendedColumns: boolean; + timelineType: TimelineTypeLiteralWithNull; }) => { return [ ...getCommonColumns({ @@ -76,7 +79,7 @@ export const getTimelinesTableColumns = ({ onToggleShowNotes, }), ...getExtendedColumnsIfEnabled(showExtendedColumns), - ...getIconHeaderColumns(), + ...getIconHeaderColumns({ timelineType }), ...getActionsColumns({ actionTimelineToShow, deleteTimelines, @@ -105,6 +108,7 @@ export interface TimelinesTableProps { showExtendedColumns: boolean; sortDirection: 'asc' | 'desc'; sortField: string; + timelineType: TimelineTypeLiteralWithNull; // eslint-disable-next-line @typescript-eslint/no-explicit-any tableRef?: React.MutableRefObject<_EuiBasicTable | undefined>; totalSearchResultsCount: number; @@ -134,6 +138,7 @@ export const TimelinesTable = React.memo( sortField, sortDirection, tableRef, + timelineType, totalSearchResultsCount, }) => { const pagination = { @@ -174,6 +179,7 @@ export const TimelinesTable = React.memo( onSelectionChange, onToggleShowNotes, showExtendedColumns, + timelineType, })} compressed data-test-subj="timelines-table" diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/mocks.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/mocks.ts index 78ca898cc407..0770f460794a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/mocks.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/timelines_table/mocks.ts @@ -7,6 +7,7 @@ import { DEFAULT_SEARCH_RESULTS_PER_PAGE } from '../../../pages/timelines_page'; import { DEFAULT_SORT_DIRECTION, DEFAULT_SORT_FIELD } from '../constants'; import { OpenTimelineResult } from '../types'; import { TimelinesTableProps } from '.'; +import { TimelineType } from '../../../../../common/types/timeline'; export const getMockTimelinesTableProps = ( mockOpenTimelineResults: OpenTimelineResult[] @@ -28,5 +29,6 @@ export const getMockTimelinesTableProps = ( showExtendedColumns: true, sortDirection: DEFAULT_SORT_DIRECTION, sortField: DEFAULT_SORT_FIELD, + timelineType: TimelineType.default, totalSearchResultsCount: mockOpenTimelineResults.length, }); 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 edd77330f508..7b07548af67a 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 @@ -220,6 +220,20 @@ export const TAB_TEMPLATES = i18n.translate( } ); +export const FILTER_ELASTIC_TIMELINES = i18n.translate( + 'xpack.securitySolution.timelines.components.templateFilter.elasticTitle', + { + defaultMessage: 'Elastic templates', + } +); + +export const FILTER_CUSTOM_TIMELINES = i18n.translate( + 'xpack.securitySolution.timelines.components.templateFilter.customizedTitle', + { + defaultMessage: 'Custom templates', + } +); + export const IMPORT_TIMELINE_BTN_TITLE = i18n.translate( 'xpack.securitySolution.timelines.components.importTimelineModal.importTimelineTitle', { @@ -230,7 +244,7 @@ export const IMPORT_TIMELINE_BTN_TITLE = i18n.translate( export const SELECT_TIMELINE = i18n.translate( 'xpack.securitySolution.timelines.components.importTimelineModal.selectTimelineDescription', { - defaultMessage: 'Select a SIEM timeline (as exported from the Timeline view) to import', + defaultMessage: 'Select a Security timeline (as exported from the Timeline view) to import', } ); @@ -280,3 +294,10 @@ export const IMPORT_FAILED_DETAILED = (id: string, statusCode: number, message: defaultMessage: 'Timeline ID: {id}\n Status Code: {statusCode}\n Message: {message}', } ); + +export const TEMPLATE_CALL_OUT_MESSAGE = i18n.translate( + 'xpack.securitySolution.timelines.components.templateCallOutMessageTitle', + { + defaultMessage: 'Now you can add timeline templates and link it to rules.', + } +); 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 e1515a3a7925..8811d5452e03 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 @@ -8,7 +8,12 @@ import { SetStateAction, Dispatch } from 'react'; import { AllTimelinesVariables } from '../../containers/all'; import { TimelineModel } from '../../store/timeline/model'; import { NoteResult } from '../../../graphql/types'; -import { TimelineTypeLiteral } from '../../../../common/types/timeline'; +import { + TimelineTypeLiteral, + TimelineTypeLiteralWithNull, + TimelineStatus, + TemplateTimelineTypeLiteral, +} from '../../../../common/types/timeline'; /** The users who added a timeline to favorites */ export interface FavoriteTimelineResult { @@ -46,6 +51,7 @@ export interface OpenTimelineResult { notes?: TimelineResultNote[] | null; pinnedEventIds?: Readonly> | null; savedObjectId?: string | null; + status?: TimelineStatus | null; title?: string | null; templateTimelineId?: string | null; type?: TimelineTypeLiteral; @@ -118,6 +124,8 @@ export interface OpenTimelineProps { deleteTimelines?: DeleteTimelines; /** The default requested size of each page of search results */ defaultPageSize: number; + /** The number of favorite timeline*/ + favoriteCount?: number | null | undefined; /** Displays an indicator that data is loading when true */ isLoading: boolean; /** Required by EuiTable for expandable rows: a map of `TimelineResult.savedObjectId` to rendered notes */ @@ -160,8 +168,12 @@ export interface OpenTimelineProps { sortDirection: 'asc' | 'desc'; /** the requested field to sort on */ sortField: string; + /** this affects timeline's behaviour like editable / duplicatible */ + timelineType: TimelineTypeLiteralWithNull; + /** when timelineType === template, templatetimelineFilter is a JSX.Element */ + templateTimelineFilter: JSX.Element[] | null; /** timeline / template timeline */ - tabs?: JSX.Element; + timelineFilter?: JSX.Element | JSX.Element[] | null; /** The title of the Open Timeline component */ title: string; /** The total (server-side) count of the search results */ @@ -196,9 +208,19 @@ export enum TimelineTabsStyle { } export interface TimelineTab { - id: TimelineTypeLiteral; - name: string; + count: number | undefined; disabled: boolean; href: string; + id: TimelineTypeLiteral; + name: string; onClick: (ev: { preventDefault: () => void }) => void; + withNext: boolean; +} + +export interface TemplateTimelineFilter { + id: TemplateTimelineTypeLiteral; + name: string; + disabled: boolean; + withNext: boolean; + count: number | undefined; } diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/use_timeline_status.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/use_timeline_status.tsx new file mode 100644 index 000000000000..f17f6aebaddf --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/use_timeline_status.tsx @@ -0,0 +1,110 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useState, useCallback, useMemo } from 'react'; +import { EuiFilterButton } from '@elastic/eui'; + +import { + TimelineStatus, + TimelineType, + TimelineTypeLiteralWithNull, + TemplateTimelineType, + TemplateTimelineTypeLiteralWithNull, + TimelineStatusLiteralWithNull, +} from '../../../../common/types/timeline'; + +import * as i18n from './translations'; +import { TemplateTimelineFilter } from './types'; +import { disableTemplate } from '../../../../common/constants'; + +export const useTimelineStatus = ({ + timelineType, + elasticTemplateTimelineCount, + customTemplateTimelineCount, +}: { + timelineType: TimelineTypeLiteralWithNull; + elasticTemplateTimelineCount?: number | null; + customTemplateTimelineCount?: number | null; +}): { + timelineStatus: TimelineStatusLiteralWithNull; + templateTimelineType: TemplateTimelineTypeLiteralWithNull; + templateTimelineFilter: JSX.Element[] | null; +} => { + const [selectedTab, setSelectedTab] = useState( + disableTemplate ? null : TemplateTimelineType.elastic + ); + const isTemplateFilterEnabled = useMemo(() => timelineType === TimelineType.template, [ + timelineType, + ]); + + const templateTimelineType = useMemo( + () => (disableTemplate || !isTemplateFilterEnabled ? null : selectedTab), + [selectedTab, isTemplateFilterEnabled] + ); + + const timelineStatus = useMemo( + () => + templateTimelineType == null + ? null + : templateTimelineType === TemplateTimelineType.elastic + ? TimelineStatus.immutable + : TimelineStatus.active, + [templateTimelineType] + ); + + const filters = useMemo( + () => [ + { + id: TemplateTimelineType.elastic, + name: i18n.FILTER_ELASTIC_TIMELINES, + disabled: !isTemplateFilterEnabled, + withNext: true, + count: elasticTemplateTimelineCount ?? undefined, + }, + { + id: TemplateTimelineType.custom, + name: i18n.FILTER_CUSTOM_TIMELINES, + disabled: !isTemplateFilterEnabled, + withNext: false, + count: customTemplateTimelineCount ?? undefined, + }, + ], + [customTemplateTimelineCount, elasticTemplateTimelineCount, isTemplateFilterEnabled] + ); + + const onFilterClicked = useCallback( + (tabId) => { + if (selectedTab === tabId) { + setSelectedTab(null); + } else { + setSelectedTab(tabId); + } + }, + [setSelectedTab, selectedTab] + ); + + const templateTimelineFilter = useMemo(() => { + return isTemplateFilterEnabled + ? filters.map((tab: TemplateTimelineFilter) => ( + + {tab.name} + + )) + : null; + }, [templateTimelineType, filters, isTemplateFilterEnabled, onFilterClicked]); + + return { + timelineStatus, + templateTimelineType, + templateTimelineFilter, + }; +}; 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 56c67b0c294a..bee94db34887 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 @@ -13,10 +13,16 @@ import { getTimelineTabsUrl, useFormatUrl } from '../../../common/components/lin import * as i18n from './translations'; import { TimelineTabsStyle, TimelineTab } from './types'; -export const useTimelineTypes = (): { +export const useTimelineTypes = ({ + defaultTimelineCount, + templateTimelineCount, +}: { + defaultTimelineCount?: number | null; + templateTimelineCount?: number | null; +}): { timelineType: TimelineTypeLiteralWithNull; timelineTabs: JSX.Element; - timelineFilters: JSX.Element; + timelineFilters: JSX.Element[]; } => { const history = useHistory(); const { formatUrl, search: urlSearch } = useFormatUrl(SecurityPageName.timelines); @@ -40,35 +46,52 @@ export const useTimelineTypes = (): { }, [history, urlSearch] ); - - const getFilterOrTabs: (timelineTabsStyle: TimelineTabsStyle) => TimelineTab[] = ( - timelineTabsStyle: TimelineTabsStyle - ) => [ - { - id: TimelineType.default, - name: - timelineTabsStyle === TimelineTabsStyle.filter - ? i18n.FILTER_TIMELINES(i18n.TAB_TIMELINES) - : i18n.TAB_TIMELINES, - href: formatUrl(getTimelineTabsUrl(TimelineType.default, urlSearch)), - disabled: false, - onClick: goToTimeline, - }, - { - id: TimelineType.template, - name: - timelineTabsStyle === TimelineTabsStyle.filter - ? i18n.FILTER_TIMELINES(i18n.TAB_TEMPLATES) - : i18n.TAB_TEMPLATES, - href: formatUrl(getTimelineTabsUrl(TimelineType.template, urlSearch)), - disabled: false, - onClick: goToTemplateTimeline, - }, - ]; + const getFilterOrTabs: (timelineTabsStyle: TimelineTabsStyle) => TimelineTab[] = useCallback( + (timelineTabsStyle: TimelineTabsStyle) => [ + { + id: TimelineType.default, + name: + timelineTabsStyle === TimelineTabsStyle.filter + ? i18n.FILTER_TIMELINES(i18n.TAB_TIMELINES) + : i18n.TAB_TIMELINES, + href: formatUrl(getTimelineTabsUrl(TimelineType.default, urlSearch)), + disabled: false, + withNext: true, + count: + timelineTabsStyle === TimelineTabsStyle.filter + ? defaultTimelineCount ?? undefined + : undefined, + onClick: goToTimeline, + }, + { + id: TimelineType.template, + name: + timelineTabsStyle === TimelineTabsStyle.filter + ? i18n.FILTER_TIMELINES(i18n.TAB_TEMPLATES) + : i18n.TAB_TEMPLATES, + href: formatUrl(getTimelineTabsUrl(TimelineType.template, urlSearch)), + disabled: false, + withNext: false, + count: + timelineTabsStyle === TimelineTabsStyle.filter + ? templateTimelineCount ?? undefined + : undefined, + onClick: goToTemplateTimeline, + }, + ], + [ + defaultTimelineCount, + templateTimelineCount, + urlSearch, + formatUrl, + goToTimeline, + goToTemplateTimeline, + ] + ); const onFilterClicked = useCallback( - (timelineTabsStyle, tabId) => { - if (timelineTabsStyle === TimelineTabsStyle.filter && tabId === timelineType) { + (tabId) => { + if (tabId === timelineType) { setTimelineTypes(null); } else { setTimelineTypes(tabId); @@ -89,7 +112,7 @@ export const useTimelineTypes = (): { href={tab.href} onClick={(ev) => { tab.onClick(ev); - onFilterClicked(TimelineTabsStyle.tab, tab.id); + onFilterClicked(tab.id); }} > {tab.name} @@ -103,24 +126,21 @@ export const useTimelineTypes = (): { }, [tabName]); const timelineFilters = useMemo(() => { - return ( - <> - {getFilterOrTabs(TimelineTabsStyle.tab).map((tab: TimelineTab) => ( - void }) => { - tab.onClick(ev); - onFilterClicked.bind(null, TimelineTabsStyle.filter, tab.id); - }} - > - {tab.name} - - ))} - - ); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [timelineType]); + return getFilterOrTabs(TimelineTabsStyle.filter).map((tab: TimelineTab) => ( + void }) => { + tab.onClick(ev); + onFilterClicked(tab.id); + }} + withNext={tab.withNext} + > + {tab.name} + + )); + }, [timelineType, getFilterOrTabs, onFilterClicked]); return { timelineType, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/__snapshots__/timeline.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/__snapshots__/timeline.test.tsx.snap index 927822527193..012cfd66317d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/__snapshots__/timeline.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/__snapshots__/timeline.test.tsx.snap @@ -926,6 +926,7 @@ In other use cases the message field can be used to concatenate different values } } start={1521830963132} + status="active" toggleColumn={[MockFunction]} usersViewing={ Array [ diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.test.tsx index a50e7e56661f..53b018fb00ad 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.test.tsx @@ -5,13 +5,24 @@ */ import { mount } from 'enzyme'; import React from 'react'; +import { useSelector } from 'react-redux'; -import { TestProviders } from '../../../../../common/mock'; +import { TestProviders, mockTimelineModel } from '../../../../../common/mock'; import { DEFAULT_ACTIONS_COLUMN_WIDTH } from '../constants'; import { Actions } from '.'; +jest.mock('react-redux', () => { + const origin = jest.requireActual('react-redux'); + return { + ...origin, + useSelector: jest.fn(), + }; +}); + describe('Actions', () => { + (useSelector as jest.Mock).mockReturnValue(mockTimelineModel); + test('it renders a checkbox for selecting the event when `showCheckboxes` is `true`', () => { const wrapper = mount( diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx index b478070b3157..d343c3db04da 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx @@ -3,10 +3,15 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { EuiButtonIcon, EuiCheckbox, EuiLoadingSpinner, EuiToolTip } from '@elastic/eui'; import React from 'react'; +import { useSelector } from 'react-redux'; +import { EuiButtonIcon, EuiCheckbox, EuiLoadingSpinner, EuiToolTip } from '@elastic/eui'; import { Note } from '../../../../../common/lib/note'; +import { StoreState } from '../../../../../common/store/types'; + +import { TimelineModel } from '../../../../store/timeline/model'; + import { AssociateNote, UpdateNote } from '../../../notes/helpers'; import { Pin } from '../../pin'; import { NotesButton } from '../../properties/helpers'; @@ -79,92 +84,101 @@ export const Actions = React.memo( showNotes, toggleShowNotes, updateNote, - }) => ( - - {showCheckboxes && ( - + }) => { + const timeline = useSelector((state) => { + return state.timeline.timelineById['timeline-1']; + }); + return ( + + {showCheckboxes && ( + + + {loadingEventIds.includes(eventId) ? ( + + ) : ( + ) => { + onRowSelected({ + eventIds: [eventId], + isSelected: event.currentTarget.checked, + }); + }} + /> + )} + + + )} + + - {loadingEventIds.includes(eventId) ? ( - - ) : ( - } + + {!loading && ( + ) => { - onRowSelected({ - eventIds: [eventId], - isSelected: event.currentTarget.checked, - }); - }} + onClick={onEventToggled} /> )} - )} - - - {loading && } + <>{additionalActions} - {!loading && ( - - )} - - + {!isEventViewer && ( + <> + + + + + + + - <>{additionalActions} - - {!isEventViewer && ( - <> - - - - + + - - - - - - - - - - - )} - - ), + + + + )} + + ); + }, (nextProps, prevProps) => { return ( prevProps.actionsColumnWidth === nextProps.actionsColumnWidth && diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx index cf76cd3ddb8d..d2175c728aa2 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/events/stateful_event.tsx @@ -5,6 +5,7 @@ */ import React, { useEffect, useRef, useState, useCallback } from 'react'; +import { useSelector } from 'react-redux'; import uuid from 'uuid'; import VisibilitySensor from 'react-visibility-sensor'; @@ -13,7 +14,7 @@ import { TimelineDetailsQuery } from '../../../../containers/details'; import { TimelineItem, DetailItem, TimelineNonEcsData } from '../../../../../graphql/types'; import { requestIdleCallbackViaScheduler } from '../../../../../common/lib/helpers/scheduler'; import { Note } from '../../../../../common/lib/note'; -import { ColumnHeaderOptions } from '../../../../../timelines/store/timeline/model'; +import { ColumnHeaderOptions, TimelineModel } from '../../../../../timelines/store/timeline/model'; import { AddNoteToEvent, UpdateNote } from '../../../notes/helpers'; import { SkeletonRow } from '../../skeleton_row'; import { @@ -33,6 +34,7 @@ import { getEventType } from '../helpers'; import { NoteCards } from '../../../notes/note_cards'; import { useEventDetailsWidthContext } from '../../../../../common/components/events_viewer/event_details_width_context'; import { EventColumnView } from './event_column_view'; +import { StoreState } from '../../../../../common/store'; interface Props { actionsColumnWidth: number; @@ -128,7 +130,9 @@ const StatefulEventComponent: React.FC = ({ const [expanded, setExpanded] = useState<{ [eventId: string]: boolean }>({}); const [initialRender, setInitialRender] = useState(false); const [showNotes, setShowNotes] = useState<{ [eventId: string]: boolean }>({}); - + const timeline = useSelector((state) => { + return state.timeline.timelineById['timeline-1']; + }); const divElement = useRef(null); const onToggleShowNotes = useCallback(() => { @@ -251,6 +255,7 @@ const StatefulEventComponent: React.FC = ({ getNotesByIds={getNotesByIds} noteIds={eventIdToNoteIds[event._id] || emptyNotes} showAddNote={!!showNotes[event._id]} + status={timeline.status} toggleShowAddNote={onToggleShowNotes} updateNote={updateNote} /> diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.test.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.test.ts index e237e99df9ad..7ecd7ec5ed35 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.test.ts @@ -7,6 +7,7 @@ import { Ecs } from '../../../../graphql/types'; import { eventHasNotes, eventIsPinned, getPinTooltip, stringifyEvent } from './helpers'; +import { TimelineType } from '../../../../../common/types/timeline'; describe('helpers', () => { describe('stringifyEvent', () => { @@ -192,21 +193,37 @@ describe('helpers', () => { describe('getPinTooltip', () => { test('it indicates the event may NOT be unpinned when `isPinned` is `true` and the event has notes', () => { - expect(getPinTooltip({ isPinned: true, eventHasNotes: true })).toEqual( - 'This event cannot be unpinned because it has notes' - ); + expect( + getPinTooltip({ isPinned: true, eventHasNotes: true, timelineType: TimelineType.default }) + ).toEqual('This event cannot be unpinned because it has notes'); }); test('it indicates the event is pinned when `isPinned` is `true` and the event does NOT have notes', () => { - expect(getPinTooltip({ isPinned: true, eventHasNotes: false })).toEqual('Pinned event'); + expect( + getPinTooltip({ isPinned: true, eventHasNotes: false, timelineType: TimelineType.default }) + ).toEqual('Pinned event'); }); test('it indicates the event is NOT pinned when `isPinned` is `false` and the event has notes', () => { - expect(getPinTooltip({ isPinned: false, eventHasNotes: true })).toEqual('Unpinned event'); + expect( + getPinTooltip({ isPinned: false, eventHasNotes: true, timelineType: TimelineType.default }) + ).toEqual('Unpinned event'); }); test('it indicates the event is NOT pinned when `isPinned` is `false` and the event does NOT have notes', () => { - expect(getPinTooltip({ isPinned: false, eventHasNotes: false })).toEqual('Unpinned event'); + expect( + getPinTooltip({ isPinned: false, eventHasNotes: false, timelineType: TimelineType.default }) + ).toEqual('Unpinned event'); + }); + + test('it indicates the event is disabled if timelineType is template', () => { + expect( + getPinTooltip({ + isPinned: false, + eventHasNotes: false, + timelineType: TimelineType.template, + }) + ).toEqual('This event cannot be pinned because it is filtered by a timeline template'); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.ts index bdc8c66ec3aa..52bbccbba58e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.ts @@ -15,6 +15,7 @@ import { OnPinEvent, OnUnPinEvent } from '../events'; import { TimelineRowAction, TimelineRowActionOnClick } from './actions'; import * as i18n from './translations'; +import { TimelineTypeLiteral, TimelineType } from '../../../../../common/types/timeline'; // eslint-disable-next-line @typescript-eslint/no-explicit-any export const omitTypenameAndEmpty = (k: string, v: any): any | undefined => @@ -28,10 +29,19 @@ export const getPinTooltip = ({ isPinned, // eslint-disable-next-line no-shadow eventHasNotes, + timelineType, }: { isPinned: boolean; eventHasNotes: boolean; -}) => (isPinned && eventHasNotes ? i18n.PINNED_WITH_NOTES : isPinned ? i18n.PINNED : i18n.UNPINNED); + timelineType: TimelineTypeLiteral; +}) => + timelineType === TimelineType.template + ? i18n.DISABLE_PIN + : isPinned && eventHasNotes + ? i18n.PINNED_WITH_NOTES + : isPinned + ? i18n.PINNED + : i18n.UNPINNED; export interface IsPinnedParams { eventId: string; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx index 9b96e0c49c73..51bf883ed2d6 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/index.test.tsx @@ -5,10 +5,11 @@ */ import React from 'react'; +import { useSelector } from 'react-redux'; import { mockBrowserFields } from '../../../../common/containers/source/mock'; import { Direction } from '../../../../graphql/types'; -import { defaultHeaders, mockTimelineData } from '../../../../common/mock'; +import { defaultHeaders, mockTimelineData, mockTimelineModel } from '../../../../common/mock'; import { TestProviders } from '../../../../common/mock/test_providers'; import { Body, BodyProps } from '.'; @@ -24,6 +25,13 @@ const mockSort: Sort = { sortDirection: Direction.desc, }; +jest.mock('react-redux', () => { + const origin = jest.requireActual('react-redux'); + return { + ...origin, + useSelector: jest.fn(), + }; +}); jest.mock('../../../../common/components/link_to'); jest.mock( @@ -41,41 +49,43 @@ jest.mock('../../../../common/lib/helpers/scheduler', () => ({ describe('Body', () => { const mount = useMountAppended(); + const props: BodyProps = { + addNoteToEvent: jest.fn(), + browserFields: mockBrowserFields, + columnHeaders: defaultHeaders, + columnRenderers, + data: mockTimelineData, + eventIdToNoteIds: {}, + height: testBodyHeight, + id: 'timeline-test', + isSelectAllChecked: false, + getNotesByIds: mockGetNotesByIds, + loadingEventIds: [], + onColumnRemoved: jest.fn(), + onColumnResized: jest.fn(), + onColumnSorted: jest.fn(), + onFilterChange: jest.fn(), + onPinEvent: jest.fn(), + onRowSelected: jest.fn(), + onSelectAll: jest.fn(), + onUnPinEvent: jest.fn(), + onUpdateColumns: jest.fn(), + pinnedEventIds: {}, + rowRenderers, + selectedEventIds: {}, + show: true, + sort: mockSort, + showCheckboxes: false, + toggleColumn: jest.fn(), + updateNote: jest.fn(), + }; + (useSelector as jest.Mock).mockReturnValue(mockTimelineModel); describe('rendering', () => { test('it renders the column headers', () => { const wrapper = mount( - + ); @@ -85,36 +95,7 @@ describe('Body', () => { test('it renders the scroll container', () => { const wrapper = mount( - + ); @@ -124,36 +105,7 @@ describe('Body', () => { test('it renders events', () => { const wrapper = mount( - + ); @@ -162,39 +114,10 @@ describe('Body', () => { test('it renders a tooltip for timestamp', async () => { const headersJustTimestamp = defaultHeaders.filter((h) => h.id === '@timestamp'); - + const testProps = { ...props, columnHeaders: headersJustTimestamp }; const wrapper = mount( - + ); wrapper.update(); @@ -215,6 +138,11 @@ describe('Body', () => { describe('action on event', () => { const dispatchAddNoteToEvent = jest.fn(); const dispatchOnPinEvent = jest.fn(); + const testProps = { + ...props, + addNoteToEvent: dispatchAddNoteToEvent, + onPinEvent: dispatchOnPinEvent, + }; const addaNoteToEvent = (wrapper: ReturnType, note: string) => { wrapper.find('[data-test-subj="add-note"]').first().find('button').simulate('click'); @@ -251,36 +179,7 @@ describe('Body', () => { test('Add a Note to an event', () => { const wrapper = mount( - + ); addaNoteToEvent(wrapper, 'hello world'); @@ -290,44 +189,13 @@ describe('Body', () => { }); test('Add two Note to an event', () => { - const Proxy = (props: BodyProps) => ( + const Proxy = (proxyProps: BodyProps) => ( - + ); - const wrapper = mount( - - ); + const wrapper = mount(); addaNoteToEvent(wrapper, 'hello world'); dispatchAddNoteToEvent.mockClear(); dispatchOnPinEvent.mockClear(); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/translations.ts index 63b92d6b316c..ef7ee26cd3ec 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/translations.ts @@ -13,6 +13,13 @@ export const NOTES_TOOLTIP = i18n.translate( } ); +export const NOTES_DISABLE_TOOLTIP = i18n.translate( + 'xpack.securitySolution.timeline.body.notes.disableEventTooltip', + { + defaultMessage: 'Add notes for event filtered by a timeline template is not allowed', + } +); + export const COPY_TO_CLIPBOARD = i18n.translate( 'xpack.securitySolution.timeline.body.copyToClipboardButtonLabel', { @@ -38,6 +45,13 @@ export const PINNED_WITH_NOTES = i18n.translate( } ); +export const DISABLE_PIN = i18n.translate( + 'xpack.securitySolution.timeline.body.pinning.disablePinnnedTooltip', + { + defaultMessage: 'This event cannot be pinned because it is filtered by a timeline template', + } +); + export const EXPAND = i18n.translate( 'xpack.securitySolution.timeline.body.actions.expandAriaLabel', { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.test.tsx index 6fb2443486f8..922148535d12 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.test.tsx @@ -15,6 +15,7 @@ import { mockDataProviders } from '../data_providers/mock/mock_data_providers'; import { useMountAppended } from '../../../../common/utils/use_mount_appended'; import { TimelineHeader } from '.'; +import { TimelineStatus } from '../../../../../common/types/timeline'; const mockUiSettingsForFilterManager = createKibanaCoreStartMock().uiSettings; @@ -23,43 +24,32 @@ jest.mock('../../../../common/lib/kibana'); describe('Header', () => { const indexPattern = mockIndexPattern; const mount = useMountAppended(); + const props = { + browserFields: {}, + dataProviders: mockDataProviders, + filterManager: new FilterManager(mockUiSettingsForFilterManager), + id: 'foo', + indexPattern, + onDataProviderEdited: jest.fn(), + onDataProviderRemoved: jest.fn(), + onToggleDataProviderEnabled: jest.fn(), + onToggleDataProviderExcluded: jest.fn(), + show: true, + showCallOutUnauthorizedMsg: false, + status: TimelineStatus.active, + }; describe('rendering', () => { test('renders correctly against snapshot', () => { - const wrapper = shallow( - - ); + const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); }); test('it renders the data providers when show is true', () => { + const testProps = { ...props, show: true }; const wrapper = mount( - + ); @@ -67,21 +57,11 @@ describe('Header', () => { }); test('it does NOT render the data providers when show is false', () => { + const testProps = { ...props, show: false }; + const wrapper = mount( - + ); @@ -89,21 +69,15 @@ describe('Header', () => { }); test('it renders the unauthorized call out providers', () => { + const testProps = { + ...props, + filterManager: new FilterManager(mockUiSettingsForFilterManager), + showCallOutUnauthorizedMsg: true, + }; + const wrapper = mount( - + ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.tsx index e8f1e7371923..0541dee4b1e5 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/index.tsx @@ -22,6 +22,10 @@ import { StatefulSearchOrFilter } from '../search_or_filter'; import { BrowserFields } from '../../../../common/containers/source'; import * as i18n from './translations'; +import { + TimelineStatus, + TimelineStatusLiteralWithNull, +} from '../../../../../common/types/timeline'; interface Props { browserFields: BrowserFields; @@ -36,6 +40,7 @@ interface Props { onToggleDataProviderExcluded: OnToggleDataProviderExcluded; show: boolean; showCallOutUnauthorizedMsg: boolean; + status: TimelineStatusLiteralWithNull; } const TimelineHeaderComponent: React.FC = ({ @@ -51,6 +56,7 @@ const TimelineHeaderComponent: React.FC = ({ onToggleDataProviderExcluded, show, showCallOutUnauthorizedMsg, + status, }) => ( <> {showCallOutUnauthorizedMsg && ( @@ -62,7 +68,15 @@ const TimelineHeaderComponent: React.FC = ({ size="s" /> )} - + {status === TimelineStatus.immutable && ( + + )} {show && !showGraphView(graphEventId) && ( <> { const originalModule = jest.requireActual('../../../common/lib/kibana'); @@ -88,6 +89,8 @@ describe('StatefulTimeline', () => { showCallOutUnauthorizedMsg: false, sort, start: startDate, + status: TimelineStatus.active, + timelineType: TimelineType.default, updateColumns: timelineActions.updateColumns, updateDataProviderEnabled: timelineActions.updateDataProviderEnabled, updateDataProviderExcluded: timelineActions.updateDataProviderExcluded, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx index a66c01d0b5d0..35622eddc359 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx @@ -57,6 +57,8 @@ const StatefulTimelineComponent = React.memo( showCallOutUnauthorizedMsg, sort, start, + status, + timelineType, updateDataProviderEnabled, updateDataProviderExcluded, updateItemsPerPage, @@ -189,6 +191,7 @@ const StatefulTimelineComponent = React.memo( showCallOutUnauthorizedMsg={showCallOutUnauthorizedMsg} sort={sort!} start={start} + status={status} toggleColumn={toggleColumn} usersViewing={usersViewing} /> @@ -207,6 +210,8 @@ const StatefulTimelineComponent = React.memo( prevProps.show === nextProps.show && prevProps.showCallOutUnauthorizedMsg === nextProps.showCallOutUnauthorizedMsg && prevProps.start === nextProps.start && + prevProps.timelineType === nextProps.timelineType && + prevProps.status === nextProps.status && deepEqual(prevProps.columns, nextProps.columns) && deepEqual(prevProps.dataProviders, nextProps.dataProviders) && deepEqual(prevProps.filters, nextProps.filters) && @@ -238,11 +243,12 @@ const makeMapStateToProps = () => { kqlMode, show, sort, + status, + timelineType, } = timeline; const kqlQueryExpression = getKqlQueryTimeline(state, id)!; const timelineFilter = kqlMode === 'filter' ? filters || [] : []; - return { columns, dataProviders, @@ -261,6 +267,8 @@ const makeMapStateToProps = () => { showCallOutUnauthorizedMsg: getShowCallOutUnauthorizedMsg(state), sort, start: input.timerange.from, + status, + timelineType, }; }; return mapStateToProps; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/pin/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/pin/index.tsx index 800ea814fdd5..30fe8ae0ca1f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/pin/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/pin/index.tsx @@ -8,6 +8,8 @@ import { EuiButtonIcon, IconSize } from '@elastic/eui'; import { noop } from 'lodash/fp'; import React from 'react'; +import { TimelineType, TimelineTypeLiteral } from '../../../../../common/types/timeline'; + import * as i18n from '../body/translations'; export type PinIcon = 'pin' | 'pinFilled'; @@ -17,21 +19,25 @@ export const getPinIcon = (pinned: boolean): PinIcon => (pinned ? 'pinFilled' : interface Props { allowUnpinning: boolean; iconSize?: IconSize; + timelineType?: TimelineTypeLiteral; onClick?: () => void; pinned: boolean; } export const Pin = React.memo( - ({ allowUnpinning, iconSize = 'm', onClick = noop, pinned }) => ( - - ) + ({ allowUnpinning, iconSize = 'm', onClick = noop, pinned, timelineType }) => { + const isTemplate = timelineType === TimelineType.template; + return ( + + ); + } ); Pin.displayName = 'Pin'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx index 528af23191ee..21140d668d71 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx @@ -27,6 +27,7 @@ import { TimelineTypeLiteral, TimelineStatus, TimelineType, + TimelineStatusLiteral, TimelineId, } from '../../../../../common/types/timeline'; import { SecurityPageName } from '../../../../app/types'; @@ -262,11 +263,13 @@ interface NotesButtonProps { getNotesByIds: (noteIds: string[]) => Note[]; noteIds: string[]; size: 's' | 'l'; + status: TimelineStatusLiteral; showNotes: boolean; toggleShowNotes: () => void; text?: string; toolTip?: string; updateNote: UpdateNote; + timelineType: TimelineTypeLiteral; } const getNewNoteId = (): string => uuid.v4(); @@ -303,16 +306,24 @@ LargeNotesButton.displayName = 'LargeNotesButton'; interface SmallNotesButtonProps { noteIds: string[]; toggleShowNotes: () => void; + timelineType: TimelineTypeLiteral; } -const SmallNotesButton = React.memo(({ noteIds, toggleShowNotes }) => ( - toggleShowNotes()} - /> -)); +const SmallNotesButton = React.memo( + ({ noteIds, toggleShowNotes, timelineType }) => { + const isTemplate = timelineType === TimelineType.template; + + return ( + toggleShowNotes()} + isDisabled={isTemplate} + /> + ); + } +); SmallNotesButton.displayName = 'SmallNotesButton'; /** @@ -326,25 +337,32 @@ const NotesButtonComponent = React.memo( noteIds, showNotes, size, + status, toggleShowNotes, text, updateNote, + timelineType, }) => ( <> {size === 'l' ? ( ) : ( - + )} {size === 'l' && showNotes ? ( @@ -364,6 +382,8 @@ export const NotesButton = React.memo( noteIds, showNotes, size, + status, + timelineType, toggleShowNotes, toolTip, text, @@ -377,9 +397,11 @@ export const NotesButton = React.memo( noteIds={noteIds} showNotes={showNotes} size={size} + status={status} toggleShowNotes={toggleShowNotes} text={text} updateNote={updateNote} + timelineType={timelineType} /> ) : ( @@ -390,9 +412,11 @@ export const NotesButton = React.memo( noteIds={noteIds} showNotes={showNotes} size={size} + status={status} toggleShowNotes={toggleShowNotes} text={text} updateNote={updateNote} + timelineType={timelineType} /> ) diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.test.tsx index 1b76db409484..cd089d10d5d4 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.test.tsx @@ -6,13 +6,14 @@ import { mount } from 'enzyme'; import React from 'react'; -import { TimelineStatus } from '../../../../../common/types/timeline'; +import { TimelineStatus, TimelineType } from '../../../../../common/types/timeline'; import { mockGlobalState, apolloClientObservable, SUB_PLUGINS_REDUCER, createSecuritySolutionStorageMock, TestProviders, + kibanaObservable, } from '../../../../common/mock'; import { createStore, State } from '../../../../common/store'; import { useThrottledResizeObserver } from '../../../../common/components/utils'; @@ -86,6 +87,7 @@ const defaultProps = { isDatepickerLocked: false, isFavorite: false, title: '', + timelineType: TimelineType.default, description: '', getNotesByIds: jest.fn(), noteIds: [], @@ -103,11 +105,23 @@ describe('Properties', () => { const { storage } = createSecuritySolutionStorageMock(); let mockedWidth = 1000; - let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + let store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); beforeEach(() => { jest.clearAllMocks(); - store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); (useThrottledResizeObserver as jest.Mock).mockReturnValue({ width: mockedWidth }); }); @@ -130,9 +144,10 @@ describe('Properties', () => { }); test('renders correctly draft timeline', () => { + const testProps = { ...defaultProps, status: TimelineStatus.draft }; const wrapper = mount( - + ); @@ -157,9 +172,11 @@ describe('Properties', () => { }); test('it renders a filled star icon when it is a favorite', () => { + const testProps = { ...defaultProps, isFavorite: true }; + const wrapper = mount( - + ); @@ -168,10 +185,10 @@ describe('Properties', () => { test('it renders the title of the timeline', () => { const title = 'foozle'; - + const testProps = { ...defaultProps, title }; const wrapper = mount( - + ); @@ -194,9 +211,11 @@ describe('Properties', () => { }); test('it renders the lock icon when isDatepickerLocked is true', () => { + const testProps = { ...defaultProps, isDatepickerLocked: true }; + const wrapper = mount( - + ); expect( @@ -223,13 +242,16 @@ describe('Properties', () => { test('it renders a description on the left when the width is at least as wide as the threshold', () => { const description = 'strange'; + const testProps = { ...defaultProps, description }; + + // mockedWidth = showDescriptionThreshold; (useThrottledResizeObserver as jest.Mock).mockReset(); (useThrottledResizeObserver as jest.Mock).mockReturnValue({ width: showDescriptionThreshold }); const wrapper = mount( - + ); @@ -244,6 +266,9 @@ describe('Properties', () => { test('it does NOT render a description on the left when the width is less than the threshold', () => { const description = 'strange'; + const testProps = { ...defaultProps, description }; + + // mockedWidth = showDescriptionThreshold - 1; (useThrottledResizeObserver as jest.Mock).mockReset(); (useThrottledResizeObserver as jest.Mock).mockReturnValue({ @@ -252,7 +277,7 @@ describe('Properties', () => { const wrapper = mount( - + ); @@ -313,10 +338,11 @@ describe('Properties', () => { test('it renders an avatar for the current user viewing the timeline when it has a title', () => { const title = 'port scan'; + const testProps = { ...defaultProps, title }; const wrapper = mount( - + ); @@ -334,9 +360,11 @@ describe('Properties', () => { }); test('insert timeline - new case', async () => { + const testProps = { ...defaultProps, title: 'coolness' }; + const wrapper = mount( - + ); wrapper.find('[data-test-subj="settings-gear"]').at(0).simulate('click'); @@ -352,9 +380,11 @@ describe('Properties', () => { }); test('insert timeline - existing case', async () => { + const testProps = { ...defaultProps, title: 'coolness' }; + const wrapper = mount( - + ); wrapper.find('[data-test-subj="settings-gear"]').at(0).simulate('click'); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.tsx index 8029d166a688..40462fa0d09d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/index.tsx @@ -7,7 +7,7 @@ import React, { useState, useCallback, useMemo } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import { TimelineStatus, TimelineTypeLiteral } from '../../../../../common/types/timeline'; +import { TimelineStatusLiteral, TimelineTypeLiteral } from '../../../../../common/types/timeline'; import { useThrottledResizeObserver } from '../../../../common/components/utils'; import { Note } from '../../../../common/lib/note'; import { InputsModelId } from '../../../../common/store/inputs/constants'; @@ -52,7 +52,8 @@ interface Props { isFavorite: boolean; noteIds: string[]; timelineId: string; - status: TimelineStatus; + timelineType: TimelineTypeLiteral; + status: TimelineStatusLiteral; title: string; toggleLock: ToggleLock; updateDescription: UpdateDescription; @@ -87,6 +88,7 @@ export const Properties = React.memo( noteIds, status, timelineId, + timelineType, title, toggleLock, updateDescription, @@ -164,10 +166,12 @@ export const Properties = React.memo( isFavorite={isFavorite} noteIds={noteIds} onToggleShowNotes={onToggleShowNotes} + status={status} showDescription={width >= showDescriptionThreshold} showNotes={showNotes} showNotesFromWidth={width >= showNotesThreshold} timelineId={timelineId} + timelineType={timelineType} title={title} toggleLock={onToggleLock} updateDescription={updateDescription} @@ -196,6 +200,7 @@ export const Properties = React.memo( showUsersView={title.length > 0} status={status} timelineId={timelineId} + timelineType={timelineType} title={title} updateDescription={updateDescription} updateNote={updateNote} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/new_template_timeline.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/new_template_timeline.test.tsx index cd6233334c5d..b14248487281 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/new_template_timeline.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/new_template_timeline.test.tsx @@ -11,6 +11,7 @@ import { mockGlobalState, apolloClientObservable, SUB_PLUGINS_REDUCER, + kibanaObservable, createSecuritySolutionStorageMock, } from '../../../../common/mock'; import { createStore, State } from '../../../../common/store'; @@ -26,7 +27,13 @@ jest.mock('../../../../common/lib/kibana', () => { describe('NewTemplateTimeline', () => { const state: State = mockGlobalState; const { storage } = createSecuritySolutionStorageMock(); - const store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + const store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); const mockClosePopover = jest.fn(); const mockTitle = 'NEW_TIMELINE'; let wrapper: ReactWrapper; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_left.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_left.tsx index 52766422e49c..4673ba662b2e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_left.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_left.tsx @@ -10,9 +10,12 @@ import React from 'react'; import styled from 'styled-components'; import { Description, Name, NotesButton, StarIcon } from './helpers'; import { AssociateNote, UpdateNote } from '../../notes/helpers'; + import { Note } from '../../../../common/lib/note'; import { SuperDatePicker } from '../../../../common/components/super_date_picker'; +import { TimelineTypeLiteral, TimelineStatusLiteral } from '../../../../../common/types/timeline'; + import * as i18n from './translations'; type UpdateIsFavorite = ({ id, isFavorite }: { id: string; isFavorite: boolean }) => void; @@ -22,6 +25,7 @@ type UpdateDescription = ({ id, description }: { id: string; description: string interface Props { isFavorite: boolean; timelineId: string; + timelineType: TimelineTypeLiteral; updateIsFavorite: UpdateIsFavorite; showDescription: boolean; description: string; @@ -29,6 +33,7 @@ interface Props { updateTitle: UpdateTitle; updateDescription: UpdateDescription; showNotes: boolean; + status: TimelineStatusLiteral; associateNote: AssociateNote; showNotesFromWidth: boolean; getNotesByIds: (noteIds: string[]) => Note[]; @@ -77,8 +82,10 @@ export const PropertiesLeft = React.memo( showDescription, description, title, + timelineType, updateTitle, updateDescription, + status, showNotes, showNotesFromWidth, associateNote, @@ -120,10 +127,12 @@ export const PropertiesLeft = React.memo( noteIds={noteIds} showNotes={showNotes} size="l" + status={status} text={i18n.NOTES} toggleShowNotes={onToggleShowNotes} toolTip={i18n.NOTES_TOOL_TIP} updateNote={updateNote} + timelineType={timelineType} /> ) : null} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_right.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_right.test.tsx index ae167515495f..a36e841f3f87 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_right.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_right.test.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { PropertiesRight } from './properties_right'; import { useKibana } from '../../../../common/lib/kibana'; -import { TimelineStatus } from '../../../../../common/types/timeline'; +import { TimelineStatus, TimelineType } from '../../../../../common/types/timeline'; import { disableTemplate } from '../../../../../common/constants'; jest.mock('../../../../common/lib/kibana', () => { @@ -67,6 +67,7 @@ describe('Properties Right', () => { onOpenTimelineModal: jest.fn(), status: TimelineStatus.active, showTimelineModal: false, + timelineType: TimelineType.default, title: 'title', updateNote: jest.fn(), }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_right.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_right.tsx index e20a3db80d88..7a9fe85ae402 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_right.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/properties_right.tsx @@ -17,7 +17,7 @@ import { import { NewTimeline, Description, NotesButton, NewCase, ExistingCase } from './helpers'; import { disableTemplate } from '../../../../../common/constants'; -import { TimelineStatus } from '../../../../../common/types/timeline'; +import { TimelineStatusLiteral, TimelineTypeLiteral } from '../../../../../common/types/timeline'; import { InspectButton, InspectButtonContainer } from '../../../../common/components/inspect'; import { useKibana } from '../../../../common/lib/kibana'; @@ -83,9 +83,10 @@ interface PropertiesRightComponentProps { showNotesFromWidth: boolean; showTimelineModal: boolean; showUsersView: boolean; - status: TimelineStatus; + status: TimelineStatusLiteral; timelineId: string; title: string; + timelineType: TimelineTypeLiteral; updateDescription: UpdateDescription; updateNote: UpdateNote; usersViewing: string[]; @@ -111,6 +112,7 @@ const PropertiesRightComponent: React.FC = ({ showTimelineModal, showUsersView, status, + timelineType, timelineId, title, updateDescription, @@ -203,6 +205,8 @@ const PropertiesRightComponent: React.FC = ({ noteIds={noteIds} showNotes={showNotes} size="l" + status={status} + timelineType={timelineType} text={i18n.NOTES} toggleShowNotes={onToggleShowNotes} toolTip={i18n.NOTES_TOOL_TIP} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/translations.ts index 2568f4127540..561f8e513aa0 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/translations.ts @@ -112,7 +112,7 @@ export const NEW_TIMELINE = i18n.translate( export const NEW_TEMPLATE_TIMELINE = i18n.translate( 'xpack.securitySolution.timeline.properties.newTemplateTimelineButtonLabel', { - defaultMessage: 'Create template timeline', + defaultMessage: 'Create new timeline template', } ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/selectable_timeline/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/selectable_timeline/index.test.tsx index 2b67cf75dcff..0ff4c0a70fff 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/selectable_timeline/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/selectable_timeline/index.test.tsx @@ -30,11 +30,7 @@ describe('SelectableTimeline', () => { }; }); - const { - SelectableTimeline, - - ORIGINAL_PAGE_SIZE, - } = jest.requireActual('./'); + const { SelectableTimeline, ORIGINAL_PAGE_SIZE } = jest.requireActual('./'); const props = { hideUntitled: false, @@ -94,8 +90,10 @@ describe('SelectableTimeline', () => { sortField: SortFieldTimeline.updated, sortOrder: Direction.desc, }, + status: null, onlyUserFavorite: false, timelineType: TimelineType.default, + templateTimelineType: null, }; beforeAll(() => { mount(); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/selectable_timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/selectable_timeline/index.tsx index 56c7c3dcfeb7..dacaf325130d 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/selectable_timeline/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/selectable_timeline/index.tsx @@ -33,6 +33,7 @@ import * as i18nTimeline from '../../open_timeline/translations'; import { OpenTimelineResult } from '../../open_timeline/types'; import { getEmptyTagValue } from '../../../../common/components/empty_value'; import * as i18n from '../translations'; +import { useTimelineStatus } from '../../open_timeline/use_timeline_status'; const MyEuiFlexItem = styled(EuiFlexItem)` display: inline-block; @@ -118,6 +119,7 @@ const SelectableTimelineComponent: React.FC = ({ const [onlyFavorites, setOnlyFavorites] = useState(false); const [searchRef, setSearchRef] = useState(null); const { fetchAllTimeline, timelines, loading, totalCount: timelineCount } = useGetAllTimeline(); + const { timelineStatus, templateTimelineType } = useTimelineStatus({ timelineType }); const onSearchTimeline = useCallback((val) => { setSearchTimelineValue(val); @@ -249,24 +251,31 @@ const SelectableTimelineComponent: React.FC = ({ }, }; - useEffect( - () => - fetchAllTimeline({ - pageInfo: { - pageIndex: 1, - pageSize, - }, - search: searchTimelineValue, - sort: { - sortField: SortFieldTimeline.updated, - sortOrder: Direction.desc, - }, - onlyUserFavorite: onlyFavorites, - timelineType, - }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [onlyFavorites, pageSize, searchTimelineValue, timelineType] - ); + useEffect(() => { + fetchAllTimeline({ + pageInfo: { + pageIndex: 1, + pageSize, + }, + search: searchTimelineValue, + sort: { + sortField: SortFieldTimeline.updated, + sortOrder: Direction.desc, + }, + onlyUserFavorite: onlyFavorites, + status: timelineStatus, + timelineType, + templateTimelineType, + }); + }, [ + fetchAllTimeline, + onlyFavorites, + pageSize, + searchTimelineValue, + timelineType, + timelineStatus, + templateTimelineType, + ]); return ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.test.tsx index 79ec58711e06..b58505546c34 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.test.tsx @@ -24,6 +24,7 @@ import { TimelineComponent, Props as TimelineComponentProps } from './timeline'; import { Sort } from './body/sort'; import { mockDataProviders } from './data_providers/mock/mock_data_providers'; import { useMountAppended } from '../../../common/utils/use_mount_appended'; +import { TimelineStatus } from '../../../../common/types/timeline'; jest.mock('../../../common/lib/kibana'); jest.mock('./properties/properties_right'); @@ -96,6 +97,7 @@ describe('Timeline', () => { showCallOutUnauthorizedMsg: false, start: startDate, sort, + status: TimelineStatus.active, toggleColumn: jest.fn(), usersViewing: ['elastic'], }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.tsx index 85e3d5d9478b..07d4b004d2ed 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.tsx @@ -40,6 +40,7 @@ import { IIndexPattern, } from '../../../../../../../src/plugins/data/public'; import { useManageTimeline } from '../manage_timeline'; +import { TimelineStatusLiteral } from '../../../../common/types/timeline'; const TimelineContainer = styled.div` height: 100%; @@ -110,6 +111,7 @@ export interface Props { showCallOutUnauthorizedMsg: boolean; start: number; sort: Sort; + status: TimelineStatusLiteral; toggleColumn: (column: ColumnHeaderOptions) => void; usersViewing: string[]; } @@ -141,6 +143,7 @@ export const TimelineComponent: React.FC = ({ show, showCallOutUnauthorizedMsg, start, + status, sort, toggleColumn, usersViewing, @@ -214,6 +217,7 @@ export const TimelineComponent: React.FC = ({ onToggleDataProviderExcluded={onToggleDataProviderExcluded} show={show} showCallOutUnauthorizedMsg={showCallOutUnauthorizedMsg} + status={status} /> diff --git a/x-pack/plugins/security_solution/public/timelines/containers/all/index.gql_query.ts b/x-pack/plugins/security_solution/public/timelines/containers/all/index.gql_query.ts index 60d000fe7818..5cbc922f09c9 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/all/index.gql_query.ts +++ b/x-pack/plugins/security_solution/public/timelines/containers/all/index.gql_query.ts @@ -13,6 +13,8 @@ export const allTimelinesQuery = gql` $sort: SortTimeline $onlyUserFavorite: Boolean $timelineType: TimelineType + $templateTimelineType: TemplateTimelineType + $status: TimelineStatus ) { getAllTimeline( pageInfo: $pageInfo @@ -20,8 +22,15 @@ export const allTimelinesQuery = gql` sort: $sort onlyUserFavorite: $onlyUserFavorite timelineType: $timelineType + templateTimelineType: $templateTimelineType + status: $status ) { totalCount + defaultTimelineCount + templateTimelineCount + elasticTemplateTimelineCount + customTemplateTimelineCount + favoriteCount timeline { savedObjectId description diff --git a/x-pack/plugins/security_solution/public/timelines/containers/all/index.tsx b/x-pack/plugins/security_solution/public/timelines/containers/all/index.tsx index f025cf15181c..17cc0f64de03 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/all/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/containers/all/index.tsx @@ -22,7 +22,11 @@ import { useApolloClient } from '../../../common/utils/apollo_context'; import { allTimelinesQuery } from './index.gql_query'; import * as i18n from '../../pages/translations'; -import { TimelineTypeLiteralWithNull } from '../../../../common/types/timeline'; +import { + TimelineTypeLiteralWithNull, + TimelineStatusLiteralWithNull, + TemplateTimelineTypeLiteralWithNull, +} from '../../../../common/types/timeline'; export interface AllTimelinesArgs { fetchAllTimeline: ({ @@ -30,11 +34,17 @@ export interface AllTimelinesArgs { pageInfo, search, sort, + status, timelineType, }: AllTimelinesVariables) => void; timelines: OpenTimelineResult[]; loading: boolean; totalCount: number; + customTemplateTimelineCount: number; + defaultTimelineCount: number; + elasticTemplateTimelineCount: number; + templateTimelineCount: number; + favoriteCount: number; } export interface AllTimelinesVariables { @@ -42,7 +52,9 @@ export interface AllTimelinesVariables { pageInfo: PageInfoTimeline; search: string; sort: SortTimeline; + status: TimelineStatusLiteralWithNull; timelineType: TimelineTypeLiteralWithNull; + templateTimelineType: TemplateTimelineTypeLiteralWithNull; } export const ALL_TIMELINE_QUERY_ID = 'FETCH_ALL_TIMELINES'; @@ -76,6 +88,7 @@ export const getAllTimeline = memoizeOne( ) : null, savedObjectId: timeline.savedObjectId, + status: timeline.status, title: timeline.title, updated: timeline.updated, updatedBy: timeline.updatedBy, @@ -90,27 +103,39 @@ export const useGetAllTimeline = (): AllTimelinesArgs => { loading: false, totalCount: 0, timelines: [], + customTemplateTimelineCount: 0, + defaultTimelineCount: 0, + elasticTemplateTimelineCount: 0, + templateTimelineCount: 0, + favoriteCount: 0, }); const fetchAllTimeline = useCallback( - ({ onlyUserFavorite, pageInfo, search, sort, timelineType }: AllTimelinesVariables) => { + async ({ + onlyUserFavorite, + pageInfo, + search, + sort, + status, + timelineType, + templateTimelineType, + }: AllTimelinesVariables) => { let didCancel = false; const abortCtrl = new AbortController(); const fetchData = async () => { try { if (apolloClient != null) { - setAllTimelines({ - ...allTimelines, - loading: true, - }); + setAllTimelines((prevState) => ({ ...prevState, loading: true })); const variables: GetAllTimeline.Variables = { onlyUserFavorite, pageInfo, search, sort, + status, timelineType, + templateTimelineType, }; const response = await apolloClient.query< GetAllTimeline.Query, @@ -125,8 +150,16 @@ export const useGetAllTimeline = (): AllTimelinesArgs => { }, }, }); - const totalCount = response?.data?.getAllTimeline?.totalCount ?? 0; - const timelines = response?.data?.getAllTimeline?.timeline ?? []; + const getAllTimelineResponse = response?.data?.getAllTimeline; + const totalCount = getAllTimelineResponse?.totalCount ?? 0; + const timelines = getAllTimelineResponse?.timeline ?? []; + const customTemplateTimelineCount = + getAllTimelineResponse?.customTemplateTimelineCount ?? 0; + const defaultTimelineCount = getAllTimelineResponse?.defaultTimelineCount ?? 0; + const elasticTemplateTimelineCount = + getAllTimelineResponse?.elasticTemplateTimelineCount ?? 0; + const templateTimelineCount = getAllTimelineResponse?.templateTimelineCount ?? 0; + const favoriteCount = getAllTimelineResponse?.favoriteCount ?? 0; if (!didCancel) { dispatch( inputsActions.setQuery({ @@ -141,6 +174,11 @@ export const useGetAllTimeline = (): AllTimelinesArgs => { loading: false, totalCount, timelines: getAllTimeline(JSON.stringify(variables), timelines as TimelineResult[]), + customTemplateTimelineCount, + defaultTimelineCount, + elasticTemplateTimelineCount, + templateTimelineCount, + favoriteCount, }); } } @@ -155,6 +193,11 @@ export const useGetAllTimeline = (): AllTimelinesArgs => { loading: false, totalCount: 0, timelines: [], + customTemplateTimelineCount: 0, + defaultTimelineCount: 0, + elasticTemplateTimelineCount: 0, + templateTimelineCount: 0, + favoriteCount: 0, }); } } @@ -165,7 +208,7 @@ export const useGetAllTimeline = (): AllTimelinesArgs => { abortCtrl.abort(); }; }, - [apolloClient, allTimelines, dispatch, dispatchToaster] + [apolloClient, dispatch, dispatchToaster] ); useEffect(() => { diff --git a/x-pack/plugins/security_solution/public/timelines/containers/api.test.ts b/x-pack/plugins/security_solution/public/timelines/containers/api.test.ts index 26373fa1a825..8a2f91d7171f 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/api.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/containers/api.test.ts @@ -165,6 +165,7 @@ describe('persistTimeline', () => { }, }, }; + const version = null; const fetchMock = jest.fn(); const postMock = jest.fn(); @@ -180,7 +181,11 @@ describe('persistTimeline', () => { patch: patchMock.mockReturnValue(mockPatchTimelineResponse), }, }); - api.persistTimeline({ timelineId, timeline: initialDraftTimeline, version }); + api.persistTimeline({ + timelineId, + timeline: initialDraftTimeline, + version, + }); }); afterAll(() => { diff --git a/x-pack/plugins/security_solution/public/timelines/containers/api.ts b/x-pack/plugins/security_solution/public/timelines/containers/api.ts index a2277897e99b..fbd89268880d 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/api.ts +++ b/x-pack/plugins/security_solution/public/timelines/containers/api.ts @@ -12,6 +12,8 @@ import { TimelineResponse, TimelineResponseType, TimelineStatus, + TimelineErrorResponseType, + TimelineErrorResponse, } from '../../../common/types/timeline'; import { TimelineInput, TimelineType } from '../../graphql/types'; import { @@ -48,6 +50,12 @@ const decodeTimelineResponse = (respTimeline?: TimelineResponse) => fold(throwErrors(createToasterPlainError), identity) ); +const decodeTimelineErrorResponse = (respTimeline?: TimelineErrorResponse) => + pipe( + TimelineErrorResponseType.decode(respTimeline), + fold(throwErrors(createToasterPlainError), identity) + ); + const postTimeline = async ({ timeline }: RequestPostTimeline): Promise => { const response = await KibanaServices.get().http.post(TIMELINE_URL, { method: 'POST', @@ -61,12 +69,19 @@ const patchTimeline = async ({ timelineId, timeline, version, -}: RequestPatchTimeline): Promise => { - const response = await KibanaServices.get().http.patch(TIMELINE_URL, { - method: 'PATCH', - body: JSON.stringify({ timeline, timelineId, version }), - }); - +}: RequestPatchTimeline): Promise => { + let response = null; + try { + response = await KibanaServices.get().http.patch(TIMELINE_URL, { + method: 'PATCH', + body: JSON.stringify({ timeline, timelineId, version }), + }); + } catch (err) { + // For Future developer + // We are not rejecting our promise here because we had issue with our RXJS epic + // the issue we were not able to pass the right object to it so we did manage the error in the success + return Promise.resolve(decodeTimelineErrorResponse(err.body)); + } return decodeTimelineResponse(response); }; @@ -74,17 +89,31 @@ export const persistTimeline = async ({ timelineId, timeline, version, -}: RequestPersistTimeline): Promise => { - if (timelineId == null && timeline.status === TimelineStatus.draft) { - const draftTimeline = await cleanDraftTimeline({ timelineType: timeline.timelineType! }); +}: RequestPersistTimeline): Promise => { + if (timelineId == null && timeline.status === TimelineStatus.draft && timeline) { + const draftTimeline = await cleanDraftTimeline({ + timelineType: timeline.timelineType!, + templateTimelineId: timeline.templateTimelineId ?? undefined, + templateTimelineVersion: timeline.templateTimelineVersion ?? undefined, + }); + + const templateTimelineInfo = + timeline.timelineType! === TimelineType.template + ? { + templateTimelineId: + draftTimeline.data.persistTimeline.timeline.templateTimelineId ?? + timeline.templateTimelineId, + templateTimelineVersion: + draftTimeline.data.persistTimeline.timeline.templateTimelineVersion ?? + timeline.templateTimelineVersion, + } + : {}; return patchTimeline({ timelineId: draftTimeline.data.persistTimeline.timeline.savedObjectId, timeline: { ...timeline, - templateTimelineId: draftTimeline.data.persistTimeline.timeline.templateTimelineId, - templateTimelineVersion: - draftTimeline.data.persistTimeline.timeline.templateTimelineVersion, + ...templateTimelineInfo, }, version: draftTimeline.data.persistTimeline.timeline.version ?? '', }); @@ -147,12 +176,24 @@ export const getDraftTimeline = async ({ export const cleanDraftTimeline = async ({ timelineType, + templateTimelineId, + templateTimelineVersion, }: { timelineType: TimelineType; + templateTimelineId?: string; + templateTimelineVersion?: number; }): Promise => { + const templateTimelineInfo = + timelineType === TimelineType.template + ? { + templateTimelineId, + templateTimelineVersion, + } + : {}; const response = await KibanaServices.get().http.post(TIMELINE_DRAFT_URL, { body: JSON.stringify({ timelineType, + ...templateTimelineInfo, }), }); diff --git a/x-pack/plugins/security_solution/public/timelines/pages/translations.ts b/x-pack/plugins/security_solution/public/timelines/pages/translations.ts index 3ec98d47c67e..5a9f80013a3e 100644 --- a/x-pack/plugins/security_solution/public/timelines/pages/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/pages/translations.ts @@ -30,3 +30,17 @@ export const ERROR_FETCHING_TIMELINES_TITLE = i18n.translate( defaultMessage: 'Failed to query all timelines data', } ); + +export const UPDATE_TIMELINE_ERROR_TITLE = i18n.translate( + 'xpack.securitySolution.timelines.updateTimelineErrorTitle', + { + defaultMessage: 'Timeline error', + } +); + +export const UPDATE_TIMELINE_ERROR_TEXT = i18n.translate( + 'xpack.securitySolution.timelines.updateTimelineErrorText', + { + defaultMessage: 'Something went wrong', + } +); diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts index 55e6849fdb6c..8fd75547cc53 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/actions.ts @@ -70,6 +70,8 @@ export const createTimeline = actionCreator<{ showCheckboxes?: boolean; showRowRenderers?: boolean; timelineType?: TimelineTypeLiteral; + templateTimelineId?: string; + templateTimelineVersion?: number; }>('CREATE_TIMELINE'); export const pinEvent = actionCreator<{ id: string; eventId: string }>('PIN_EVENT'); diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.ts index 2155dc804aa7..94acb9d92075 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic.ts @@ -33,7 +33,7 @@ import { Filter, MatchAllFilter, } from '../../../../../../.../../../src/plugins/data/public'; -import { TimelineStatus } from '../../../../common/types/timeline'; +import { TimelineStatus, TimelineErrorResponse } from '../../../../common/types/timeline'; import { inputsModel } from '../../../common/store/inputs'; import { TimelineType, @@ -43,6 +43,10 @@ import { } from '../../../graphql/types'; import { addError } from '../../../common/store/app/actions'; +import { persistTimeline } from '../../containers/api'; +import { ALL_TIMELINE_QUERY_ID } from '../../containers/all'; +import * as i18n from '../../pages/translations'; + import { applyKqlFilterQuery, addProvider, @@ -79,8 +83,6 @@ import { isNotNull } from './helpers'; import { dispatcherTimelinePersistQueue } from './epic_dispatcher_timeline_persistence_queue'; import { myEpicTimelineId } from './my_epic_timeline_id'; import { ActionTimeline, TimelineEpicDependencies } from './types'; -import { persistTimeline } from '../../containers/api'; -import { ALL_TIMELINE_QUERY_ID } from '../../containers/all'; const timelineActionsType = [ applyKqlFilterQuery.type, @@ -121,6 +123,7 @@ export const createTimelineEpic = (): Epic< timelineByIdSelector, timelineTimeRangeSelector, apolloClient$, + kibana$, } ) => { const timeline$ = state$.pipe(map(timelineByIdSelector), filter(isNotNull)); @@ -146,13 +149,24 @@ export const createTimelineEpic = (): Epic< if (action.type === addError.type) { return true; } - if (action.type === createTimeline.type && isItAtimelineAction(timelineId)) { + if ( + isItAtimelineAction(timelineId) && + timelineObj != null && + timelineObj.status != null && + TimelineStatus.immutable === timelineObj.status + ) { + return false; + } else if (action.type === createTimeline.type && isItAtimelineAction(timelineId)) { myEpicTimelineId.setTimelineVersion(null); myEpicTimelineId.setTimelineId(null); + myEpicTimelineId.setTemplateTimelineId(null); + myEpicTimelineId.setTemplateTimelineVersion(null); } else if (action.type === addTimeline.type && isItAtimelineAction(timelineId)) { const addNewTimeline: TimelineModel = get('payload.timeline', action); myEpicTimelineId.setTimelineId(addNewTimeline.savedObjectId); myEpicTimelineId.setTimelineVersion(addNewTimeline.version); + myEpicTimelineId.setTemplateTimelineId(addNewTimeline.templateTimelineId); + myEpicTimelineId.setTemplateTimelineVersion(addNewTimeline.templateTimelineVersion); return true; } else if ( timelineActionsType.includes(action.type) && @@ -176,6 +190,8 @@ export const createTimelineEpic = (): Epic< const action: ActionTimeline = get('action', objAction); const timelineId = myEpicTimelineId.getTimelineId(); const version = myEpicTimelineId.getTimelineVersion(); + const templateTimelineId = myEpicTimelineId.getTemplateTimelineId(); + const templateTimelineVersion = myEpicTimelineId.getTemplateTimelineVersion(); if (timelineNoteActionsType.includes(action.type)) { return epicPersistNote( @@ -211,13 +227,37 @@ export const createTimelineEpic = (): Epic< persistTimeline({ timelineId, version, - timeline: convertTimelineAsInput(timeline[action.payload.id], timelineTimeRange), + timeline: { + ...convertTimelineAsInput(timeline[action.payload.id], timelineTimeRange), + templateTimelineId, + templateTimelineVersion, + }, }) ).pipe( - withLatestFrom(timeline$, allTimelineQuery$), - mergeMap(([result, recentTimeline, allTimelineQuery]) => { + withLatestFrom(timeline$, allTimelineQuery$, kibana$), + mergeMap(([result, recentTimeline, allTimelineQuery, kibana]) => { + const error = result as TimelineErrorResponse; + if (error.status_code != null && error.status_code === 405) { + kibana.notifications!.toasts.addDanger({ + title: i18n.UPDATE_TIMELINE_ERROR_TITLE, + text: error.message ?? i18n.UPDATE_TIMELINE_ERROR_TEXT, + }); + return [ + endTimelineSaving({ + id: action.payload.id, + }), + ]; + } + const savedTimeline = recentTimeline[action.payload.id]; const response: ResponseTimeline = get('data.persistTimeline', result); + if (response == null) { + return [ + endTimelineSaving({ + id: action.payload.id, + }), + ]; + } const callOutMsg = response.code === 403 ? [showCallOutUnauthorizedMsg()] : []; if (allTimelineQuery.refetch != null) { @@ -264,6 +304,12 @@ export const createTimelineEpic = (): Epic< myEpicTimelineId.setTimelineVersion( updatedTimeline[get('payload.id', checkAction)].version ); + myEpicTimelineId.setTemplateTimelineId( + updatedTimeline[get('payload.id', checkAction)].templateTimelineId + ); + myEpicTimelineId.setTemplateTimelineVersion( + updatedTimeline[get('payload.id', checkAction)].templateTimelineVersion + ); return true; } return false; diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_local_storage.test.tsx b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_local_storage.test.tsx index 34778aba7873..388869194085 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_local_storage.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/epic_local_storage.test.tsx @@ -15,6 +15,7 @@ import { defaultHeaders, createSecuritySolutionStorageMock, mockIndexPattern, + kibanaObservable, } from '../../../common/mock'; import { createStore, State } from '../../../common/store'; @@ -38,6 +39,7 @@ import { Direction } from '../../../graphql/types'; import { addTimelineInStorage } from '../../containers/local_storage'; import { isPageTimeline } from './epic_local_storage'; +import { TimelineStatus } from '../../../../common/types/timeline'; jest.mock('../../containers/local_storage'); @@ -50,7 +52,13 @@ const addTimelineInStorageMock = addTimelineInStorage as jest.Mock; describe('epicLocalStorage', () => { const state: State = mockGlobalState; const { storage } = createSecuritySolutionStorageMock(); - let store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + let store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); let props = {} as TimelineComponentProps; const sort: Sort = { @@ -63,7 +71,13 @@ describe('epicLocalStorage', () => { const indexPattern = mockIndexPattern; beforeEach(() => { - store = createStore(state, SUB_PLUGINS_REDUCER, apolloClientObservable, storage); + store = createStore( + state, + SUB_PLUGINS_REDUCER, + apolloClientObservable, + kibanaObservable, + storage + ); props = { browserFields: mockBrowserFields, columns: defaultHeaders, @@ -89,6 +103,7 @@ describe('epicLocalStorage', () => { show: true, showCallOutUnauthorizedMsg: false, start: startDate, + status: TimelineStatus.active, sort, toggleColumn: jest.fn(), usersViewing: ['elastic'], diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts index c0615d36f7a2..33770aacde6b 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/helpers.ts @@ -6,6 +6,7 @@ import { getOr, omit, uniq, isEmpty, isEqualWith, union } from 'lodash/fp'; +import uuid from 'uuid'; import { Filter } from '../../../../../../../src/plugins/data/public'; import { disableTemplate } from '../../../../common/constants'; @@ -19,7 +20,7 @@ import { } from '../../../timelines/components/timeline/data_providers/data_provider'; import { KueryFilterQuery, SerializedFilterQuery } from '../../../common/store/model'; import { TimelineNonEcsData } from '../../../graphql/types'; -import { TimelineTypeLiteral } from '../../../../common/types/timeline'; +import { TimelineTypeLiteral, TimelineType } from '../../../../common/types/timeline'; import { timelineDefaults } from './defaults'; import { ColumnHeaderOptions, KqlMode, TimelineModel, EventType } from './model'; @@ -158,28 +159,38 @@ export const addNewTimeline = ({ showRowRenderers = true, timelineById, timelineType, -}: AddNewTimelineParams): TimelineById => ({ - ...timelineById, - [id]: { - id, - ...timelineDefaults, - columns, - dataProviders, - dateRange, - filters, - itemsPerPage, - kqlQuery, - sort, - show, - savedObjectId: null, - version: null, - isSaving: false, - isLoading: false, - showCheckboxes, - showRowRenderers, - timelineType: !disableTemplate ? timelineType : timelineDefaults.timelineType, - }, -}); +}: AddNewTimelineParams): TimelineById => { + const templateTimelineInfo = + !disableTemplate && timelineType === TimelineType.template + ? { + templateTimelineId: uuid.v4(), + templateTimelineVersion: 1, + } + : {}; + return { + ...timelineById, + [id]: { + id, + ...timelineDefaults, + columns, + dataProviders, + dateRange, + filters, + itemsPerPage, + kqlQuery, + sort, + show, + savedObjectId: null, + version: null, + isSaving: false, + isLoading: false, + showCheckboxes, + showRowRenderers, + timelineType: !disableTemplate ? timelineType : timelineDefaults.timelineType, + ...templateTimelineInfo, + }, + }; +}; interface PinTimelineEventParams { id: string; diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/manage_timeline_id.tsx b/x-pack/plugins/security_solution/public/timelines/store/timeline/manage_timeline_id.tsx index d68c9bd42d97..6f8666a349d7 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/manage_timeline_id.tsx +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/manage_timeline_id.tsx @@ -7,6 +7,8 @@ export class ManageEpicTimelineId { private timelineId: string | null = null; private version: string | null = null; + private templateTimelineId: string | null = null; + private templateVersion: number | null = null; public getTimelineId(): string | null { return this.timelineId; @@ -16,6 +18,14 @@ export class ManageEpicTimelineId { return this.version; } + public getTemplateTimelineId(): string | null { + return this.templateTimelineId; + } + + public getTemplateTimelineVersion(): number | null { + return this.templateVersion; + } + public setTimelineId(timelineId: string | null) { this.timelineId = timelineId; } @@ -23,4 +33,12 @@ export class ManageEpicTimelineId { public setTimelineVersion(version: string | null) { this.version = version; } + + public setTemplateTimelineId(templateTimelineId: string | null) { + this.templateTimelineId = templateTimelineId; + } + + public setTemplateTimelineVersion(templateVersion: number | null) { + this.templateVersion = templateVersion; + } } diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts index e8ea3c8d16e3..57895fea8f8f 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/model.ts @@ -160,7 +160,6 @@ export type SubsetTimelineModel = Readonly< | 'isLoading' | 'savedObjectId' | 'version' - | 'timelineType' | 'status' > >; diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts index 30b7f73c839d..4072b4ac2f78 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/reducer.ts @@ -137,24 +137,26 @@ export const timelineReducer = reducerWithInitialState(initialTimelineState) timelineType = TimelineType.default, filters, } - ) => ({ - ...state, - timelineById: addNewTimeline({ - columns, - dataProviders, - dateRange, - filters, - id, - itemsPerPage, - kqlQuery, - sort, - show, - showCheckboxes, - showRowRenderers, - timelineById: state.timelineById, - timelineType, - }), - }) + ) => { + return { + ...state, + timelineById: addNewTimeline({ + columns, + dataProviders, + dateRange, + filters, + id, + itemsPerPage, + kqlQuery, + sort, + show, + showCheckboxes, + showRowRenderers, + timelineById: state.timelineById, + timelineType, + }), + }; + } ) .case(upsertColumn, (state, { column, id, index }) => ({ ...state, diff --git a/x-pack/plugins/security_solution/public/timelines/store/timeline/types.ts b/x-pack/plugins/security_solution/public/timelines/store/timeline/types.ts index 65798648f92c..c64ed608339b 100644 --- a/x-pack/plugins/security_solution/public/timelines/store/timeline/types.ts +++ b/x-pack/plugins/security_solution/public/timelines/store/timeline/types.ts @@ -10,6 +10,8 @@ import { Storage } from '../../../../../../../src/plugins/kibana_utils/public'; import { AppApolloClient } from '../../../common/lib/lib'; import { inputsModel } from '../../../common/store/inputs'; import { NotesById } from '../../../common/store/app/model'; +import { StartServices } from '../../../types'; + import { TimelineModel } from './model'; export interface AutoSavedWarningMsg { @@ -53,5 +55,6 @@ export interface TimelineEpicDependencies { selectAllTimelineQuery: () => (state: State, id: string) => inputsModel.GlobalQuery; selectNotesByIdSelector: (state: State) => NotesById; apolloClient$: Observable; + kibana$: Observable; storage: Storage; } diff --git a/x-pack/plugins/security_solution/public/types.ts b/x-pack/plugins/security_solution/public/types.ts index 6d59824702cf..e212289458ed 100644 --- a/x-pack/plugins/security_solution/public/types.ts +++ b/x-pack/plugins/security_solution/public/types.ts @@ -19,6 +19,7 @@ import { TriggersAndActionsUIPublicPluginStart as TriggersActionsStart, } from '../../triggers_actions_ui/public'; import { SecurityPluginSetup } from '../../security/public'; +import { AppFrontendLibs } from './common/lib/lib'; export interface SetupPlugins { home: HomePublicPluginSetup; @@ -47,3 +48,7 @@ export type StartServices = CoreStart & export interface PluginSetup {} // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface PluginStart {} + +export interface AppObservableLibs extends AppFrontendLibs { + kibana: CoreStart; +} diff --git a/x-pack/plugins/security_solution/server/graphql/timeline/resolvers.ts b/x-pack/plugins/security_solution/server/graphql/timeline/resolvers.ts index a40ef5466c78..ab729bae6474 100644 --- a/x-pack/plugins/security_solution/server/graphql/timeline/resolvers.ts +++ b/x-pack/plugins/security_solution/server/graphql/timeline/resolvers.ts @@ -53,7 +53,9 @@ export const createTimelineResolvers = ( args.pageInfo || null, args.search || null, args.sort || null, - args.timelineType || null + args.status || null, + args.timelineType || null, + args.templateTimelineType || null ); }, }, diff --git a/x-pack/plugins/security_solution/server/graphql/timeline/schema.gql.ts b/x-pack/plugins/security_solution/server/graphql/timeline/schema.gql.ts index b9aa8534ab0e..a9d07389797d 100644 --- a/x-pack/plugins/security_solution/server/graphql/timeline/schema.gql.ts +++ b/x-pack/plugins/security_solution/server/graphql/timeline/schema.gql.ts @@ -133,6 +133,12 @@ export const timelineSchema = gql` enum TimelineStatus { active draft + immutable + } + + enum TemplateTimelineType { + elastic + custom } input TimelineInput { @@ -277,6 +283,11 @@ export const timelineSchema = gql` type ResponseTimelines { timeline: [TimelineResult]! totalCount: Float + defaultTimelineCount: Float + templateTimelineCount: Float + elasticTemplateTimelineCount: Float + customTemplateTimelineCount: Float + favoriteCount: Float } ######################### @@ -285,7 +296,7 @@ export const timelineSchema = gql` extend type Query { getOneTimeline(id: ID!): TimelineResult! - getAllTimeline(pageInfo: PageInfoTimeline, search: String, sort: SortTimeline, onlyUserFavorite: Boolean, timelineType: TimelineType): ResponseTimelines! + getAllTimeline(pageInfo: PageInfoTimeline, search: String, sort: SortTimeline, onlyUserFavorite: Boolean, timelineType: TimelineType, templateTimelineType: TemplateTimelineType, status: TimelineStatus): ResponseTimelines! } extend type Mutation { diff --git a/x-pack/plugins/security_solution/server/graphql/types.ts b/x-pack/plugins/security_solution/server/graphql/types.ts index 40666b619392..2db3052bae66 100644 --- a/x-pack/plugins/security_solution/server/graphql/types.ts +++ b/x-pack/plugins/security_solution/server/graphql/types.ts @@ -347,6 +347,7 @@ export enum TlsFields { export enum TimelineStatus { active = 'active', draft = 'draft', + immutable = 'immutable', } export enum TimelineType { @@ -361,6 +362,11 @@ export enum SortFieldTimeline { created = 'created', } +export enum TemplateTimelineType { + elastic = 'elastic', + custom = 'custom', +} + export enum NetworkDirectionEcs { inbound = 'inbound', outbound = 'outbound', @@ -2119,6 +2125,16 @@ export interface ResponseTimelines { timeline: (Maybe)[]; totalCount?: Maybe; + + defaultTimelineCount?: Maybe; + + templateTimelineCount?: Maybe; + + elasticTemplateTimelineCount?: Maybe; + + customTemplateTimelineCount?: Maybe; + + favoriteCount?: Maybe; } export interface Mutation { @@ -2256,6 +2272,10 @@ export interface GetAllTimelineQueryArgs { onlyUserFavorite?: Maybe; timelineType?: Maybe; + + templateTimelineType?: Maybe; + + status?: Maybe; } export interface AuthenticationsSourceArgs { timerange: TimerangeInput; @@ -2714,6 +2734,10 @@ export namespace QueryResolvers { onlyUserFavorite?: Maybe; timelineType?: Maybe; + + templateTimelineType?: Maybe; + + status?: Maybe; } } @@ -8670,6 +8694,24 @@ export namespace ResponseTimelinesResolvers { timeline?: TimelineResolver<(Maybe)[], TypeParent, TContext>; totalCount?: TotalCountResolver, TypeParent, TContext>; + + defaultTimelineCount?: DefaultTimelineCountResolver, TypeParent, TContext>; + + templateTimelineCount?: TemplateTimelineCountResolver, TypeParent, TContext>; + + elasticTemplateTimelineCount?: ElasticTemplateTimelineCountResolver< + Maybe, + TypeParent, + TContext + >; + + customTemplateTimelineCount?: CustomTemplateTimelineCountResolver< + Maybe, + TypeParent, + TContext + >; + + favoriteCount?: FavoriteCountResolver, TypeParent, TContext>; } export type TimelineResolver< @@ -8682,6 +8724,31 @@ export namespace ResponseTimelinesResolvers { Parent = ResponseTimelines, TContext = SiemContext > = Resolver; + export type DefaultTimelineCountResolver< + R = Maybe, + Parent = ResponseTimelines, + TContext = SiemContext + > = Resolver; + export type TemplateTimelineCountResolver< + R = Maybe, + Parent = ResponseTimelines, + TContext = SiemContext + > = Resolver; + export type ElasticTemplateTimelineCountResolver< + R = Maybe, + Parent = ResponseTimelines, + TContext = SiemContext + > = Resolver; + export type CustomTemplateTimelineCountResolver< + R = Maybe, + Parent = ResponseTimelines, + TContext = SiemContext + > = Resolver; + export type FavoriteCountResolver< + R = Maybe, + Parent = ResponseTimelines, + TContext = SiemContext + > = Resolver; } export namespace MutationResolvers { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/README.md b/x-pack/plugins/security_solution/server/lib/detection_engine/README.md index 7a48df72d6bd..fa0716ec0828 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/README.md +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/README.md @@ -59,7 +59,7 @@ which will: - Delete any existing alerts you have - Delete any existing alert tasks you have - Delete any existing signal mapping, policies, and template, you might have previously had. -- Add the latest signal index and its mappings using your settings from `kibana.dev.yml` environment variable of `xpack.security_solution.signalsIndex`. +- Add the latest signal index and its mappings using your settings from `kibana.dev.yml` environment variable of `xpack.securitySolution.signalsIndex`. - Posts the sample rule from `./rules/queries/query_with_rule_id.json` - The sample rule checks for root or admin every 5 minutes and reports that as a signal if it is a positive hit @@ -171,6 +171,6 @@ go about doing so. To test out the functionality of large lists with rules, the user will need to import a list and post a rule with a reference to that exception list. The following outlines an example using the sample json rule provided in the repo. * First, set the appropriate env var in order to enable exceptions features`export ELASTIC_XPACK_SECURITY_SOLUTION_LISTS_FEATURE=true` and `export ELASTIC_XPACK_SECURITY_SOLUTION_EXCEPTIONS_LISTS=true` and start kibana -* Second, import a list of ips from a file called `ci-badguys.txt`. The command should look like this: +* Second, import a list of ips from a file called `ci-badguys.txt`. The command should look like this: `cd $HOME/kibana/x-pack/plugins/lists/server/scripts && ./import_list_items_by_filename.sh ip ~/ci-badguys.txt` * Then, from the detection engine scripts folder (`cd kibana/x-pack/plugins/security_solution/server/lib/detection_engine/scripts`) run `./post_rule.sh rules/queries/lists/query_with_list_plugin.json` diff --git a/x-pack/plugins/security_solution/server/lib/timeline/pick_saved_timeline.ts b/x-pack/plugins/security_solution/server/lib/timeline/pick_saved_timeline.ts index 281726d488ab..68e7f8d5e6fe 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/pick_saved_timeline.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/pick_saved_timeline.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import uuid from 'uuid'; import { isEmpty } from 'lodash/fp'; import { AuthenticatedUser } from '../../../../security/common/model'; import { UNAUTHENTICATED_USER } from '../../../common/constants'; @@ -28,18 +27,13 @@ export const pickSavedTimeline = ( savedTimeline.updatedBy = userInfo?.username ?? UNAUTHENTICATED_USER; } - if (savedTimeline.timelineType === TimelineType.template) { - if (savedTimeline.templateTimelineId == null) { - // create template timeline - savedTimeline.templateTimelineId = uuid.v4(); - savedTimeline.templateTimelineVersion = 1; - } else { - // update template timeline - if (savedTimeline.templateTimelineVersion != null) { - savedTimeline.templateTimelineVersion = savedTimeline.templateTimelineVersion + 1; - } - } - } else { + if (savedTimeline.status === TimelineStatus.draft) { + savedTimeline.status = !isEmpty(savedTimeline.title) + ? TimelineStatus.active + : TimelineStatus.draft; + } + + if (savedTimeline.timelineType === TimelineType.default) { savedTimeline.timelineType = savedTimeline.timelineType ?? TimelineType.default; savedTimeline.templateTimelineId = null; savedTimeline.templateTimelineVersion = null; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/__mocks__/import_timelines.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/__mocks__/import_timelines.ts index 7180f06d853b..adfdf831f22c 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/__mocks__/import_timelines.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/__mocks__/import_timelines.ts @@ -138,6 +138,7 @@ export const mockGetTimelineValue = { kqlMode: 'filter', kqlQuery: { filterQuery: [] }, title: 'My duplicate timeline', + timelineType: TimelineType.default, dateRange: { start: 1584523907294, end: 1584610307294 }, savedQueryId: null, sort: { columnId: '@timestamp', sortDirection: 'desc' }, @@ -145,17 +146,25 @@ export const mockGetTimelineValue = { createdBy: 'angela', updated: 1584868346013, updatedBy: 'angela', - noteIds: [], + noteIds: ['d2649d40-6bc5-xxxx-0000-5db0048c6086'], pinnedEventIds: ['k-gi8nABm-sIqJ_scOoS'], }; export const mockGetTemplateTimelineValue = { ...mockGetTimelineValue, timelineType: TimelineType.template, - templateTimelineId: '79deb4c0-6bc1-11ea-a90b-f5341fb7a189', + templateTimelineId: '79deb4c0-6bc1-0000-0000-f5341fb7a189', templateTimelineVersion: 1, }; +export const mockUniqueParsedTemplateTimelineObjects = [ + { ...mockUniqueParsedObjects[0], ...mockGetTemplateTimelineValue, templateTimelineVersion: 2 }, +]; + +export const mockParsedTemplateTimelineObjects = [ + { ...mockParsedObjects[0], ...mockGetTemplateTimelineValue }, +]; + export const mockGetDraftTimelineValue = { savedObjectId: '79deb4c0-6bc1-11ea-a90b-f5341fb7a189', version: 'WzEyMjUsMV0=', @@ -195,8 +204,51 @@ export const mockParsedTimelineObject = omit( mockUniqueParsedObjects[0] ); +export const mockParsedTemplateTimelineObject = omit( + [ + 'globalNotes', + 'eventNotes', + 'pinnedEventIds', + 'version', + 'savedObjectId', + 'created', + 'createdBy', + 'updated', + 'updatedBy', + ], + mockUniqueParsedTemplateTimelineObjects[0] +); + export const mockGetCurrentUser = { user: { username: 'mockUser', }, }; + +export const mockCreatedTimeline = { + savedObjectId: '79deb4c0-1111-1111-1111-f5341fb7a189', + version: 'WzEyMjUsMV0=', + columns: [], + dataProviders: [], + description: 'description', + eventType: 'all', + filters: [], + kqlMode: 'filter', + kqlQuery: { filterQuery: [] }, + title: 'My duplicate timeline', + dateRange: { start: 1584523907294, end: 1584610307294 }, + savedQueryId: null, + sort: { columnId: '@timestamp', sortDirection: 'desc' }, + created: 1584828930463, + createdBy: 'angela', + updated: 1584868346013, + updatedBy: 'angela', + eventNotes: [], + globalNotes: [], + pinnedEventIds: [], +}; + +export const mockCreatedTemplateTimeline = { + ...mockCreatedTimeline, + ...mockGetTemplateTimelineValue, +}; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/__mocks__/request_responses.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/__mocks__/request_responses.ts index 0b320459c76a..9afe5ad53332 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/__mocks__/request_responses.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/__mocks__/request_responses.ts @@ -4,15 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ import * as rt from 'io-ts'; +import stream from 'stream'; + import { TIMELINE_DRAFT_URL, TIMELINE_EXPORT_URL, TIMELINE_IMPORT_URL, TIMELINE_URL, } from '../../../../../common/constants'; -import stream from 'stream'; -import { requestMock } from '../../../detection_engine/routes/__mocks__'; import { SavedTimeline, TimelineType, TimelineStatus } from '../../../../../common/types/timeline'; + +import { requestMock } from '../../../detection_engine/routes/__mocks__'; + import { updateTimelineSchema } from '../schemas/update_timelines_schema'; import { createTimelineSchema } from '../schemas/create_timelines_schema'; @@ -59,7 +62,7 @@ export const inputTimeline: SavedTimeline = { title: 't', timelineType: TimelineType.default, templateTimelineId: null, - templateTimelineVersion: null, + templateTimelineVersion: 1, dateRange: { start: 1585227005527, end: 1585313405527 }, savedQueryId: null, sort: { columnId: '@timestamp', sortDirection: 'desc' }, @@ -68,7 +71,7 @@ export const inputTimeline: SavedTimeline = { export const inputTemplateTimeline = { ...inputTimeline, timelineType: TimelineType.template, - templateTimelineId: null, + templateTimelineId: '79deb4c0-6bc1-11ea-inpt-templatea189', templateTimelineVersion: null, }; @@ -90,11 +93,11 @@ export const createDraftTimelineWithoutTimelineId = { }; export const createTemplateTimelineWithoutTimelineId = { - templateTimelineId: null, timeline: inputTemplateTimeline, timelineId: null, version: null, timelineType: TimelineType.template, + status: TimelineStatus.active, }; export const createTimelineWithTimelineId = { @@ -110,7 +113,6 @@ export const createDraftTimelineWithTimelineId = { export const createTemplateTimelineWithTimelineId = { ...createTemplateTimelineWithoutTimelineId, timelineId: '79deb4c0-6bc1-11ea-a90b-f5341fb7a189', - templateTimelineId: 'existing template timeline id', }; export const updateTimelineWithTimelineId = { @@ -122,7 +124,7 @@ export const updateTimelineWithTimelineId = { export const updateTemplateTimelineWithTimelineId = { timeline: { ...inputTemplateTimeline, - templateTimelineId: '79deb4c0-6bc1-11ea-a90b-f5341fb7a189', + templateTimelineId: '79deb4c0-6bc1-0000-0000-f5341fb7a189', templateTimelineVersion: 1, }, timelineId: '79deb4c0-6bc1-11ea-a90b-f5341fb7a189', diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/clean_draft_timelines_route.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/clean_draft_timelines_route.ts index 9ad50b8f2266..8cabd84a965b 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/clean_draft_timelines_route.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/clean_draft_timelines_route.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import uuid from 'uuid'; import { IRouter } from '../../../../../../../src/core/server'; import { ConfigType } from '../../..'; import { transformError, buildSiemResponse } from '../../detection_engine/routes/utils'; @@ -14,6 +15,7 @@ import { buildRouteValidation } from '../../../utils/build_validation/route_vali import { getDraftTimeline, resetTimeline, getTimeline, persistTimeline } from '../saved_object'; import { draftTimelineDefaults } from '../default_timeline'; import { cleanDraftTimelineSchema } from './schemas/clean_draft_timelines_schema'; +import { TimelineType } from '../../../../common/types/timeline'; export const cleanDraftTimelinesRoute = ( router: IRouter, @@ -60,10 +62,18 @@ export const cleanDraftTimelinesRoute = ( }, }); } + const templateTimelineData = + request.body.timelineType === TimelineType.template + ? { + timelineType: request.body.timelineType, + templateTimelineId: uuid.v4(), + templateTimelineVersion: 1, + } + : {}; const newTimelineResponse = await persistTimeline(frameworkRequest, null, null, { ...draftTimelineDefaults, - timelineType: request.body.timelineType, + ...templateTimelineData, }); if (newTimelineResponse.code === 200) { diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/create_timelines_route.test.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/create_timelines_route.test.ts index 70ee1532395a..f5345c3dce22 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/create_timelines_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/create_timelines_route.test.ts @@ -23,6 +23,7 @@ import { createTimelineWithTimelineId, createTemplateTimelineWithoutTimelineId, createTemplateTimelineWithTimelineId, + updateTemplateTimelineWithTimelineId, } from './__mocks__/request_responses'; import { CREATE_TEMPLATE_TIMELINE_ERROR_MESSAGE, @@ -34,6 +35,7 @@ describe('create timelines', () => { let securitySetup: SecurityPluginSetup; let { context } = requestContextMock.createTools(); let mockGetTimeline: jest.Mock; + let mockGetTemplateTimeline: jest.Mock; let mockPersistTimeline: jest.Mock; let mockPersistPinnedEventOnTimeline: jest.Mock; let mockPersistNote: jest.Mock; @@ -55,6 +57,7 @@ describe('create timelines', () => { } as unknown) as SecurityPluginSetup; mockGetTimeline = jest.fn(); + mockGetTemplateTimeline = jest.fn(); mockPersistTimeline = jest.fn(); mockPersistPinnedEventOnTimeline = jest.fn(); mockPersistNote = jest.fn(); @@ -231,11 +234,14 @@ describe('create timelines', () => { }); }); - describe('Import a template timeline already exist', () => { + describe('Create a template timeline already exist', () => { beforeEach(() => { jest.doMock('../saved_object', () => { return { getTimeline: mockGetTimeline.mockReturnValue(mockGetTemplateTimelineValue), + getTimelineByTemplateTimelineId: mockGetTemplateTimeline.mockReturnValue({ + timeline: [mockGetTemplateTimelineValue], + }), persistTimeline: mockPersistTimeline, }; }); @@ -259,7 +265,7 @@ describe('create timelines', () => { test('returns error message', async () => { const response = await server.inject( - getCreateTimelinesRequest(createTemplateTimelineWithTimelineId), + getCreateTimelinesRequest(updateTemplateTimelineWithTimelineId), context ); expect(response.body).toEqual({ diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/create_timelines_route.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/create_timelines_route.ts index d92f2ce0764c..60ddaea367ae 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/create_timelines_route.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/create_timelines_route.ts @@ -6,7 +6,6 @@ import { IRouter } from '../../../../../../../src/core/server'; import { TIMELINE_URL } from '../../../../common/constants'; -import { TimelineType } from '../../../../common/types/timeline'; import { ConfigType } from '../../..'; import { SetupPlugins } from '../../../plugin'; @@ -15,14 +14,12 @@ import { buildRouteValidation } from '../../../utils/build_validation/route_vali import { transformError, buildSiemResponse } from '../../detection_engine/routes/utils'; import { createTimelineSchema } from './schemas/create_timelines_schema'; -import { buildFrameworkRequest } from './utils/common'; import { - createTimelines, - getTimeline, - getTemplateTimeline, - CREATE_TEMPLATE_TIMELINE_ERROR_MESSAGE, - CREATE_TIMELINE_ERROR_MESSAGE, -} from './utils/create_timelines'; + buildFrameworkRequest, + CompareTimelinesStatus, + TimelineStatusActions, +} from './utils/common'; +import { createTimelines } from './utils/create_timelines'; export const createTimelinesRoute = ( router: IRouter, @@ -36,7 +33,7 @@ export const createTimelinesRoute = ( body: buildRouteValidation(createTimelineSchema), }, options: { - tags: ['access:securitySolution'], + tags: ['access:siem'], }, }, async (context, request, response) => { @@ -46,40 +43,54 @@ export const createTimelinesRoute = ( const frameworkRequest = await buildFrameworkRequest(context, security, request); const { timelineId, timeline, version } = request.body; - const { templateTimelineId, timelineType } = timeline; - const isHandlingTemplateTimeline = timelineType === TimelineType.template; - - const existTimeline = - timelineId != null ? await getTimeline(frameworkRequest, timelineId) : null; - const existTemplateTimeline = - templateTimelineId != null - ? await getTemplateTimeline(frameworkRequest, templateTimelineId) - : null; - - if ( - (!isHandlingTemplateTimeline && existTimeline != null) || - (isHandlingTemplateTimeline && (existTemplateTimeline != null || existTimeline != null)) - ) { - return siemResponse.error({ - body: isHandlingTemplateTimeline - ? CREATE_TEMPLATE_TIMELINE_ERROR_MESSAGE - : CREATE_TIMELINE_ERROR_MESSAGE, - statusCode: 405, - }); - } + const { + templateTimelineId, + templateTimelineVersion, + timelineType, + title, + status, + } = timeline; + const compareTimelinesStatus = new CompareTimelinesStatus({ + status, + title, + timelineType, + timelineInput: { + id: timelineId, + version, + }, + templateTimelineInput: { + id: templateTimelineId, + version: templateTimelineVersion, + }, + frameworkRequest, + }); + await compareTimelinesStatus.init(); // Create timeline - const newTimeline = await createTimelines(frameworkRequest, timeline, null, version); - return response.ok({ - body: { - data: { - persistTimeline: newTimeline, + if (compareTimelinesStatus.isCreatable) { + const newTimeline = await createTimelines({ + frameworkRequest, + timeline, + timelineVersion: version, + }); + + return response.ok({ + body: { + data: { + persistTimeline: newTimeline, + }, }, - }, - }); + }); + } else { + return siemResponse.error( + compareTimelinesStatus.checkIsFailureCases(TimelineStatusActions.create) || { + statusCode: 405, + body: 'update timeline error', + } + ); + } } catch (err) { const error = transformError(err); - return siemResponse.error({ body: error.message, statusCode: error.statusCode, diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/import_timelines_route.test.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/import_timelines_route.test.ts index 48e22f6af2a7..15fb8f3411cf 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/import_timelines_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/import_timelines_route.test.ts @@ -12,7 +12,7 @@ import { createMockConfig, } from '../../detection_engine/routes/__mocks__'; import { TIMELINE_EXPORT_URL } from '../../../../common/constants'; -import { TimelineStatus } from '../../../../common/types/timeline'; +import { TimelineStatus, TimelineType } from '../../../../common/types/timeline'; import { SecurityPluginSetup } from '../../../../../../plugins/security/server'; import { @@ -22,7 +22,19 @@ import { mockGetCurrentUser, mockGetTimelineValue, mockParsedTimelineObject, + mockParsedTemplateTimelineObjects, + mockUniqueParsedTemplateTimelineObjects, + mockParsedTemplateTimelineObject, + mockCreatedTemplateTimeline, + mockGetTemplateTimelineValue, + mockCreatedTimeline, } from './__mocks__/import_timelines'; +import { + TEMPLATE_TIMELINE_VERSION_CONFLICT_MESSAGE, + EMPTY_TITLE_ERROR_MESSAGE, + NOT_ALLOW_UPDATE_STATUS_ERROR_MESSAGE, + NOT_ALLOW_UPDATE_TIMELINE_TYPE_ERROR_MESSAGE, +} from './utils/failure_cases'; describe('import timelines', () => { let server: ReturnType; @@ -35,8 +47,7 @@ describe('import timelines', () => { let mockPersistPinnedEventOnTimeline: jest.Mock; let mockPersistNote: jest.Mock; let mockGetTupleDuplicateErrorsAndUniqueTimeline: jest.Mock; - const newTimelineSavedObjectId = '79deb4c0-6bc1-11ea-9999-f5341fb7a189'; - const newTimelineVersion = '9999'; + beforeEach(() => { jest.resetModules(); jest.resetAllMocks(); @@ -90,7 +101,7 @@ describe('import timelines', () => { getTimeline: mockGetTimeline.mockReturnValue(null), getTimelineByTemplateTimelineId: mockGetTemplateTimeline.mockReturnValue(null), persistTimeline: mockPersistTimeline.mockReturnValue({ - timeline: { savedObjectId: newTimelineSavedObjectId, version: newTimelineVersion }, + timeline: mockCreatedTimeline, }), }; }); @@ -137,21 +148,40 @@ describe('import timelines', () => { }); test('should Create a new timeline savedObject with given timeline', async () => { - const mockRequest = getImportTimelinesRequest(); - await server.inject(mockRequest, context); - expect(mockPersistTimeline.mock.calls[0][3]).toEqual(mockParsedTimelineObject); - }); - - test('should Create a new timeline savedObject with given draft timeline', async () => { - mockGetTupleDuplicateErrorsAndUniqueTimeline.mockReturnValue([ - mockDuplicateIdErrors, - [{ ...mockUniqueParsedObjects[0], status: TimelineStatus.draft }], - ]); const mockRequest = getImportTimelinesRequest(); await server.inject(mockRequest, context); expect(mockPersistTimeline.mock.calls[0][3]).toEqual({ ...mockParsedTimelineObject, status: TimelineStatus.active, + templateTimelineId: null, + templateTimelineVersion: null, + }); + }); + + test('should throw error if given an untitle timeline', async () => { + mockGetTupleDuplicateErrorsAndUniqueTimeline.mockReturnValue([ + mockDuplicateIdErrors, + [ + { + ...mockUniqueParsedObjects[0], + title: '', + }, + ], + ]); + const mockRequest = getImportTimelinesRequest(); + const response = await server.inject(mockRequest, context); + expect(response.body).toEqual({ + success: false, + success_count: 0, + errors: [ + { + id: '79deb4c0-6bc1-11ea-a90b-f5341fb7a189', + error: { + status_code: 409, + message: EMPTY_TITLE_ERROR_MESSAGE, + }, + }, + ], }); }); @@ -178,7 +208,9 @@ describe('import timelines', () => { test('should Create a new pinned event with new timelineSavedObjectId', async () => { const mockRequest = getImportTimelinesRequest(); await server.inject(mockRequest, context); - expect(mockPersistPinnedEventOnTimeline.mock.calls[0][3]).toEqual(newTimelineSavedObjectId); + expect(mockPersistPinnedEventOnTimeline.mock.calls[0][3]).toEqual( + mockCreatedTimeline.savedObjectId + ); }); test('should Create notes', async () => { @@ -202,7 +234,7 @@ describe('import timelines', () => { test('should provide note content when Creating notes for a timeline', async () => { const mockRequest = getImportTimelinesRequest(); await server.inject(mockRequest, context); - expect(mockPersistNote.mock.calls[0][2]).toEqual(newTimelineVersion); + expect(mockPersistNote.mock.calls[0][2]).toEqual(mockCreatedTimeline.version); }); test('should provide new notes when Creating notes for a timeline', async () => { @@ -211,17 +243,17 @@ describe('import timelines', () => { expect(mockPersistNote.mock.calls[0][3]).toEqual({ eventId: undefined, note: mockUniqueParsedObjects[0].globalNotes[0].note, - timelineId: newTimelineSavedObjectId, + timelineId: mockCreatedTimeline.savedObjectId, }); expect(mockPersistNote.mock.calls[1][3]).toEqual({ eventId: mockUniqueParsedObjects[0].eventNotes[0].eventId, note: mockUniqueParsedObjects[0].eventNotes[0].note, - timelineId: newTimelineSavedObjectId, + timelineId: mockCreatedTimeline.savedObjectId, }); expect(mockPersistNote.mock.calls[2][3]).toEqual({ eventId: mockUniqueParsedObjects[0].eventNotes[1].eventId, note: mockUniqueParsedObjects[0].eventNotes[1].note, - timelineId: newTimelineSavedObjectId, + timelineId: mockCreatedTimeline.savedObjectId, }); }); @@ -268,7 +300,458 @@ describe('import timelines', () => { id: '79deb4c0-6bc1-11ea-a90b-f5341fb7a189', error: { status_code: 409, - message: `timeline_id: "79deb4c0-6bc1-11ea-a90b-f5341fb7a189" already exists`, + message: `savedObjectId: "79deb4c0-6bc1-11ea-a90b-f5341fb7a189" already exists`, + }, + }, + ], + }); + }); + + test('should throw error if given an untitle timeline', async () => { + mockGetTupleDuplicateErrorsAndUniqueTimeline.mockReturnValue([ + mockDuplicateIdErrors, + [ + { + ...mockUniqueParsedObjects[0], + title: '', + }, + ], + ]); + const mockRequest = getImportTimelinesRequest(); + const response = await server.inject(mockRequest, context); + expect(response.body).toEqual({ + success: false, + success_count: 0, + errors: [ + { + id: '79deb4c0-6bc1-11ea-a90b-f5341fb7a189', + error: { + status_code: 409, + message: EMPTY_TITLE_ERROR_MESSAGE, + }, + }, + ], + }); + }); + + test('should throw error if timelineType updated', async () => { + mockGetTupleDuplicateErrorsAndUniqueTimeline.mockReturnValue([ + mockDuplicateIdErrors, + [ + { + ...mockGetTimelineValue, + timelineType: TimelineType.template, + }, + ], + ]); + const mockRequest = getImportTimelinesRequest(); + const response = await server.inject(mockRequest, context); + expect(response.body).toEqual({ + success: false, + success_count: 0, + errors: [ + { + id: '79deb4c0-6bc1-11ea-a90b-f5341fb7a189', + error: { + status_code: 409, + message: NOT_ALLOW_UPDATE_TIMELINE_TYPE_ERROR_MESSAGE, + }, + }, + ], + }); + }); + }); + + describe('request validation', () => { + beforeEach(() => { + jest.doMock('../saved_object', () => { + return { + getTimeline: mockGetTimeline.mockReturnValue(null), + persistTimeline: mockPersistTimeline.mockReturnValue({ + timeline: { savedObjectId: '79deb4c0-6bc1-11ea-9999-f5341fb7a189' }, + }), + }; + }); + + jest.doMock('../../pinned_event/saved_object', () => { + return { + persistPinnedEventOnTimeline: mockPersistPinnedEventOnTimeline.mockReturnValue( + new Error('Test error') + ), + }; + }); + + jest.doMock('../../note/saved_object', () => { + return { + persistNote: mockPersistNote, + }; + }); + }); + test('disallows invalid query', async () => { + request = requestMock.create({ + method: 'post', + path: TIMELINE_EXPORT_URL, + body: { id: 'someId' }, + }); + const importTimelinesRoute = jest.requireActual('./import_timelines_route') + .importTimelinesRoute; + + importTimelinesRoute(server.router, createMockConfig(), securitySetup); + const result = server.validate(request); + + expect(result.badRequest).toHaveBeenCalledWith( + [ + 'Invalid value "undefined" supplied to "file"', + 'Invalid value "undefined" supplied to "file"', + ].join(',') + ); + }); + }); +}); + +describe('import template timelines', () => { + let server: ReturnType; + let request: ReturnType; + let securitySetup: SecurityPluginSetup; + let { context } = requestContextMock.createTools(); + let mockGetTimeline: jest.Mock; + let mockGetTemplateTimeline: jest.Mock; + let mockPersistTimeline: jest.Mock; + let mockPersistPinnedEventOnTimeline: jest.Mock; + let mockPersistNote: jest.Mock; + let mockGetTupleDuplicateErrorsAndUniqueTimeline: jest.Mock; + const mockNewTemplateTimelineId = 'new templateTimelineId'; + beforeEach(() => { + jest.resetModules(); + jest.resetAllMocks(); + jest.restoreAllMocks(); + jest.clearAllMocks(); + + server = serverMock.create(); + context = requestContextMock.createTools().context; + + securitySetup = ({ + authc: { + getCurrentUser: jest.fn().mockReturnValue(mockGetCurrentUser), + }, + authz: {}, + } as unknown) as SecurityPluginSetup; + + mockGetTimeline = jest.fn(); + mockGetTemplateTimeline = jest.fn(); + mockPersistTimeline = jest.fn(); + mockPersistPinnedEventOnTimeline = jest.fn(); + mockPersistNote = jest.fn(); + mockGetTupleDuplicateErrorsAndUniqueTimeline = jest.fn(); + + jest.doMock('../create_timelines_stream_from_ndjson', () => { + return { + createTimelinesStreamFromNdJson: jest + .fn() + .mockReturnValue(mockParsedTemplateTimelineObjects), + }; + }); + + jest.doMock('../../../../../../../src/legacy/utils', () => { + return { + createPromiseFromStreams: jest.fn().mockReturnValue(mockParsedTemplateTimelineObjects), + }; + }); + + jest.doMock('./utils/import_timelines', () => { + const originalModule = jest.requireActual('./utils/import_timelines'); + return { + ...originalModule, + getTupleDuplicateErrorsAndUniqueTimeline: mockGetTupleDuplicateErrorsAndUniqueTimeline.mockReturnValue( + [mockDuplicateIdErrors, mockUniqueParsedTemplateTimelineObjects] + ), + }; + }); + + jest.doMock('uuid', () => ({ + v4: jest.fn().mockReturnValue(mockNewTemplateTimelineId), + })); + }); + + describe('Import a new template timeline', () => { + beforeEach(() => { + jest.doMock('../saved_object', () => { + return { + getTimeline: mockGetTimeline.mockReturnValue(null), + getTimelineByTemplateTimelineId: mockGetTemplateTimeline.mockReturnValue(null), + persistTimeline: mockPersistTimeline.mockReturnValue({ + timeline: mockCreatedTemplateTimeline, + }), + }; + }); + + jest.doMock('../../pinned_event/saved_object', () => { + return { + persistPinnedEventOnTimeline: mockPersistPinnedEventOnTimeline, + }; + }); + + jest.doMock('../../note/saved_object', () => { + return { + persistNote: mockPersistNote, + }; + }); + + const importTimelinesRoute = jest.requireActual('./import_timelines_route') + .importTimelinesRoute; + importTimelinesRoute(server.router, createMockConfig(), securitySetup); + }); + + test('should use given timelineId to check if the timeline savedObject already exist', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockGetTimeline.mock.calls[0][1]).toEqual( + mockUniqueParsedTemplateTimelineObjects[0].savedObjectId + ); + }); + + test('should use given templateTimelineId to check if the timeline savedObject already exist', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockGetTemplateTimeline.mock.calls[0][1]).toEqual( + mockUniqueParsedTemplateTimelineObjects[0].templateTimelineId + ); + }); + + test('should Create a new timeline savedObject', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistTimeline).toHaveBeenCalled(); + }); + + test('should Create a new timeline savedObject without timelineId', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistTimeline.mock.calls[0][1]).toBeNull(); + }); + + test('should Create a new timeline savedObject without timeline version', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistTimeline.mock.calls[0][2]).toBeNull(); + }); + + test('should Create a new timeline savedObject witn given timeline and skip the omitted fields', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistTimeline.mock.calls[0][3]).toEqual({ + ...mockParsedTemplateTimelineObject, + status: TimelineStatus.active, + }); + }); + + test('should NOT Create new pinned events', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistPinnedEventOnTimeline).not.toHaveBeenCalled(); + }); + + test('should provide no noteSavedObjectId when Creating notes for a timeline', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistNote.mock.calls[0][1]).toBeNull(); + }); + + test('should provide new timeline version when Creating notes for a timeline', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistNote.mock.calls[0][2]).toEqual(mockCreatedTemplateTimeline.version); + }); + + test('should exclude event notes when creating notes', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistNote.mock.calls[0][3]).toEqual({ + eventId: undefined, + note: mockUniqueParsedTemplateTimelineObjects[0].globalNotes[0].note, + timelineId: mockCreatedTemplateTimeline.savedObjectId, + }); + }); + + test('returns 200 when import timeline successfully', async () => { + const response = await server.inject(getImportTimelinesRequest(), context); + expect(response.status).toEqual(200); + }); + + test('should assign a templateTimeline Id automatically if not given one', async () => { + mockGetTupleDuplicateErrorsAndUniqueTimeline.mockReturnValue([ + mockDuplicateIdErrors, + [ + { + ...mockUniqueParsedTemplateTimelineObjects[0], + templateTimelineId: null, + }, + ], + ]); + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistTimeline.mock.calls[0][3].templateTimelineId).toEqual( + mockNewTemplateTimelineId + ); + }); + }); + + describe('Import a template timeline already exist', () => { + beforeEach(() => { + jest.doMock('../saved_object', () => { + return { + getTimeline: mockGetTimeline.mockReturnValue(mockGetTemplateTimelineValue), + getTimelineByTemplateTimelineId: mockGetTemplateTimeline.mockReturnValue({ + timeline: [mockGetTemplateTimelineValue], + }), + persistTimeline: mockPersistTimeline.mockReturnValue({ + timeline: mockCreatedTemplateTimeline, + }), + }; + }); + + jest.doMock('../../pinned_event/saved_object', () => { + return { + persistPinnedEventOnTimeline: mockPersistPinnedEventOnTimeline, + }; + }); + + jest.doMock('../../note/saved_object', () => { + return { + persistNote: mockPersistNote, + }; + }); + + const importTimelinesRoute = jest.requireActual('./import_timelines_route') + .importTimelinesRoute; + importTimelinesRoute(server.router, createMockConfig(), securitySetup); + }); + + test('should use given timelineId to check if the timeline savedObject already exist', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockGetTimeline.mock.calls[0][1]).toEqual( + mockUniqueParsedTemplateTimelineObjects[0].savedObjectId + ); + }); + + test('should use given templateTimelineId to check if the timeline savedObject already exist', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockGetTemplateTimeline.mock.calls[0][1]).toEqual( + mockUniqueParsedTemplateTimelineObjects[0].templateTimelineId + ); + }); + + test('should UPDATE timeline savedObject', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistTimeline).toHaveBeenCalled(); + }); + + test('should UPDATE timeline savedObject with timelineId', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistTimeline.mock.calls[0][1]).toEqual( + mockUniqueParsedTemplateTimelineObjects[0].savedObjectId + ); + }); + + test('should UPDATE timeline savedObject without timeline version', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistTimeline.mock.calls[0][2]).toEqual( + mockUniqueParsedTemplateTimelineObjects[0].version + ); + }); + + test('should UPDATE a new timeline savedObject witn given timeline and skip the omitted fields', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistTimeline.mock.calls[0][3]).toEqual(mockParsedTemplateTimelineObject); + }); + + test('should NOT Create new pinned events', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistPinnedEventOnTimeline).not.toHaveBeenCalled(); + }); + + test('should provide noteSavedObjectId when Creating notes for a timeline', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistNote.mock.calls[0][1]).toBeNull(); + }); + + test('should provide new timeline version when Creating notes for a timeline', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistNote.mock.calls[0][2]).toEqual(mockCreatedTemplateTimeline.version); + }); + + test('should exclude event notes when creating notes', async () => { + const mockRequest = getImportTimelinesRequest(); + await server.inject(mockRequest, context); + expect(mockPersistNote.mock.calls[0][3]).toEqual({ + eventId: undefined, + note: mockUniqueParsedTemplateTimelineObjects[0].globalNotes[0].note, + timelineId: mockCreatedTemplateTimeline.savedObjectId, + }); + }); + + test('returns 200 when import timeline successfully', async () => { + const response = await server.inject(getImportTimelinesRequest(), context); + expect(response.status).toEqual(200); + }); + + test('should throw error if with given template timeline version conflict', async () => { + mockGetTupleDuplicateErrorsAndUniqueTimeline.mockReturnValue([ + mockDuplicateIdErrors, + [ + { + ...mockUniqueParsedTemplateTimelineObjects[0], + templateTimelineVersion: 1, + }, + ], + ]); + const mockRequest = getImportTimelinesRequest(); + const response = await server.inject(mockRequest, context); + expect(response.body).toEqual({ + success: false, + success_count: 0, + errors: [ + { + id: '79deb4c0-6bc1-11ea-a90b-f5341fb7a189', + error: { + status_code: 409, + message: TEMPLATE_TIMELINE_VERSION_CONFLICT_MESSAGE, + }, + }, + ], + }); + }); + + test('should throw error if status updated', async () => { + mockGetTupleDuplicateErrorsAndUniqueTimeline.mockReturnValue([ + mockDuplicateIdErrors, + [ + { + ...mockUniqueParsedTemplateTimelineObjects[0], + status: TimelineStatus.immutable, + }, + ], + ]); + const mockRequest = getImportTimelinesRequest(); + const response = await server.inject(mockRequest, context); + expect(response.body).toEqual({ + success: false, + success_count: 0, + errors: [ + { + id: '79deb4c0-6bc1-11ea-a90b-f5341fb7a189', + error: { + status_code: 409, + message: NOT_ALLOW_UPDATE_STATUS_ERROR_MESSAGE, }, }, ], diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/import_timelines_route.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/import_timelines_route.ts index 5080142f22b1..fb4991d7d1e7 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/import_timelines_route.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/import_timelines_route.ts @@ -7,17 +7,17 @@ import { extname } from 'path'; import { chunk, omit } from 'lodash/fp'; -import { validate } from '../../../../common/validate'; -import { importRulesSchema } from '../../../../common/detection_engine/schemas/response/import_rules_schema'; +import uuid from 'uuid'; import { createPromiseFromStreams } from '../../../../../../../src/legacy/utils'; import { IRouter } from '../../../../../../../src/core/server'; import { TIMELINE_IMPORT_URL } from '../../../../common/constants'; +import { validate } from '../../../../common/validate'; import { SetupPlugins } from '../../../plugin'; import { ConfigType } from '../../../config'; import { buildRouteValidation } from '../../../utils/build_validation/route_validation'; - +import { importRulesSchema } from '../../../../common/detection_engine/schemas/response/import_rules_schema'; import { buildSiemResponse, createBulkErrorObject, @@ -28,7 +28,11 @@ import { import { createTimelinesStreamFromNdJson } from '../create_timelines_stream_from_ndjson'; import { ImportTimelinesPayloadSchemaRt } from './schemas/import_timelines_schema'; -import { buildFrameworkRequest } from './utils/common'; +import { + buildFrameworkRequest, + CompareTimelinesStatus, + TimelineStatusActions, +} from './utils/common'; import { getTupleDuplicateErrorsAndUniqueTimeline, isBulkError, @@ -38,11 +42,11 @@ import { PromiseFromStreams, timelineSavedObjectOmittedFields, } from './utils/import_timelines'; -import { createTimelines, getTimeline, getTemplateTimeline } from './utils/create_timelines'; -import { TimelineType, TimelineStatus } from '../../../../common/types/timeline'; -import { checkIsFailureCases } from './utils/update_timelines'; +import { createTimelines } from './utils/create_timelines'; +import { TimelineStatus } from '../../../../common/types/timeline'; const CHUNK_PARSED_OBJECT_SIZE = 10; +const DEFAULT_IMPORT_ERROR = `Something went wrong, there's something we didn't handle properly, please help us improve by providing the file you try to import on https://discuss.elastic.co/c/security/siem`; export const importTimelinesRoute = ( router: IRouter, @@ -118,100 +122,112 @@ export const importTimelinesRoute = ( return null; } + const { - savedObjectId = null, + savedObjectId, pinnedEventIds, globalNotes, eventNotes, + status, templateTimelineId, templateTimelineVersion, + title, timelineType, - version = null, + version, } = parsedTimeline; const parsedTimelineObject = omit( timelineSavedObjectOmittedFields, parsedTimeline ); - let newTimeline = null; try { - const templateTimeline = - templateTimelineId != null - ? await getTemplateTimeline(frameworkRequest, templateTimelineId) - : null; - - const timeline = - savedObjectId != null && - (await getTimeline(frameworkRequest, savedObjectId)); - const isHandlingTemplateTimeline = timelineType === TimelineType.template; - - if ( - (timeline == null && !isHandlingTemplateTimeline) || - (timeline == null && templateTimeline == null && isHandlingTemplateTimeline) - ) { + const compareTimelinesStatus = new CompareTimelinesStatus({ + status, + timelineType, + title, + timelineInput: { + id: savedObjectId, + version, + }, + templateTimelineInput: { + id: templateTimelineId, + version: templateTimelineVersion, + }, + frameworkRequest, + }); + await compareTimelinesStatus.init(); + const isTemplateTimeline = compareTimelinesStatus.isHandlingTemplateTimeline; + if (compareTimelinesStatus.isCreatableViaImport) { // create timeline / template timeline - newTimeline = await createTimelines( + newTimeline = await createTimelines({ frameworkRequest, - { + timeline: { ...parsedTimelineObject, status: - parsedTimelineObject.status === TimelineStatus.draft + status === TimelineStatus.draft ? TimelineStatus.active - : parsedTimelineObject.status, + : status ?? TimelineStatus.active, + templateTimelineVersion: isTemplateTimeline + ? templateTimelineVersion + : null, + templateTimelineId: isTemplateTimeline + ? templateTimelineId ?? uuid.v4() + : null, }, - null, // timelineSavedObjectId - null, // timelineVersion - pinnedEventIds, - isHandlingTemplateTimeline - ? globalNotes - : [...globalNotes, ...eventNotes], - [] // existing note ids - ); + pinnedEventIds: isTemplateTimeline ? null : pinnedEventIds, + notes: isTemplateTimeline ? globalNotes : [...globalNotes, ...eventNotes], + }); resolve({ timeline_id: newTimeline.timeline.savedObjectId, status_code: 200, }); - } else if ( - timeline && - timeline != null && - templateTimeline != null && - isHandlingTemplateTimeline - ) { - // update template timeline - const errorObj = checkIsFailureCases( - isHandlingTemplateTimeline, - version, - templateTimelineVersion ?? null, - timeline, - templateTimeline - ); - if (errorObj != null) { - return siemResponse.error(errorObj); - } + } - newTimeline = await createTimelines( - frameworkRequest, - { ...parsedTimelineObject, templateTimelineId, templateTimelineVersion }, - timeline.savedObjectId, // timelineSavedObjectId - timeline.version, // timelineVersion - pinnedEventIds, - globalNotes, - [] // existing note ids + if (!compareTimelinesStatus.isHandlingTemplateTimeline) { + const errorMessage = compareTimelinesStatus.checkIsFailureCases( + TimelineStatusActions.createViaImport ); + const message = errorMessage?.body ?? DEFAULT_IMPORT_ERROR; - resolve({ - timeline_id: newTimeline.timeline.savedObjectId, - status_code: 200, - }); - } else { resolve( createBulkErrorObject({ id: savedObjectId ?? 'unknown', statusCode: 409, - message: `timeline_id: "${savedObjectId}" already exists`, + message, }) ); + } else { + if (compareTimelinesStatus.isUpdatableViaImport) { + // update template timeline + newTimeline = await createTimelines({ + frameworkRequest, + timeline: parsedTimelineObject, + timelineSavedObjectId: compareTimelinesStatus.timelineId, + timelineVersion: compareTimelinesStatus.timelineVersion, + notes: globalNotes, + existingNoteIds: compareTimelinesStatus.timelineInput.data?.noteIds, + }); + + resolve({ + timeline_id: newTimeline.timeline.savedObjectId, + status_code: 200, + }); + } else { + const errorMessage = compareTimelinesStatus.checkIsFailureCases( + TimelineStatusActions.updateViaImport + ); + + const message = errorMessage?.body ?? DEFAULT_IMPORT_ERROR; + + resolve( + createBulkErrorObject({ + id: savedObjectId ?? 'unknown', + statusCode: 409, + message, + }) + ); + } } } catch (err) { resolve( @@ -236,9 +252,9 @@ export const importTimelinesRoute = ( ]; } - const errorsResp = importTimelineResponse.filter((resp) => - isBulkError(resp) - ) as BulkError[]; + const errorsResp = importTimelineResponse.filter((resp) => { + return isBulkError(resp); + }) as BulkError[]; const successes = importTimelineResponse.filter((resp) => { if (isImportRegular(resp)) { return resp.status_code === 200; @@ -261,7 +277,6 @@ export const importTimelinesRoute = ( } catch (err) { const error = transformError(err); const siemResponse = buildSiemResponse(response); - return siemResponse.error({ body: error.message, statusCode: error.statusCode, diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/update_timelines_route.test.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/update_timelines_route.test.ts index 2a3feb7afd59..3cedb925649a 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/update_timelines_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/update_timelines_route.test.ts @@ -26,7 +26,7 @@ import { import { UPDATE_TIMELINE_ERROR_MESSAGE, UPDATE_TEMPLATE_TIMELINE_ERROR_MESSAGE, -} from './utils/update_timelines'; +} from './utils/failure_cases'; describe('update timelines', () => { let server: ReturnType; @@ -93,7 +93,7 @@ describe('update timelines', () => { await server.inject(mockRequest, context); }); - test('should Check a if given timeline id exist', async () => { + test('should Check if given timeline id exist', async () => { expect(mockGetTimeline.mock.calls[0][1]).toEqual(updateTimelineWithTimelineId.timelineId); }); @@ -178,7 +178,7 @@ describe('update timelines', () => { timeline: [mockGetTemplateTimelineValue], }), persistTimeline: mockPersistTimeline.mockReturnValue({ - timeline: updateTimelineWithTimelineId.timeline, + timeline: updateTemplateTimelineWithTimelineId.timeline, }), }; }); @@ -211,7 +211,7 @@ describe('update timelines', () => { test('should Update existing template timeline with template timelineId', async () => { expect(mockGetTemplateTimeline.mock.calls[0][1]).toEqual( - updateTemplateTimelineWithTimelineId.timelineId + updateTemplateTimelineWithTimelineId.timeline.templateTimelineId ); }); diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/update_timelines_route.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/update_timelines_route.ts index d5ecd408a6ef..f59df151b695 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/update_timelines_route.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/update_timelines_route.ts @@ -7,19 +7,17 @@ import { IRouter } from '../../../../../../../src/core/server'; import { TIMELINE_URL } from '../../../../common/constants'; -import { TimelineType } from '../../../../common/types/timeline'; import { SetupPlugins } from '../../../plugin'; import { buildRouteValidation } from '../../../utils/build_validation/route_validation'; import { ConfigType } from '../../..'; import { transformError, buildSiemResponse } from '../../detection_engine/routes/utils'; -import { FrameworkRequest } from '../../framework'; import { updateTimelineSchema } from './schemas/update_timelines_schema'; -import { buildFrameworkRequest } from './utils/common'; -import { createTimelines, getTimeline, getTemplateTimeline } from './utils/create_timelines'; -import { checkIsFailureCases } from './utils/update_timelines'; +import { buildFrameworkRequest, TimelineStatusActions } from './utils/common'; +import { createTimelines } from './utils/create_timelines'; +import { CompareTimelinesStatus } from './utils/compare_timelines_status'; export const updateTimelinesRoute = ( router: IRouter, @@ -33,7 +31,7 @@ export const updateTimelinesRoute = ( body: buildRouteValidation(updateTimelineSchema), }, options: { - tags: ['access:securitySolution'], + tags: ['access:siem'], }, }, // eslint-disable-next-line complexity @@ -43,39 +41,54 @@ export const updateTimelinesRoute = ( try { const frameworkRequest = await buildFrameworkRequest(context, security, request); const { timelineId, timeline, version } = request.body; - const { templateTimelineId, templateTimelineVersion, timelineType } = timeline; - const isHandlingTemplateTimeline = timelineType === TimelineType.template; - const existTimeline = - timelineId != null ? await getTimeline(frameworkRequest, timelineId) : null; + const { + templateTimelineId, + templateTimelineVersion, + timelineType, + title, + status, + } = timeline; - const existTemplateTimeline = - templateTimelineId != null - ? await getTemplateTimeline(frameworkRequest, templateTimelineId) - : null; - - const errorObj = checkIsFailureCases( - isHandlingTemplateTimeline, - version, - templateTimelineVersion ?? null, - existTimeline, - existTemplateTimeline - ); - if (errorObj != null) { - return siemResponse.error(errorObj); - } - const updatedTimeline = await createTimelines( - (frameworkRequest as unknown) as FrameworkRequest, - timeline, - timelineId, - version - ); - return response.ok({ - body: { - data: { - persistTimeline: updatedTimeline, - }, + const compareTimelinesStatus = new CompareTimelinesStatus({ + status, + title, + timelineType, + timelineInput: { + id: timelineId, + version, }, + templateTimelineInput: { + id: templateTimelineId, + version: templateTimelineVersion, + }, + frameworkRequest, }); + + await compareTimelinesStatus.init(); + if (compareTimelinesStatus.isUpdatable) { + const updatedTimeline = await createTimelines({ + frameworkRequest, + timeline, + timelineSavedObjectId: timelineId, + timelineVersion: version, + }); + + return response.ok({ + body: { + data: { + persistTimeline: updatedTimeline, + }, + }, + }); + } else { + const error = compareTimelinesStatus.checkIsFailureCases(TimelineStatusActions.update); + return siemResponse.error( + error || { + statusCode: 405, + body: 'update timeline error', + } + ); + } } catch (err) { const error = transformError(err); return siemResponse.error({ diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/common.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/common.ts index adbfdbf6d605..2c2d651fd483 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/common.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/common.ts @@ -5,9 +5,10 @@ */ import { set } from 'lodash/fp'; -import { RequestHandlerContext } from 'src/core/server'; +import { KibanaRequest, RequestHandlerContext } from 'src/core/server'; + import { SetupPlugins } from '../../../../plugin'; -import { KibanaRequest } from '../../../../../../../../src/core/server'; + import { FrameworkRequest } from '../../../framework'; export const buildFrameworkRequest = async ( @@ -28,3 +29,19 @@ export const buildFrameworkRequest = async ( ) ); }; + +export enum TimelineStatusActions { + create = 'create', + createViaImport = 'createViaImport', + update = 'update', + updateViaImport = 'updateViaImport', +} + +export type TimelineStatusAction = + | TimelineStatusActions.create + | TimelineStatusActions.createViaImport + | TimelineStatusActions.update + | TimelineStatusActions.updateViaImport; + +export * from './compare_timelines_status'; +export * from './timeline_object'; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/compare_timelines_status.test.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/compare_timelines_status.test.ts new file mode 100644 index 000000000000..a6d379e534bc --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/compare_timelines_status.test.ts @@ -0,0 +1,810 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { TimelineType, TimelineStatus } from '../../../../../common/types/timeline'; +import { FrameworkRequest } from '../../../framework'; + +import { + mockUniqueParsedObjects, + mockUniqueParsedTemplateTimelineObjects, + mockGetTemplateTimelineValue, + mockGetTimelineValue, +} from '../__mocks__/import_timelines'; + +import { CompareTimelinesStatus as TimelinesStatusType } from './compare_timelines_status'; +import { + EMPTY_TITLE_ERROR_MESSAGE, + UPDATE_STATUS_ERROR_MESSAGE, + UPDATE_TEMPLATE_TIMELINE_ERROR_MESSAGE, + TEMPLATE_TIMELINE_VERSION_CONFLICT_MESSAGE, + CREATE_TEMPLATE_TIMELINE_ERROR_MESSAGE, + getImportExistingTimelineError, +} from './failure_cases'; +import { TimelineStatusActions } from './common'; + +describe('CompareTimelinesStatus', () => { + describe('timeline', () => { + describe('given timeline exists', () => { + const mockGetTimeline: jest.Mock = jest.fn(); + const mockGetTemplateTimeline: jest.Mock = jest.fn(); + let timelineObj: TimelinesStatusType; + + afterEach(() => { + jest.clearAllMocks(); + }); + + afterAll(() => { + jest.resetModules(); + }); + + beforeAll(() => { + jest.resetModules(); + }); + + beforeEach(async () => { + jest.doMock('../../saved_object', () => { + return { + getTimeline: mockGetTimeline.mockReturnValue(mockGetTimelineValue), + getTimelineByTemplateTimelineId: mockGetTemplateTimeline.mockReturnValue({ + timeline: [], + }), + }; + }); + + const CompareTimelinesStatus = jest.requireActual('./compare_timelines_status') + .CompareTimelinesStatus; + + timelineObj = new CompareTimelinesStatus({ + timelineInput: { + id: mockUniqueParsedObjects[0].savedObjectId, + version: mockUniqueParsedObjects[0].version, + }, + timelineType: TimelineType.default, + title: mockUniqueParsedObjects[0].title, + templateTimelineInput: { + id: mockUniqueParsedTemplateTimelineObjects[0].templateTimelineId, + version: mockUniqueParsedTemplateTimelineObjects[0].templateTimelineVersion, + }, + frameworkRequest: {} as FrameworkRequest, + }); + + await timelineObj.init(); + }); + + test('should get timeline', () => { + expect(mockGetTimeline).toHaveBeenCalled(); + }); + + test('should get templateTimeline', () => { + expect(mockGetTemplateTimeline).toHaveBeenCalled(); + }); + + test('should not creatable', () => { + expect(timelineObj.isCreatable).toEqual(false); + }); + + test('should not CreatableViaImport', () => { + expect(timelineObj.isCreatableViaImport).toEqual(false); + }); + + test('should be Updatable', () => { + expect(timelineObj.isUpdatable).toEqual(true); + }); + + test('should not be UpdatableViaImport', () => { + expect(timelineObj.isUpdatableViaImport).toEqual(false); + }); + + test('should indicate we are handling a timeline', () => { + expect(timelineObj.isHandlingTemplateTimeline).toEqual(false); + }); + }); + + describe('given timeline does NOT exists', () => { + const mockGetTimeline: jest.Mock = jest.fn(); + const mockGetTemplateTimeline: jest.Mock = jest.fn(); + let timelineObj: TimelinesStatusType; + + afterEach(() => { + jest.clearAllMocks(); + }); + + afterAll(() => { + jest.resetModules(); + }); + + beforeAll(() => { + jest.resetModules(); + }); + + beforeEach(async () => { + jest.doMock('../../saved_object', () => { + return { + getTimeline: mockGetTimeline.mockReturnValue(null), + getTimelineByTemplateTimelineId: mockGetTemplateTimeline.mockReturnValue({ + timeline: [], + }), + }; + }); + + const CompareTimelinesStatus = jest.requireActual('./compare_timelines_status') + .CompareTimelinesStatus; + + timelineObj = new CompareTimelinesStatus({ + timelineInput: { + id: mockUniqueParsedObjects[0].savedObjectId, + version: mockUniqueParsedObjects[0].version, + }, + timelineType: TimelineType.default, + title: mockUniqueParsedObjects[0].title, + templateTimelineInput: { + id: mockUniqueParsedTemplateTimelineObjects[0].templateTimelineId, + version: mockUniqueParsedTemplateTimelineObjects[0].templateTimelineVersion, + }, + frameworkRequest: {} as FrameworkRequest, + }); + + await timelineObj.init(); + }); + + test('should get timeline', () => { + expect(mockGetTimeline).toHaveBeenCalled(); + }); + + test('should get templateTimeline', () => { + expect(mockGetTemplateTimeline).toHaveBeenCalled(); + }); + + test('should be creatable', () => { + expect(timelineObj.isCreatable).toEqual(true); + }); + + test('should be CreatableViaImport', () => { + expect(timelineObj.isCreatableViaImport).toEqual(true); + }); + + test('should be Updatable', () => { + expect(timelineObj.isUpdatable).toEqual(false); + }); + + test('should not be UpdatableViaImport', () => { + expect(timelineObj.isUpdatableViaImport).toEqual(false); + }); + + test('should indicate we are handling a timeline', () => { + expect(timelineObj.isHandlingTemplateTimeline).toEqual(false); + }); + }); + }); + + describe('template timeline', () => { + describe('given template timeline exists', () => { + const mockGetTimeline: jest.Mock = jest.fn(); + const mockGetTemplateTimeline: jest.Mock = jest.fn(); + + let timelineObj: TimelinesStatusType; + + beforeEach(async () => { + jest.doMock('../../saved_object', () => ({ + getTimeline: mockGetTimeline.mockReturnValue(mockGetTemplateTimelineValue), + getTimelineByTemplateTimelineId: mockGetTemplateTimeline.mockReturnValue({ + timeline: [mockGetTemplateTimelineValue], + }), + })); + + const CompareTimelinesStatus = jest.requireActual('./compare_timelines_status') + .CompareTimelinesStatus; + + timelineObj = new CompareTimelinesStatus({ + timelineInput: { + id: mockUniqueParsedObjects[0].savedObjectId, + version: mockUniqueParsedObjects[0].version, + }, + timelineType: TimelineType.template, + title: mockUniqueParsedObjects[0].title, + templateTimelineInput: { + id: mockUniqueParsedTemplateTimelineObjects[0].templateTimelineId, + version: mockUniqueParsedTemplateTimelineObjects[0].templateTimelineVersion, + }, + frameworkRequest: {} as FrameworkRequest, + }); + await timelineObj.init(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + afterAll(() => { + jest.resetModules(); + }); + + beforeAll(() => { + jest.resetModules(); + }); + + test('should get timeline', () => { + expect(mockGetTimeline).toHaveBeenCalled(); + }); + + test('should get templateTimeline', () => { + expect(mockGetTemplateTimeline).toHaveBeenCalled(); + }); + + test('should not creatable', () => { + expect(timelineObj.isCreatable).toEqual(false); + }); + + test('should not CreatableViaImport', () => { + expect(timelineObj.isCreatableViaImport).toEqual(false); + }); + + test('should be Updatable', () => { + expect(timelineObj.isUpdatable).toEqual(true); + }); + + test('should be UpdatableViaImport', () => { + expect(timelineObj.isUpdatableViaImport).toEqual(true); + }); + + test('should indicate we are handling a template timeline', () => { + expect(timelineObj.isHandlingTemplateTimeline).toEqual(true); + }); + }); + + describe('given template timeline does NOT exists', () => { + const mockGetTimeline: jest.Mock = jest.fn(); + const mockGetTemplateTimeline: jest.Mock = jest.fn(); + + let timelineObj: TimelinesStatusType; + + afterEach(() => { + jest.clearAllMocks(); + }); + + afterAll(() => { + jest.resetModules(); + }); + + beforeAll(() => { + jest.resetModules(); + }); + + beforeEach(async () => { + jest.doMock('../../saved_object', () => ({ + getTimeline: mockGetTimeline, + getTimelineByTemplateTimelineId: mockGetTemplateTimeline, + })); + + const CompareTimelinesStatus = jest.requireActual('./compare_timelines_status') + .CompareTimelinesStatus; + + timelineObj = new CompareTimelinesStatus({ + timelineInput: { + id: mockUniqueParsedObjects[0].savedObjectId, + version: mockUniqueParsedObjects[0].version, + }, + timelineType: TimelineType.template, + title: mockUniqueParsedObjects[0].title, + templateTimelineInput: { + id: mockUniqueParsedTemplateTimelineObjects[0].templateTimelineId, + version: mockUniqueParsedTemplateTimelineObjects[0].templateTimelineVersion, + }, + frameworkRequest: {} as FrameworkRequest, + }); + await timelineObj.init(); + }); + + test('should get timeline', () => { + expect(mockGetTimeline).toHaveBeenCalled(); + }); + + test('should get templateTimeline', () => { + expect(mockGetTemplateTimeline).toHaveBeenCalled(); + }); + + test('should be creatable', () => { + expect(timelineObj.isCreatable).toEqual(true); + }); + + test('should throw no error on creatable', () => { + expect(timelineObj.checkIsFailureCases(TimelineStatusActions.create)).toBeNull(); + }); + + test('should be CreatableViaImport', () => { + expect(timelineObj.isCreatableViaImport).toEqual(true); + }); + + test('should throw no error on CreatableViaImport', () => { + expect(timelineObj.checkIsFailureCases(TimelineStatusActions.createViaImport)).toBeNull(); + }); + + test('should not be Updatable', () => { + expect(timelineObj.isUpdatable).toEqual(false); + }); + + test('should throw error when updat', () => { + const error = timelineObj.checkIsFailureCases(TimelineStatusActions.update); + expect(error?.body).toEqual(UPDATE_TEMPLATE_TIMELINE_ERROR_MESSAGE); + }); + + test('should not be UpdatableViaImport', () => { + expect(timelineObj.isUpdatableViaImport).toEqual(false); + }); + + test('should throw error when UpdatableViaImport', () => { + const error = timelineObj.checkIsFailureCases(TimelineStatusActions.updateViaImport); + expect(error?.body).toEqual(UPDATE_TEMPLATE_TIMELINE_ERROR_MESSAGE); + }); + + test('should indicate we are handling a template timeline', () => { + expect(timelineObj.isHandlingTemplateTimeline).toEqual(true); + }); + }); + }); + + describe(`Throw error if given title does NOT exists`, () => { + describe('timeline', () => { + const mockGetTimeline: jest.Mock = jest.fn(); + const mockGetTemplateTimeline: jest.Mock = jest.fn(); + let timelineObj: TimelinesStatusType; + + afterEach(() => { + jest.clearAllMocks(); + }); + + afterAll(() => { + jest.resetModules(); + }); + + beforeAll(() => { + jest.resetModules(); + }); + + beforeEach(async () => { + jest.doMock('../../saved_object', () => { + return { + getTimeline: mockGetTimeline.mockReturnValue(null), + getTimelineByTemplateTimelineId: mockGetTemplateTimeline.mockReturnValue({ + timeline: [], + }), + }; + }); + + const CompareTimelinesStatus = jest.requireActual('./compare_timelines_status') + .CompareTimelinesStatus; + + timelineObj = new CompareTimelinesStatus({ + timelineInput: { + id: mockUniqueParsedObjects[0].savedObjectId, + type: TimelineType.default, + version: mockUniqueParsedObjects[0].version, + }, + timelineType: TimelineType.default, + title: null, + templateTimelineInput: { + id: mockUniqueParsedTemplateTimelineObjects[0].templateTimelineId, + type: TimelineType.template, + version: mockUniqueParsedTemplateTimelineObjects[0].templateTimelineVersion, + }, + frameworkRequest: {} as FrameworkRequest, + }); + + await timelineObj.init(); + }); + + test(`should not be creatable`, () => { + expect(timelineObj.isCreatable).toEqual(false); + }); + + test(`throw error on create`, () => { + const error = timelineObj.checkIsFailureCases(TimelineStatusActions.create); + expect(error?.body).toEqual(EMPTY_TITLE_ERROR_MESSAGE); + }); + + test(`should not be creatable via import`, () => { + expect(timelineObj.isCreatableViaImport).toEqual(false); + }); + + test(`throw error when create via import`, () => { + const error = timelineObj.checkIsFailureCases(TimelineStatusActions.createViaImport); + expect(error?.body).toEqual(EMPTY_TITLE_ERROR_MESSAGE); + }); + + test(`should not be updatable`, () => { + expect(timelineObj.isUpdatable).toEqual(false); + }); + + test(`throw error when update`, () => { + const error = timelineObj.checkIsFailureCases(TimelineStatusActions.update); + expect(error?.body).toEqual(EMPTY_TITLE_ERROR_MESSAGE); + }); + + test(`should not be updatable via import`, () => { + expect(timelineObj.isUpdatableViaImport).toEqual(false); + }); + }); + + describe('template timeline', () => { + const mockGetTimeline: jest.Mock = jest.fn(); + const mockGetTemplateTimeline: jest.Mock = jest.fn(); + let timelineObj: TimelinesStatusType; + + afterEach(() => { + jest.clearAllMocks(); + }); + + afterAll(() => { + jest.resetModules(); + }); + + beforeAll(() => { + jest.resetModules(); + }); + + beforeEach(async () => { + jest.doMock('../../saved_object', () => { + return { + getTimeline: mockGetTimeline.mockReturnValue(null), + getTimelineByTemplateTimelineId: mockGetTemplateTimeline.mockReturnValue({ + timeline: [], + }), + }; + }); + + const CompareTimelinesStatus = jest.requireActual('./compare_timelines_status') + .CompareTimelinesStatus; + + timelineObj = new CompareTimelinesStatus({ + timelineInput: { + id: mockUniqueParsedObjects[0].savedObjectId, + type: TimelineType.default, + version: mockUniqueParsedObjects[0].version, + }, + timelineType: TimelineType.default, + title: null, + templateTimelineInput: { + id: mockUniqueParsedTemplateTimelineObjects[0].templateTimelineId, + type: TimelineType.template, + version: mockUniqueParsedTemplateTimelineObjects[0].templateTimelineVersion, + }, + frameworkRequest: {} as FrameworkRequest, + }); + + await timelineObj.init(); + }); + + test(`should not be creatable`, () => { + expect(timelineObj.isCreatable).toEqual(false); + }); + + test(`throw error on create`, () => { + const error = timelineObj.checkIsFailureCases(TimelineStatusActions.create); + expect(error?.body).toEqual(EMPTY_TITLE_ERROR_MESSAGE); + }); + + test(`should not be creatable via import`, () => { + expect(timelineObj.isCreatableViaImport).toEqual(false); + }); + + test(`throw error when create via import`, () => { + const error = timelineObj.checkIsFailureCases(TimelineStatusActions.createViaImport); + expect(error?.body).toEqual(EMPTY_TITLE_ERROR_MESSAGE); + }); + + test(`should not be updatable`, () => { + expect(timelineObj.isUpdatable).toEqual(false); + }); + + test(`throw error when update`, () => { + const error = timelineObj.checkIsFailureCases(TimelineStatusActions.update); + expect(error?.body).toEqual(EMPTY_TITLE_ERROR_MESSAGE); + }); + + test(`should not be updatable via import`, () => { + expect(timelineObj.isUpdatableViaImport).toEqual(false); + }); + + test(`throw error when update via import`, () => { + const error = timelineObj.checkIsFailureCases(TimelineStatusActions.updateViaImport); + expect(error?.body).toEqual(EMPTY_TITLE_ERROR_MESSAGE); + }); + }); + }); + + describe(`Throw error if timeline status is updated`, () => { + describe('immutable timeline', () => { + const mockGetTimeline: jest.Mock = jest.fn(); + const mockGetTemplateTimeline: jest.Mock = jest.fn(); + let timelineObj: TimelinesStatusType; + + afterEach(() => { + jest.clearAllMocks(); + }); + + afterAll(() => { + jest.resetModules(); + }); + + beforeAll(() => { + jest.resetModules(); + }); + + beforeEach(async () => { + jest.doMock('../../saved_object', () => { + return { + getTimeline: mockGetTimeline.mockReturnValue({ + ...mockGetTimelineValue, + status: TimelineStatus.immutable, + }), + getTimelineByTemplateTimelineId: mockGetTemplateTimeline.mockReturnValue({ + timeline: [], + }), + }; + }); + + const CompareTimelinesStatus = jest.requireActual('./compare_timelines_status') + .CompareTimelinesStatus; + + timelineObj = new CompareTimelinesStatus({ + timelineInput: { + id: mockUniqueParsedObjects[0].savedObjectId, + type: TimelineType.default, + version: mockUniqueParsedObjects[0].version, + }, + timelineType: TimelineType.default, + title: 'mock title', + status: TimelineStatus.immutable, + templateTimelineInput: { + id: mockUniqueParsedTemplateTimelineObjects[0].templateTimelineId, + type: TimelineType.template, + version: mockUniqueParsedTemplateTimelineObjects[0].templateTimelineVersion, + }, + frameworkRequest: {} as FrameworkRequest, + }); + + await timelineObj.init(); + }); + + test(`should not be updatable if existing status is immutable`, () => { + expect(timelineObj.isUpdatable).toBe(false); + }); + + test(`should throw error when update`, () => { + const error = timelineObj.checkIsFailureCases(TimelineStatusActions.update); + expect(error?.body).toEqual(UPDATE_STATUS_ERROR_MESSAGE); + }); + + test(`should not be updatable via import if existing status is immutable`, () => { + expect(timelineObj.isUpdatableViaImport).toBe(false); + }); + + test(`should throw error when update via import`, () => { + const error = timelineObj.checkIsFailureCases(TimelineStatusActions.updateViaImport); + expect(error?.body).toEqual( + getImportExistingTimelineError(mockUniqueParsedObjects[0].savedObjectId) + ); + }); + }); + + describe('immutable template timeline', () => { + const mockGetTimeline: jest.Mock = jest.fn(); + const mockGetTemplateTimeline: jest.Mock = jest.fn(); + let timelineObj: TimelinesStatusType; + + afterEach(() => { + jest.clearAllMocks(); + }); + + afterAll(() => { + jest.resetModules(); + }); + + beforeAll(() => { + jest.resetModules(); + }); + + beforeEach(async () => { + jest.doMock('../../saved_object', () => { + return { + getTimeline: mockGetTimeline.mockReturnValue({ + ...mockGetTemplateTimelineValue, + status: TimelineStatus.immutable, + }), + getTimelineByTemplateTimelineId: mockGetTemplateTimeline.mockReturnValue({ + timeline: [{ ...mockGetTemplateTimelineValue, status: TimelineStatus.immutable }], + }), + }; + }); + + const CompareTimelinesStatus = jest.requireActual('./compare_timelines_status') + .CompareTimelinesStatus; + + timelineObj = new CompareTimelinesStatus({ + timelineInput: { + id: mockUniqueParsedObjects[0].savedObjectId, + type: TimelineType.default, + version: mockUniqueParsedObjects[0].version, + }, + status: TimelineStatus.immutable, + timelineType: TimelineType.template, + title: 'mock title', + templateTimelineInput: { + id: mockUniqueParsedTemplateTimelineObjects[0].templateTimelineId, + type: TimelineType.template, + version: mockUniqueParsedTemplateTimelineObjects[0].templateTimelineVersion, + }, + frameworkRequest: {} as FrameworkRequest, + }); + + await timelineObj.init(); + }); + + test(`should not be able to update`, () => { + expect(timelineObj.isUpdatable).toEqual(false); + }); + + test(`should not throw error when update`, () => { + const error = timelineObj.checkIsFailureCases(TimelineStatusActions.update); + expect(error?.body).toEqual(UPDATE_STATUS_ERROR_MESSAGE); + }); + + test(`should not be able to update via import`, () => { + expect(timelineObj.isUpdatableViaImport).toEqual(true); + }); + + test(`should not throw error when update via import`, () => { + const error = timelineObj.checkIsFailureCases(TimelineStatusActions.updateViaImport); + expect(error?.body).toBeUndefined(); + }); + }); + }); + + describe('If create template timeline without template timeline id', () => { + const mockGetTimeline: jest.Mock = jest.fn(); + const mockGetTemplateTimeline: jest.Mock = jest.fn(); + + let timelineObj: TimelinesStatusType; + + afterEach(() => { + jest.clearAllMocks(); + }); + + afterAll(() => { + jest.resetModules(); + }); + + beforeAll(() => { + jest.resetModules(); + }); + + beforeEach(async () => { + jest.doMock('../../saved_object', () => ({ + getTimeline: mockGetTimeline, + getTimelineByTemplateTimelineId: mockGetTemplateTimeline, + })); + + const CompareTimelinesStatus = jest.requireActual('./compare_timelines_status') + .CompareTimelinesStatus; + + timelineObj = new CompareTimelinesStatus({ + timelineInput: { + id: mockUniqueParsedObjects[0].savedObjectId, + version: mockUniqueParsedObjects[0].version, + }, + timelineType: TimelineType.template, + title: mockUniqueParsedObjects[0].title, + templateTimelineInput: { + id: null, + version: mockUniqueParsedTemplateTimelineObjects[0].templateTimelineVersion, + }, + frameworkRequest: {} as FrameworkRequest, + }); + await timelineObj.init(); + }); + + test('should not be creatable', () => { + expect(timelineObj.isCreatable).toEqual(true); + }); + + test(`throw no error when create`, () => { + const error = timelineObj.checkIsFailureCases(TimelineStatusActions.create); + expect(error?.body).toBeUndefined(); + }); + + test('should be Creatable via import', () => { + expect(timelineObj.isCreatableViaImport).toEqual(true); + }); + + test(`throw no error when CreatableViaImport`, () => { + const error = timelineObj.checkIsFailureCases(TimelineStatusActions.createViaImport); + expect(error?.body).toBeUndefined(); + }); + }); + + describe('Throw error if template timeline version is conflict when update via import', () => { + const mockGetTimeline: jest.Mock = jest.fn(); + const mockGetTemplateTimeline: jest.Mock = jest.fn(); + + let timelineObj: TimelinesStatusType; + + afterEach(() => { + jest.clearAllMocks(); + }); + + afterAll(() => { + jest.resetModules(); + }); + + beforeAll(() => { + jest.resetModules(); + }); + + beforeEach(async () => { + jest.doMock('../../saved_object', () => ({ + getTimeline: mockGetTimeline.mockReturnValue(mockGetTemplateTimelineValue), + getTimelineByTemplateTimelineId: mockGetTemplateTimeline.mockReturnValue({ + timeline: [mockGetTemplateTimelineValue], + }), + })); + + const CompareTimelinesStatus = jest.requireActual('./compare_timelines_status') + .CompareTimelinesStatus; + + timelineObj = new CompareTimelinesStatus({ + timelineInput: { + id: mockUniqueParsedObjects[0].savedObjectId, + version: mockUniqueParsedObjects[0].version, + }, + timelineType: TimelineType.template, + title: mockUniqueParsedObjects[0].title, + templateTimelineInput: { + id: mockUniqueParsedTemplateTimelineObjects[0].templateTimelineId, + version: mockGetTemplateTimelineValue.templateTimelineVersion, + }, + frameworkRequest: {} as FrameworkRequest, + }); + await timelineObj.init(); + }); + + test('should not be creatable', () => { + expect(timelineObj.isCreatable).toEqual(false); + }); + + test(`throw error when create`, () => { + const error = timelineObj.checkIsFailureCases(TimelineStatusActions.create); + expect(error?.body).toEqual(CREATE_TEMPLATE_TIMELINE_ERROR_MESSAGE); + }); + + test('should not be Creatable via import', () => { + expect(timelineObj.isCreatableViaImport).toEqual(false); + }); + + test(`throw error when CreatableViaImport`, () => { + const error = timelineObj.checkIsFailureCases(TimelineStatusActions.createViaImport); + expect(error?.body).toEqual( + getImportExistingTimelineError(mockUniqueParsedObjects[0].savedObjectId) + ); + }); + + test('should be updatable', () => { + expect(timelineObj.isUpdatable).toEqual(true); + }); + + test(`throw no error when update`, () => { + const error = timelineObj.checkIsFailureCases(TimelineStatusActions.update); + expect(error).toBeNull(); + }); + + test('should not be updatable via import', () => { + expect(timelineObj.isUpdatableViaImport).toEqual(false); + }); + + test(`throw error when UpdatableViaImport`, () => { + const error = timelineObj.checkIsFailureCases(TimelineStatusActions.updateViaImport); + expect(error?.body).toEqual(TEMPLATE_TIMELINE_VERSION_CONFLICT_MESSAGE); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/compare_timelines_status.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/compare_timelines_status.ts new file mode 100644 index 000000000000..d61d217a4cf4 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/compare_timelines_status.ts @@ -0,0 +1,247 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { isEmpty } from 'lodash/fp'; +import { + TimelineTypeLiteralWithNull, + TimelineType, + TimelineStatus, + TimelineTypeLiteral, +} from '../../../../../common/types/timeline'; +import { FrameworkRequest } from '../../../framework'; + +import { TimelineStatusActions, TimelineStatusAction } from './common'; +import { TimelineObject } from './timeline_object'; +import { + checkIsCreateFailureCases, + checkIsUpdateFailureCases, + checkIsCreateViaImportFailureCases, + checkIsUpdateViaImportFailureCases, + commonFailureChecker, +} from './failure_cases'; + +interface GivenTimelineInput { + id: string | null | undefined; + type?: TimelineTypeLiteralWithNull; + version: string | number | null | undefined; +} + +interface TimelinesStatusProps { + status: TimelineStatus | null | undefined; + title: string | null | undefined; + timelineType: TimelineTypeLiteralWithNull | undefined; + timelineInput: GivenTimelineInput; + templateTimelineInput: GivenTimelineInput; + frameworkRequest: FrameworkRequest; +} + +export class CompareTimelinesStatus { + public readonly timelineObject: TimelineObject; + public readonly templateTimelineObject: TimelineObject; + private readonly timelineType: TimelineTypeLiteral; + private readonly title: string | null; + private readonly status: TimelineStatus; + constructor({ + status = TimelineStatus.active, + title, + timelineType = TimelineType.default, + timelineInput, + templateTimelineInput, + frameworkRequest, + }: TimelinesStatusProps) { + this.timelineObject = new TimelineObject({ + id: timelineInput.id, + type: timelineInput.type ?? TimelineType.default, + version: timelineInput.version, + frameworkRequest, + }); + + this.templateTimelineObject = new TimelineObject({ + id: templateTimelineInput.id, + type: templateTimelineInput.type ?? TimelineType.template, + version: templateTimelineInput.version, + frameworkRequest, + }); + + this.timelineType = timelineType ?? TimelineType.default; + this.title = title ?? null; + this.status = status ?? TimelineStatus.active; + } + + public get isCreatable() { + return ( + this.isTitleValid && + !this.isSavedObjectVersionConflict && + ((this.timelineObject.isCreatable && !this.isHandlingTemplateTimeline) || + (this.templateTimelineObject.isCreatable && + this.timelineObject.isCreatable && + this.isHandlingTemplateTimeline)) + ); + } + + public get isCreatableViaImport() { + return ( + this.isCreatedStatusValid && + ((this.isCreatable && !this.isHandlingTemplateTimeline) || + (this.isCreatable && this.isHandlingTemplateTimeline && this.isTemplateVersionValid)) + ); + } + + private get isCreatedStatusValid() { + const obj = this.isHandlingTemplateTimeline ? this.templateTimelineObject : this.timelineObject; + + return obj.isExists + ? this.status === obj.getData?.status && this.status !== TimelineStatus.draft + : this.status !== TimelineStatus.draft; + } + + public get isUpdatable() { + return ( + this.isTitleValid && + !this.isSavedObjectVersionConflict && + ((this.timelineObject.isUpdatable && !this.isHandlingTemplateTimeline) || + (this.templateTimelineObject.isUpdatable && this.isHandlingTemplateTimeline)) + ); + } + + private get isTimelineTypeValid() { + const obj = this.isHandlingTemplateTimeline ? this.templateTimelineObject : this.timelineObject; + const existintTimelineType = obj.getData?.timelineType ?? TimelineType.default; + return obj.isExists ? this.timelineType === existintTimelineType : true; + } + + public get isUpdatableViaImport() { + return ( + this.isTimelineTypeValid && + this.isTitleValid && + this.isUpdatedTimelineStatusValid && + (this.timelineObject.isUpdatableViaImport || + (this.templateTimelineObject.isUpdatableViaImport && + this.isTemplateVersionValid && + this.isHandlingTemplateTimeline)) + ); + } + + public get isTitleValid() { + return ( + (this.status !== TimelineStatus.draft && !isEmpty(this.title)) || + this.status === TimelineStatus.draft + ); + } + + public getFailureChecker(action?: TimelineStatusAction) { + if (action === TimelineStatusActions.create) { + return checkIsCreateFailureCases; + } else if (action === TimelineStatusActions.createViaImport) { + return checkIsCreateViaImportFailureCases; + } else if (action === TimelineStatusActions.update) { + return checkIsUpdateFailureCases; + } else { + return checkIsUpdateViaImportFailureCases; + } + } + + public checkIsFailureCases(action?: TimelineStatusAction) { + const failureChecker = this.getFailureChecker(action); + const version = this.templateTimelineObject.getVersion; + const commonError = commonFailureChecker(this.status, this.title); + if (commonError != null) { + return commonError; + } + + const msg = failureChecker( + this.isHandlingTemplateTimeline, + this.status, + this.timelineType, + this.timelineObject.getVersion?.toString() ?? null, + version != null && typeof version === 'string' ? parseInt(version, 10) : version, + this.templateTimelineObject.getId, + this.timelineObject.getData, + this.templateTimelineObject.getData + ); + return msg; + } + + public get templateTimelineInput() { + return this.templateTimelineObject; + } + + public get timelineInput() { + return this.timelineObject; + } + + private getTimelines() { + return Promise.all([ + this.timelineObject.getTimeline(), + this.templateTimelineObject.getTimeline(), + ]); + } + + public get isHandlingTemplateTimeline() { + return this.timelineType === TimelineType.template; + } + + private get isSavedObjectVersionConflict() { + const version = this.timelineObject?.getVersion; + const existingVersion = this.timelineObject?.data?.version; + if (version != null && this.timelineObject.isExists) { + return version !== existingVersion; + } else if (this.timelineObject.isExists && version == null) { + return true; + } + return false; + } + + private get isTemplateVersionConflict() { + const version = this.templateTimelineObject?.getVersion; + const existingTemplateTimelineVersion = this.templateTimelineObject?.data + ?.templateTimelineVersion; + if ( + version != null && + this.templateTimelineObject.isExists && + existingTemplateTimelineVersion != null + ) { + return version <= existingTemplateTimelineVersion; + } else if (this.templateTimelineObject.isExists && version == null) { + return true; + } + return false; + } + + private get isTemplateVersionValid() { + const version = this.templateTimelineObject?.getVersion; + return typeof version === 'number' && !this.isTemplateVersionConflict; + } + + private get isUpdatedTimelineStatusValid() { + const status = this.status; + const existingStatus = this.isHandlingTemplateTimeline + ? this.templateTimelineInput.data?.status + : this.timelineInput.data?.status; + return ( + ((existingStatus == null || existingStatus === TimelineStatus.active) && + (status == null || status === TimelineStatus.active)) || + (existingStatus != null && status === existingStatus) + ); + } + + public get timelineId() { + if (this.isHandlingTemplateTimeline) { + return this.templateTimelineInput.data?.savedObjectId ?? this.templateTimelineInput.getId; + } + return this.timelineInput.data?.savedObjectId ?? this.timelineInput.getId; + } + + public get timelineVersion() { + const version = this.isHandlingTemplateTimeline + ? this.templateTimelineInput.data?.version ?? this.timelineInput.getVersion + : this.timelineInput.data?.version ?? this.timelineInput.getVersion; + return version != null ? version.toString() : null; + } + + public async init() { + await this.getTimelines(); + } +} diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/create_timelines.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/create_timelines.ts index 5b2470821b69..abe298566341 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/create_timelines.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/create_timelines.ts @@ -12,6 +12,7 @@ import { FrameworkRequest } from '../../../framework'; import { SavedTimeline, TimelineSavedObject } from '../../../../../common/types/timeline'; import { SavedNote } from '../../../../../common/types/timeline/note'; import { NoteResult, ResponseTimeline } from '../../../../graphql/types'; + export const CREATE_TIMELINE_ERROR_MESSAGE = 'UPDATE timeline with POST is not allowed, please use PATCH instead'; export const CREATE_TEMPLATE_TIMELINE_ERROR_MESSAGE = @@ -20,16 +21,10 @@ export const CREATE_TEMPLATE_TIMELINE_ERROR_MESSAGE = export const saveTimelines = ( frameworkRequest: FrameworkRequest, timeline: SavedTimeline, - timelineSavedObjectId?: string | null, - timelineVersion?: string | null -): Promise => { - return timelineLib.persistTimeline( - frameworkRequest, - timelineSavedObjectId ?? null, - timelineVersion ?? null, - timeline - ); -}; + timelineSavedObjectId: string | null = null, + timelineVersion: string | null = null +): Promise => + timelineLib.persistTimeline(frameworkRequest, timelineSavedObjectId, timelineVersion, timeline); export const savePinnedEvents = ( frameworkRequest: FrameworkRequest, @@ -72,15 +67,25 @@ export const saveNotes = ( ); }; -export const createTimelines = async ( - frameworkRequest: FrameworkRequest, - timeline: SavedTimeline, - timelineSavedObjectId?: string | null, - timelineVersion?: string | null, - pinnedEventIds?: string[] | null, - notes?: NoteResult[], - existingNoteIds?: string[] -): Promise => { +interface CreateTimelineProps { + frameworkRequest: FrameworkRequest; + timeline: SavedTimeline; + timelineSavedObjectId?: string | null; + timelineVersion?: string | null; + pinnedEventIds?: string[] | null; + notes?: NoteResult[]; + existingNoteIds?: string[]; +} + +export const createTimelines = async ({ + frameworkRequest, + timeline, + timelineSavedObjectId = null, + timelineVersion = null, + pinnedEventIds = null, + notes = [], + existingNoteIds = [], +}: CreateTimelineProps): Promise => { const responseTimeline = await saveTimelines( frameworkRequest, timeline, @@ -89,7 +94,6 @@ export const createTimelines = async ( ); const newTimelineSavedObjectId = responseTimeline.timeline.savedObjectId; const newTimelineVersion = responseTimeline.timeline.version; - let myPromises: unknown[] = []; if (pinnedEventIds != null && !isEmpty(pinnedEventIds)) { myPromises = [ @@ -143,8 +147,9 @@ export const getTemplateTimeline = async ( frameworkRequest, templateTimelineId ); + // eslint-disable-next-line no-empty } catch (e) { return null; } - return templateTimeline.timeline[0]; + return templateTimeline?.timeline[0] ?? null; }; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/export_timelines.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/export_timelines.ts index 1f02851c56b8..23090bfc6f0b 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/export_timelines.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/export_timelines.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import { omit } from 'lodash/fp'; + import { SavedObjectsClient, SavedObjectsFindOptions, @@ -16,7 +18,6 @@ import { ExportedNotes, TimelineSavedObject, ExportTimelineNotFoundError, - TimelineStatus, } from '../../../../../common/types/timeline'; import { NoteSavedObject } from '../../../../../common/types/timeline/note'; import { PinnedEventSavedObject } from '../../../../../common/types/timeline/pinned_event'; @@ -180,12 +181,11 @@ const getTimelinesFromObjects = async ( if (myTimeline != null) { const timelineNotes = myNotes.filter((n) => n.timelineId === timelineId); const timelinePinnedEventIds = myPinnedEventIds.filter((p) => p.timelineId === timelineId); + const exportedTimeline = omit('status', myTimeline); return [ ...acc, { - ...myTimeline, - status: - myTimeline.status === TimelineStatus.draft ? TimelineStatus.active : myTimeline.status, + ...exportedTimeline, ...getGlobalEventNotesByTimelineId(timelineNotes), pinnedEventIds: getPinnedEventsIdsByTimelineId(timelinePinnedEventIds), }, diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/failure_cases.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/failure_cases.ts new file mode 100644 index 000000000000..60ba5389280c --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/failure_cases.ts @@ -0,0 +1,377 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { isEmpty } from 'lodash/fp'; +import { + TimelineSavedObject, + TimelineStatus, + TimelineTypeLiteral, +} from '../../../../../common/types/timeline'; + +export const UPDATE_TIMELINE_ERROR_MESSAGE = + 'CREATE timeline with PATCH is not allowed, please use POST instead'; +export const UPDATE_TEMPLATE_TIMELINE_ERROR_MESSAGE = + "CREATE template timeline with PATCH is not allowed, please use POST instead (Given template timeline doesn't exist)"; +export const NO_MATCH_VERSION_ERROR_MESSAGE = + 'TimelineVersion conflict: The given version doesn not match with existing timeline'; +export const NO_MATCH_ID_ERROR_MESSAGE = + "Timeline id doesn't match with existing template timeline"; +export const TEMPLATE_TIMELINE_VERSION_CONFLICT_MESSAGE = 'Template timelineVersion conflict'; +export const CREATE_TIMELINE_ERROR_MESSAGE = + 'UPDATE timeline with POST is not allowed, please use PATCH instead'; +export const CREATE_TEMPLATE_TIMELINE_ERROR_MESSAGE = + 'UPDATE template timeline with POST is not allowed, please use PATCH instead'; +export const EMPTY_TITLE_ERROR_MESSAGE = 'Title cannot be empty'; +export const UPDATE_STATUS_ERROR_MESSAGE = 'Update an immutable timeline is is not allowed'; +export const CREATE_TEMPLATE_TIMELINE_WITHOUT_VERSION_ERROR_MESSAGE = + 'Create template timeline without a valid templateTimelineVersion is not allowed. Please start from 1 to create a new template timeline'; +export const CREATE_WITH_INVALID_STATUS_ERROR_MESSAGE = 'Cannot create a draft timeline'; +export const NOT_ALLOW_UPDATE_STATUS_ERROR_MESSAGE = 'Update status is not allowed'; +export const NOT_ALLOW_UPDATE_TIMELINE_TYPE_ERROR_MESSAGE = 'Update timelineType is not allowed'; +export const UPDAT_TIMELINE_VIA_IMPORT_NOT_ALLOWED_ERROR_MESSAGE = + 'Update timeline via import is not allowed'; + +const isUpdatingStatus = ( + isHandlingTemplateTimeline: boolean, + status: TimelineStatus | null | undefined, + existTimeline: TimelineSavedObject | null, + existTemplateTimeline: TimelineSavedObject | null +) => { + const obj = isHandlingTemplateTimeline ? existTemplateTimeline : existTimeline; + return obj?.status === TimelineStatus.immutable ? UPDATE_STATUS_ERROR_MESSAGE : null; +}; + +const isGivenTitleValid = (status: TimelineStatus, title: string | null | undefined) => { + return (status !== TimelineStatus.draft && !isEmpty(title)) || status === TimelineStatus.draft + ? null + : EMPTY_TITLE_ERROR_MESSAGE; +}; + +export const getImportExistingTimelineError = (id: string) => + `savedObjectId: "${id}" already exists`; + +export const commonFailureChecker = (status: TimelineStatus, title: string | null | undefined) => { + const error = [isGivenTitleValid(status, title)].filter((msg) => msg != null).join(','); + return !isEmpty(error) + ? { + body: error, + statusCode: 405, + } + : null; +}; + +const commonUpdateTemplateTimelineCheck = ( + isHandlingTemplateTimeline: boolean, + status: TimelineStatus | null | undefined, + timelineType: TimelineTypeLiteral, + version: string | null, + templateTimelineVersion: number | null, + templateTimelineId: string | null | undefined, + existTimeline: TimelineSavedObject | null, + existTemplateTimeline: TimelineSavedObject | null +) => { + if (isHandlingTemplateTimeline) { + if (existTimeline != null && timelineType !== existTimeline.timelineType) { + return { + body: NOT_ALLOW_UPDATE_TIMELINE_TYPE_ERROR_MESSAGE, + statusCode: 403, + }; + } + + if (existTemplateTimeline == null && templateTimelineVersion != null) { + // template timeline !exists + // Throw error to create template timeline in patch + return { + body: UPDATE_TEMPLATE_TIMELINE_ERROR_MESSAGE, + statusCode: 405, + }; + } + + if ( + existTimeline != null && + existTemplateTimeline != null && + existTimeline.savedObjectId !== existTemplateTimeline.savedObjectId + ) { + // Throw error you can not have a no matching between your timeline and your template timeline during an update + return { + body: NO_MATCH_ID_ERROR_MESSAGE, + statusCode: 409, + }; + } + + if ( + existTemplateTimeline != null && + existTemplateTimeline.templateTimelineVersion == null && + existTemplateTimeline.version !== version + ) { + // throw error 409 conflict timeline + return { + body: NO_MATCH_VERSION_ERROR_MESSAGE, + statusCode: 409, + }; + } + } + return null; +}; + +const commonUpdateTimelineCheck = ( + isHandlingTemplateTimeline: boolean, + status: TimelineStatus | null | undefined, + timelineType: TimelineTypeLiteral, + version: string | null, + templateTimelineVersion: number | null, + templateTimelineId: string | null | undefined, + existTimeline: TimelineSavedObject | null, + existTemplateTimeline: TimelineSavedObject | null +) => { + if (existTimeline == null) { + // timeline !exists + return { + body: UPDATE_TIMELINE_ERROR_MESSAGE, + statusCode: 405, + }; + } + + if (existTimeline?.version !== version) { + // throw error 409 conflict timeline + return { + body: NO_MATCH_VERSION_ERROR_MESSAGE, + statusCode: 409, + }; + } + + return null; +}; + +const commonUpdateCases = ( + isHandlingTemplateTimeline: boolean, + status: TimelineStatus | null | undefined, + timelineType: TimelineTypeLiteral, + version: string | null, + templateTimelineVersion: number | null, + templateTimelineId: string | null | undefined, + existTimeline: TimelineSavedObject | null, + existTemplateTimeline: TimelineSavedObject | null +) => { + if (isHandlingTemplateTimeline) { + return commonUpdateTemplateTimelineCheck( + isHandlingTemplateTimeline, + status, + timelineType, + version, + templateTimelineVersion, + templateTimelineId, + existTimeline, + existTemplateTimeline + ); + } else { + return commonUpdateTimelineCheck( + isHandlingTemplateTimeline, + status, + timelineType, + version, + templateTimelineVersion, + templateTimelineId, + existTimeline, + existTemplateTimeline + ); + } +}; + +const createTemplateTimelineCheck = ( + isHandlingTemplateTimeline: boolean, + status: TimelineStatus, + timelineType: TimelineTypeLiteral, + version: string | null, + templateTimelineVersion: number | null, + templateTimelineId: string | null | undefined, + existTimeline: TimelineSavedObject | null, + existTemplateTimeline: TimelineSavedObject | null +) => { + if (isHandlingTemplateTimeline && existTemplateTimeline != null) { + // Throw error to create template timeline in patch + return { + body: CREATE_TEMPLATE_TIMELINE_ERROR_MESSAGE, + statusCode: 405, + }; + } else if (isHandlingTemplateTimeline && templateTimelineVersion == null) { + return { + body: CREATE_TEMPLATE_TIMELINE_WITHOUT_VERSION_ERROR_MESSAGE, + statusCode: 403, + }; + } else { + return null; + } +}; + +export const checkIsUpdateViaImportFailureCases = ( + isHandlingTemplateTimeline: boolean, + status: TimelineStatus | null | undefined, + timelineType: TimelineTypeLiteral, + version: string | null, + templateTimelineVersion: number | null, + templateTimelineId: string | null | undefined, + existTimeline: TimelineSavedObject | null, + existTemplateTimeline: TimelineSavedObject | null +) => { + if (!isHandlingTemplateTimeline) { + if (existTimeline == null) { + return { body: UPDAT_TIMELINE_VIA_IMPORT_NOT_ALLOWED_ERROR_MESSAGE, statusCode: 405 }; + } else { + return { + body: getImportExistingTimelineError(existTimeline!.savedObjectId), + statusCode: 405, + }; + } + } else { + if (existTemplateTimeline != null && timelineType !== existTemplateTimeline?.timelineType) { + return { + body: NOT_ALLOW_UPDATE_TIMELINE_TYPE_ERROR_MESSAGE, + statusCode: 403, + }; + } + const isStatusValid = + ((existTemplateTimeline?.status == null || + existTemplateTimeline?.status === TimelineStatus.active) && + (status == null || status === TimelineStatus.active)) || + (existTemplateTimeline?.status != null && status === existTemplateTimeline?.status); + + if (!isStatusValid) { + return { + body: NOT_ALLOW_UPDATE_STATUS_ERROR_MESSAGE, + statusCode: 405, + }; + } + + const error = commonUpdateTemplateTimelineCheck( + isHandlingTemplateTimeline, + status, + timelineType, + version, + templateTimelineVersion, + templateTimelineId, + existTimeline, + existTemplateTimeline + ); + if (error) { + return error; + } + if ( + templateTimelineVersion != null && + existTemplateTimeline != null && + existTemplateTimeline.templateTimelineVersion != null && + existTemplateTimeline.templateTimelineVersion >= templateTimelineVersion + ) { + // Throw error you can not update a template timeline version with an old version + return { + body: TEMPLATE_TIMELINE_VERSION_CONFLICT_MESSAGE, + statusCode: 409, + }; + } + } + return null; +}; + +export const checkIsUpdateFailureCases = ( + isHandlingTemplateTimeline: boolean, + status: TimelineStatus | null | undefined, + timelineType: TimelineTypeLiteral, + version: string | null, + templateTimelineVersion: number | null, + templateTimelineId: string | null | undefined, + existTimeline: TimelineSavedObject | null, + existTemplateTimeline: TimelineSavedObject | null +) => { + const error = isUpdatingStatus( + isHandlingTemplateTimeline, + status, + existTimeline, + existTemplateTimeline + ); + if (error) { + return { + body: error, + statusCode: 403, + }; + } + return commonUpdateCases( + isHandlingTemplateTimeline, + status, + timelineType, + version, + templateTimelineVersion, + templateTimelineId, + existTimeline, + existTemplateTimeline + ); +}; + +export const checkIsCreateFailureCases = ( + isHandlingTemplateTimeline: boolean, + status: TimelineStatus, + timelineType: TimelineTypeLiteral, + version: string | null, + templateTimelineVersion: number | null, + templateTimelineId: string | null | undefined, + existTimeline: TimelineSavedObject | null, + existTemplateTimeline: TimelineSavedObject | null +) => { + if (!isHandlingTemplateTimeline && existTimeline != null) { + return { + body: CREATE_TIMELINE_ERROR_MESSAGE, + statusCode: 405, + }; + } else if (isHandlingTemplateTimeline) { + return createTemplateTimelineCheck( + isHandlingTemplateTimeline, + status, + timelineType, + version, + templateTimelineVersion, + templateTimelineId, + existTimeline, + existTemplateTimeline + ); + } else { + return null; + } +}; + +export const checkIsCreateViaImportFailureCases = ( + isHandlingTemplateTimeline: boolean, + status: TimelineStatus | null | undefined, + timelineType: TimelineTypeLiteral, + version: string | null, + templateTimelineVersion: number | null, + templateTimelineId: string | null | undefined, + existTimeline: TimelineSavedObject | null, + existTemplateTimeline: TimelineSavedObject | null +) => { + if (status === TimelineStatus.draft) { + return { + body: CREATE_WITH_INVALID_STATUS_ERROR_MESSAGE, + statusCode: 405, + }; + } + + if (!isHandlingTemplateTimeline) { + if (existTimeline != null) { + return { + body: getImportExistingTimelineError(existTimeline.savedObjectId), + statusCode: 405, + }; + } + } else { + if (existTemplateTimeline != null) { + // Throw error to create template timeline in patch + return { + body: getImportExistingTimelineError(existTemplateTimeline.savedObjectId), + statusCode: 405, + }; + } + } + + return null; +}; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/timeline_object.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/timeline_object.ts new file mode 100644 index 000000000000..9fb96b509ec3 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/timeline_object.ts @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { + TimelineType, + TimelineTypeLiteral, + TimelineSavedObject, + TimelineStatus, +} from '../../../../../common/types/timeline'; +import { getTimeline, getTemplateTimeline } from './create_timelines'; +import { FrameworkRequest } from '../../../framework'; + +interface TimelineObjectProps { + id: string | null | undefined; + type: TimelineTypeLiteral; + version: string | number | null | undefined; + frameworkRequest: FrameworkRequest; +} + +export class TimelineObject { + public readonly id: string | null; + private type: TimelineTypeLiteral; + public readonly version: string | number | null; + private frameworkRequest: FrameworkRequest; + + public data: TimelineSavedObject | null; + + constructor({ + id = null, + type = TimelineType.default, + version = null, + frameworkRequest, + }: TimelineObjectProps) { + this.id = id; + this.type = type; + + this.version = version; + this.frameworkRequest = frameworkRequest; + this.data = null; + } + + public async getTimeline() { + this.data = + this.id != null + ? this.type === TimelineType.template + ? await getTemplateTimeline(this.frameworkRequest, this.id) + : await getTimeline(this.frameworkRequest, this.id) + : null; + + return this.data; + } + + public get getData() { + return this.data; + } + + private get isImmutable() { + return this.data?.status === TimelineStatus.immutable; + } + + public get isExists() { + return this.data != null; + } + + public get isUpdatable() { + return this.isExists && !this.isImmutable; + } + + public get isCreatable() { + return !this.isExists; + } + + public get isUpdatableViaImport() { + return this.type === TimelineType.template && this.isExists; + } + + public get getVersion() { + return this.version; + } + + public get getId() { + return this.id; + } +} diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/update_timelines.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/update_timelines.ts deleted file mode 100644 index a4efa676dadd..000000000000 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/update_timelines.ts +++ /dev/null @@ -1,80 +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; - * you may not use this file except in compliance with the Elastic License. - */ - -import { TimelineSavedObject } from '../../../../../common/types/timeline'; - -export const UPDATE_TIMELINE_ERROR_MESSAGE = - 'CREATE timeline with PATCH is not allowed, please use POST instead'; -export const UPDATE_TEMPLATE_TIMELINE_ERROR_MESSAGE = - 'CREATE template timeline with PATCH is not allowed, please use POST instead'; -export const NO_MATCH_VERSION_ERROR_MESSAGE = - 'TimelineVersion conflict: The given version doesn not match with existing timeline'; -export const NO_MATCH_ID_ERROR_MESSAGE = - "Timeline id doesn't match with existing template timeline"; -export const TEMPLATE_TIMELINE_VERSION_CONFLICT_MESSAGE = 'Template timelineVersion conflict'; - -export const checkIsFailureCases = ( - isHandlingTemplateTimeline: boolean, - version: string | null, - templateTimelineVersion: number | null, - existTimeline: TimelineSavedObject | null, - existTemplateTimeline: TimelineSavedObject | null -) => { - if (!isHandlingTemplateTimeline && existTimeline == null) { - return { - body: UPDATE_TIMELINE_ERROR_MESSAGE, - statusCode: 405, - }; - } else if (isHandlingTemplateTimeline && existTemplateTimeline == null) { - // Throw error to create template timeline in patch - return { - body: UPDATE_TEMPLATE_TIMELINE_ERROR_MESSAGE, - statusCode: 405, - }; - } else if ( - isHandlingTemplateTimeline && - existTimeline != null && - existTemplateTimeline != null && - existTimeline.savedObjectId !== existTemplateTimeline.savedObjectId - ) { - // Throw error you can not have a no matching between your timeline and your template timeline during an update - return { - body: NO_MATCH_ID_ERROR_MESSAGE, - statusCode: 409, - }; - } else if (!isHandlingTemplateTimeline && existTimeline?.version !== version) { - // throw error 409 conflict timeline - return { - body: NO_MATCH_VERSION_ERROR_MESSAGE, - statusCode: 409, - }; - } else if ( - isHandlingTemplateTimeline && - existTemplateTimeline != null && - existTemplateTimeline.templateTimelineVersion == null && - existTemplateTimeline.version !== version - ) { - // throw error 409 conflict timeline - return { - body: NO_MATCH_VERSION_ERROR_MESSAGE, - statusCode: 409, - }; - } else if ( - isHandlingTemplateTimeline && - templateTimelineVersion != null && - existTemplateTimeline != null && - existTemplateTimeline.templateTimelineVersion != null && - existTemplateTimeline.templateTimelineVersion !== templateTimelineVersion - ) { - // Throw error you can not update a template timeline version with an old version - return { - body: TEMPLATE_TIMELINE_VERSION_CONFLICT_MESSAGE, - statusCode: 409, - }; - } else { - return null; - } -}; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/saved_object.ts b/x-pack/plugins/security_solution/server/lib/timeline/saved_object.ts index bbb11cd642c4..ec90fc6d8e07 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/saved_object.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/saved_object.ts @@ -7,13 +7,20 @@ import { getOr } from 'lodash/fp'; import { SavedObjectsFindOptions } from '../../../../../../src/core/server'; -import { UNAUTHENTICATED_USER, disableTemplate } from '../../../common/constants'; +import { + UNAUTHENTICATED_USER, + disableTemplate, + enableElasticFilter, +} from '../../../common/constants'; import { NoteSavedObject } from '../../../common/types/timeline/note'; import { PinnedEventSavedObject } from '../../../common/types/timeline/pinned_event'; import { SavedTimeline, TimelineSavedObject, TimelineTypeLiteralWithNull, + TimelineStatusLiteralWithNull, + TemplateTimelineTypeLiteralWithNull, + TemplateTimelineType, } from '../../../common/types/timeline'; import { ResponseTimeline, @@ -38,6 +45,14 @@ interface ResponseTimelines { totalCount: number; } +interface AllTimelinesResponse extends ResponseTimelines { + defaultTimelineCount: number; + templateTimelineCount: number; + elasticTemplateTimelineCount: number; + customTemplateTimelineCount: number; + favoriteCount: number; +} + export interface ResponseTemplateTimeline { code?: Maybe; @@ -55,8 +70,10 @@ export interface Timeline { pageInfo: PageInfoTimeline | null, search: string | null, sort: SortTimeline | null, - timelineType: TimelineTypeLiteralWithNull - ) => Promise; + status: TimelineStatusLiteralWithNull, + timelineType: TimelineTypeLiteralWithNull, + templateTimelineType: TemplateTimelineTypeLiteralWithNull + ) => Promise; persistFavorite: ( request: FrameworkRequest, @@ -97,7 +114,7 @@ export const getTimelineByTemplateTimelineId = async ( }> => { const options: SavedObjectsFindOptions = { type: timelineSavedObjectType, - filter: `siem-ui-timeline.attributes.templateTimelineId: ${templateTimelineId}`, + filter: `siem-ui-timeline.attributes.templateTimelineId: "${templateTimelineId}"`, }; return getAllSavedTimeline(request, options); }; @@ -106,10 +123,13 @@ export const getTimelineByTemplateTimelineId = async ( * which has no timelineType exists in the savedObject */ const getTimelineTypeFilter = ( timelineType: TimelineTypeLiteralWithNull, - includeDraft: boolean + templateTimelineType: TemplateTimelineTypeLiteralWithNull, + status: TimelineStatusLiteralWithNull ) => { const typeFilter = - timelineType === TimelineType.template + timelineType == null + ? null + : timelineType === TimelineType.template ? `siem-ui-timeline.attributes.timelineType: ${TimelineType.template}` /** Show only whose timelineType exists and equals to "template" */ : /** Show me every timeline whose timelineType is not "template". * which includes timelineType === 'default' and @@ -119,10 +139,30 @@ const getTimelineTypeFilter = ( /** Show me every timeline whose status is not "draft". * which includes status === 'active' and * those status doesn't exists */ - const draftFilter = includeDraft - ? `siem-ui-timeline.attributes.status: ${TimelineStatus.draft}` - : `not siem-ui-timeline.attributes.status: ${TimelineStatus.draft}`; - return `${typeFilter} and ${draftFilter}`; + const draftFilter = + status === TimelineStatus.draft + ? `siem-ui-timeline.attributes.status: ${TimelineStatus.draft}` + : `not siem-ui-timeline.attributes.status: ${TimelineStatus.draft}`; + + const immutableFilter = + status == null + ? null + : status === TimelineStatus.immutable + ? `siem-ui-timeline.attributes.status: ${TimelineStatus.immutable}` + : `not siem-ui-timeline.attributes.status: ${TimelineStatus.immutable}`; + + const templateTimelineTypeFilter = + templateTimelineType == null + ? null + : templateTimelineType === TemplateTimelineType.elastic + ? `siem-ui-timeline.attributes.createdBy: "Elsatic"` + : `not siem-ui-timeline.attributes.createdBy: "Elastic"`; + + const filters = + !disableTemplate && enableElasticFilter + ? [typeFilter, draftFilter, immutableFilter, templateTimelineTypeFilter] + : [typeFilter, draftFilter, immutableFilter]; + return filters.filter((f) => f != null).join(' and '); }; export const getAllTimeline = async ( @@ -131,8 +171,10 @@ export const getAllTimeline = async ( pageInfo: PageInfoTimeline | null, search: string | null, sort: SortTimeline | null, - timelineType: TimelineTypeLiteralWithNull -): Promise => { + status: TimelineStatusLiteralWithNull, + timelineType: TimelineTypeLiteralWithNull, + templateTimelineType: TemplateTimelineTypeLiteralWithNull +): Promise => { const options: SavedObjectsFindOptions = { type: timelineSavedObjectType, perPage: pageInfo != null ? pageInfo.pageSize : undefined, @@ -144,13 +186,78 @@ export const getAllTimeline = async ( /** * CreateTemplateTimelineBtn * Remove the comment here to enable template timeline and apply the change below - * filter: getTimelineTypeFilter(timelineType, false) + * filter: getTimelineTypeFilter(timelineType, templateTimelineType, false) */ - filter: getTimelineTypeFilter(disableTemplate ? TimelineType.default : timelineType, false), + filter: getTimelineTypeFilter( + disableTemplate ? TimelineType.default : timelineType, + disableTemplate ? null : templateTimelineType, + disableTemplate ? null : status + ), sortField: sort != null ? sort.sortField : undefined, sortOrder: sort != null ? sort.sortOrder : undefined, }; - return getAllSavedTimeline(request, options); + + const timelineOptions = { + type: timelineSavedObjectType, + perPage: 1, + page: 1, + filter: getTimelineTypeFilter(TimelineType.default, null, TimelineStatus.active), + }; + + const templateTimelineOptions = { + type: timelineSavedObjectType, + perPage: 1, + page: 1, + filter: getTimelineTypeFilter(TimelineType.template, null, null), + }; + + const elasticTemplateTimelineOptions = { + type: timelineSavedObjectType, + perPage: 1, + page: 1, + filter: getTimelineTypeFilter( + TimelineType.template, + TemplateTimelineType.elastic, + TimelineStatus.immutable + ), + }; + + const customTemplateTimelineOptions = { + type: timelineSavedObjectType, + perPage: 1, + page: 1, + filter: getTimelineTypeFilter( + TimelineType.template, + TemplateTimelineType.custom, + TimelineStatus.active + ), + }; + + const favoriteTimelineOptions = { + type: timelineSavedObjectType, + searchFields: ['title', 'description', 'favorite.keySearch'], + perPage: 1, + page: 1, + filter: getTimelineTypeFilter(timelineType, null, TimelineStatus.active), + }; + + const result = await Promise.all([ + getAllSavedTimeline(request, options), + getAllSavedTimeline(request, timelineOptions), + getAllSavedTimeline(request, templateTimelineOptions), + getAllSavedTimeline(request, elasticTemplateTimelineOptions), + getAllSavedTimeline(request, customTemplateTimelineOptions), + getAllSavedTimeline(request, favoriteTimelineOptions), + ]); + + return Promise.resolve({ + ...result[0], + defaultTimelineCount: result[1].totalCount, + templateTimelineCount: result[2].totalCount, + elasticTemplateTimelineCount: result[3].totalCount, + customTemplateTimelineCount: result[4].totalCount, + favoriteCount: result[5].totalCount, + }); }; export const getDraftTimeline = async ( @@ -160,7 +267,11 @@ export const getDraftTimeline = async ( const options: SavedObjectsFindOptions = { type: timelineSavedObjectType, perPage: 1, - filter: getTimelineTypeFilter(timelineType, true), + filter: getTimelineTypeFilter( + timelineType, + timelineType === TimelineType.template ? TemplateTimelineType.custom : null, + TimelineStatus.draft + ), sortField: 'created', sortOrder: 'desc', }; @@ -395,7 +506,6 @@ const getAllSavedTimeline = async (request: FrameworkRequest, options: SavedObje } const savedObjects = await savedObjectsClient.find(options); - const timelinesWithNotesAndPinnedEvents = await Promise.all( savedObjects.saved_objects.map(async (savedObject) => { const timelineSaveObject = convertSavedObjectToSavedTimeline(savedObject);