From 3f5f9b7669fe160ba0d9de5e6e0b499342d7e9ed Mon Sep 17 00:00:00 2001 From: Kevin Qualters <56408403+kqualters-elastic@users.noreply.github.com> Date: Tue, 21 Jul 2020 13:07:40 -0400 Subject: [PATCH 001/142] [Security Solution][Resolver] Show process detail panel when clicking a process node (#72563) --- .../public/resolver/view/process_event_dot.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx b/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx index 503fd3d3dcef..1f4952f15119 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx @@ -252,7 +252,7 @@ const UnstyledProcessEventDot = React.memo( selectedProcessId: nodeID, }, }); - pushToQueryParams({ crumbId: nodeID, crumbEvent: 'all' }); + pushToQueryParams({ crumbId: nodeID, crumbEvent: '' }); }, [animationTarget, dispatch, pushToQueryParams, nodeID, nodeHTMLID]); /** From 4b06a4eb410654c304541fa9d10a047815d8751f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20Kopyci=C5=84ski?= Date: Tue, 21 Jul 2020 19:15:27 +0200 Subject: [PATCH 002/142] [Security Solution][Timeline] Add Empty view to the Timelines page (#72576) --- .../timelines/pages/timelines_page.test.tsx | 10 ++ .../public/timelines/pages/timelines_page.tsx | 108 ++++++++++-------- 2 files changed, 70 insertions(+), 48 deletions(-) diff --git a/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.test.tsx b/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.test.tsx index 2e59dbb72233..f9097ddef649 100644 --- a/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.test.tsx @@ -21,6 +21,16 @@ jest.mock('react-router-dom', () => { }; }); jest.mock('../../overview/components/events_by_dataset'); +jest.mock('../../common/containers/source', () => { + const originalModule = jest.requireActual('../../common/containers/source'); + + return { + ...originalModule, + useWithSource: jest.fn().mockReturnValue({ + indicesExist: true, + }), + }; +}); jest.mock('../../common/lib/kibana', () => { const originalModule = jest.requireActual('../../common/lib/kibana'); diff --git a/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.tsx b/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.tsx index 56aff3ec8aaa..b59f9e90f8e7 100644 --- a/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.tsx +++ b/x-pack/plugins/security_solution/public/timelines/pages/timelines_page.tsx @@ -15,6 +15,8 @@ import { WrapperPage } from '../../common/components/wrapper_page'; import { useKibana } from '../../common/lib/kibana'; import { SpyRoute } from '../../common/utils/route/spy_routes'; import { useApolloClient } from '../../common/utils/apollo_context'; +import { useWithSource } from '../../common/containers/source'; +import { OverviewEmpty } from '../../overview/components/overview_empty'; import { StatefulOpenTimeline } from '../components/open_timeline'; import { NEW_TEMPLATE_TIMELINE } from '../components/timeline/properties/translations'; @@ -36,61 +38,71 @@ export const TimelinesPageComponent: React.FC = () => { const onImportTimelineBtnClick = useCallback(() => { setImportDataModalToggle(true); }, [setImportDataModalToggle]); + const { indicesExist } = useWithSource(); const apolloClient = useApolloClient(); - const uiCapabilities = useKibana().services.application.capabilities; - const capabilitiesCanUserCRUD: boolean = !!uiCapabilities.siem.crud; + const capabilitiesCanUserCRUD: boolean = !!useKibana().services.application.capabilities.siem + .crud; return ( <> - - - - - {capabilitiesCanUserCRUD && ( - - {i18n.ALL_TIMELINES_IMPORT_TIMELINE_TITLE} - - )} - - {tabName === TimelineType.default ? ( - - {capabilitiesCanUserCRUD && ( - + {indicesExist ? ( + <> + + + + + {capabilitiesCanUserCRUD && ( + + {i18n.ALL_TIMELINES_IMPORT_TIMELINE_TITLE} + + )} + + {tabName === TimelineType.default ? ( + + {capabilitiesCanUserCRUD && ( + + )} + + ) : ( + + + )} - - ) : ( - - - - )} - - + + - - - - + + + + + + ) : ( + + + + + )} From eb71e599ce2dbdb2062389257d64f7761e9e6e08 Mon Sep 17 00:00:00 2001 From: Clint Andrew Hall Date: Tue, 21 Jul 2020 14:12:56 -0400 Subject: [PATCH 003/142] [pre-req] Convert Page Manager, Page Preview, DOM Preview (#70370) Co-authored-by: Elastic Machine Co-authored-by: Corey Robertson --- x-pack/plugins/canvas/i18n/components.ts | 16 ++ .../{dom_preview.js => dom_preview.tsx} | 53 +++++-- .../public/components/dom_preview/index.js | 9 -- .../index.js => dom_preview/index.ts} | 2 +- .../components/link/{index.js => index.ts} | 0 .../canvas/public/components/link/link.js | 63 -------- .../canvas/public/components/link/link.tsx | 72 +++++++++ .../public/components/page_manager/index.js | 37 ----- .../public/components/page_manager/index.ts | 31 ++++ .../{page_manager.js => page_manager.tsx} | 148 ++++++++++-------- .../public/components/page_preview/index.ts | 24 +++ .../{page_controls.js => page_controls.tsx} | 21 ++- .../{page_preview.js => page_preview.tsx} | 35 ++--- .../public/components/toolbar/toolbar.tsx | 3 +- .../canvas/public/lib/create_handlers.ts | 2 +- 15 files changed, 292 insertions(+), 224 deletions(-) rename x-pack/plugins/canvas/public/components/dom_preview/{dom_preview.js => dom_preview.tsx} (71%) delete mode 100644 x-pack/plugins/canvas/public/components/dom_preview/index.js rename x-pack/plugins/canvas/public/components/{page_preview/index.js => dom_preview/index.ts} (84%) rename x-pack/plugins/canvas/public/components/link/{index.js => index.ts} (100%) delete mode 100644 x-pack/plugins/canvas/public/components/link/link.js create mode 100644 x-pack/plugins/canvas/public/components/link/link.tsx delete mode 100644 x-pack/plugins/canvas/public/components/page_manager/index.js create mode 100644 x-pack/plugins/canvas/public/components/page_manager/index.ts rename x-pack/plugins/canvas/public/components/page_manager/{page_manager.js => page_manager.tsx} (63%) create mode 100644 x-pack/plugins/canvas/public/components/page_preview/index.ts rename x-pack/plugins/canvas/public/components/page_preview/{page_controls.js => page_controls.tsx} (75%) rename x-pack/plugins/canvas/public/components/page_preview/{page_preview.js => page_preview.tsx} (56%) diff --git a/x-pack/plugins/canvas/i18n/components.ts b/x-pack/plugins/canvas/i18n/components.ts index 78083f26a38b..acc55d50ae19 100644 --- a/x-pack/plugins/canvas/i18n/components.ts +++ b/x-pack/plugins/canvas/i18n/components.ts @@ -567,6 +567,22 @@ export const ComponentStrings = { pageNumber, }, }), + getAddPageTooltip: () => + i18n.translate('xpack.canvas.pageManager.addPageTooltip', { + defaultMessage: 'Add a new page to this workpad', + }), + getConfirmRemoveTitle: () => + i18n.translate('xpack.canvas.pageManager.confirmRemoveTitle', { + defaultMessage: 'Remove Page', + }), + getConfirmRemoveDescription: () => + i18n.translate('xpack.canvas.pageManager.confirmRemoveDescription', { + defaultMessage: 'Are you sure you want to remove this page?', + }), + getConfirmRemoveButtonLabel: () => + i18n.translate('xpack.canvas.pageManager.removeButtonLabel', { + defaultMessage: 'Remove', + }), }, PagePreviewPageControls: { getClonePageAriaLabel: () => diff --git a/x-pack/plugins/canvas/public/components/dom_preview/dom_preview.js b/x-pack/plugins/canvas/public/components/dom_preview/dom_preview.tsx similarity index 71% rename from x-pack/plugins/canvas/public/components/dom_preview/dom_preview.js rename to x-pack/plugins/canvas/public/components/dom_preview/dom_preview.tsx index f74862af8d10..6ec0276c2f49 100644 --- a/x-pack/plugins/canvas/public/components/dom_preview/dom_preview.js +++ b/x-pack/plugins/canvas/public/components/dom_preview/dom_preview.tsx @@ -4,30 +4,38 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; import { debounce } from 'lodash'; -export class DomPreview extends React.Component { +interface Props { + elementId: string; + height: number; +} + +export class DomPreview extends PureComponent { static propTypes = { elementId: PropTypes.string.isRequired, height: PropTypes.number.isRequired, }; + _container: HTMLDivElement | null = null; + _content: HTMLDivElement | null = null; + _observer: MutationObserver | null = null; + _original: Element | null = null; + _updateTimeout: number = 0; + componentDidMount() { this.update(); } componentWillUnmount() { clearTimeout(this._updateTimeout); - this._observer && this._observer.disconnect(); // observer not guaranteed to exist - } - _container = null; - _content = null; - _observer = null; - _original = null; - _updateTimeout = null; + if (this._observer) { + this._observer.disconnect(); // observer not guaranteed to exist + } + } update = () => { if (!this._content || !this._container) { @@ -38,7 +46,10 @@ export class DomPreview extends React.Component { const originalChanged = currentOriginal !== this._original; if (originalChanged) { - this._observer && this._observer.disconnect(); + if (this._observer) { + this._observer.disconnect(); + } + this._original = currentOriginal; if (this._original) { @@ -50,12 +61,16 @@ export class DomPreview extends React.Component { this._observer.observe(this._original, config); } else { clearTimeout(this._updateTimeout); // to avoid the assumption that we fully control when `update` is called - this._updateTimeout = setTimeout(this.update, 30); + this._updateTimeout = window.setTimeout(this.update, 30); return; } } - const thumb = this._original.cloneNode(true); + if (!this._original) { + return; + } + + const thumb = this._original.cloneNode(true) as HTMLDivElement; thumb.id += '-thumb'; const originalStyle = window.getComputedStyle(this._original, null); @@ -66,9 +81,10 @@ export class DomPreview extends React.Component { const scale = thumbHeight / originalHeight; const thumbWidth = originalWidth * scale; - if (this._content.hasChildNodes()) { + if (this._content.firstChild) { this._content.removeChild(this._content.firstChild); } + this._content.appendChild(thumb); this._content.style.cssText = `transform: scale(${scale}); transform-origin: top left;`; @@ -76,13 +92,16 @@ export class DomPreview extends React.Component { // Copy canvas data const originalCanvas = this._original.querySelectorAll('canvas'); - const thumbCanvas = thumb.querySelectorAll('canvas'); + const thumbCanvas = (thumb as Element).querySelectorAll('canvas'); // Cloned canvas elements are blank and need to be explicitly redrawn if (originalCanvas.length > 0) { - Array.from(originalCanvas).map((img, i) => - thumbCanvas[i].getContext('2d').drawImage(img, 0, 0) - ); + Array.from(originalCanvas).map((img, i) => { + const context = thumbCanvas[i].getContext('2d'); + if (context) { + context.drawImage(img, 0, 0); + } + }); } }; diff --git a/x-pack/plugins/canvas/public/components/dom_preview/index.js b/x-pack/plugins/canvas/public/components/dom_preview/index.js deleted file mode 100644 index 283f92c7ecd9..000000000000 --- a/x-pack/plugins/canvas/public/components/dom_preview/index.js +++ /dev/null @@ -1,9 +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 { DomPreview as Component } from './dom_preview'; - -export const DomPreview = Component; diff --git a/x-pack/plugins/canvas/public/components/page_preview/index.js b/x-pack/plugins/canvas/public/components/dom_preview/index.ts similarity index 84% rename from x-pack/plugins/canvas/public/components/page_preview/index.js rename to x-pack/plugins/canvas/public/components/dom_preview/index.ts index d72d6403dd5b..19980b7c2cfe 100644 --- a/x-pack/plugins/canvas/public/components/page_preview/index.js +++ b/x-pack/plugins/canvas/public/components/dom_preview/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { PagePreview } from './page_preview'; +export { DomPreview } from './dom_preview'; diff --git a/x-pack/plugins/canvas/public/components/link/index.js b/x-pack/plugins/canvas/public/components/link/index.ts similarity index 100% rename from x-pack/plugins/canvas/public/components/link/index.js rename to x-pack/plugins/canvas/public/components/link/index.ts diff --git a/x-pack/plugins/canvas/public/components/link/link.js b/x-pack/plugins/canvas/public/components/link/link.js deleted file mode 100644 index d97316419059..000000000000 --- a/x-pack/plugins/canvas/public/components/link/link.js +++ /dev/null @@ -1,63 +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 React from 'react'; -import PropTypes from 'prop-types'; -import { EuiLink } from '@elastic/eui'; - -import { ComponentStrings } from '../../../i18n'; - -const { Link: strings } = ComponentStrings; - -const isModifiedEvent = (ev) => !!(ev.metaKey || ev.altKey || ev.ctrlKey || ev.shiftKey); - -export class Link extends React.PureComponent { - static propTypes = { - target: PropTypes.string, - onClick: PropTypes.func, - name: PropTypes.string.isRequired, - params: PropTypes.object, - children: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.node]).isRequired, - }; - - static contextTypes = { - router: PropTypes.object, - }; - - navigateTo = (name, params) => (ev) => { - if (this.props.onClick) { - this.props.onClick(ev); - } - - if ( - !ev.defaultPrevented && // onClick prevented default - ev.button === 0 && // ignore everything but left clicks - !this.props.target && // let browser handle "target=_blank" etc. - !isModifiedEvent(ev) // ignore clicks with modifier keys - ) { - ev.preventDefault(); - this.context.router.navigateTo(name, params); - } - }; - - render() { - try { - const { name, params, children, ...linkArgs } = this.props; - const { router } = this.context; - const href = router.getFullPath(router.create(name, params)); - const props = { - ...linkArgs, - href, - onClick: this.navigateTo(name, params), - }; - - return {children}; - } catch (e) { - console.error(e); - return
{strings.getErrorMessage(e.message)}
; - } - } -} diff --git a/x-pack/plugins/canvas/public/components/link/link.tsx b/x-pack/plugins/canvas/public/components/link/link.tsx new file mode 100644 index 000000000000..b0289fba842d --- /dev/null +++ b/x-pack/plugins/canvas/public/components/link/link.tsx @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, MouseEvent, useContext } from 'react'; +import PropTypes from 'prop-types'; +import { EuiLink, EuiLinkProps } from '@elastic/eui'; +import { RouterContext } from '../router'; + +import { ComponentStrings } from '../../../i18n'; + +const { Link: strings } = ComponentStrings; + +const isModifiedEvent = (ev: MouseEvent) => + !!(ev.metaKey || ev.altKey || ev.ctrlKey || ev.shiftKey); + +interface Props { + name: string; + params: Record; +} + +export const Link: FC = ({ + onClick, + target, + name, + params, + children, + ...linkArgs +}) => { + const router = useContext(RouterContext); + + if (router) { + const navigateTo = (ev: MouseEvent) => { + if (onClick) { + onClick(ev); + } + + if ( + !ev.defaultPrevented && // onClick prevented default + ev.button === 0 && // ignore everything but left clicks + !target && // let browser handle "target=_blank" etc. + !isModifiedEvent(ev) // ignore clicks with modifier keys + ) { + ev.preventDefault(); + router.navigateTo(name, params); + } + }; + + try { + return ( + + {children} + + ); + } catch (e) { + return
{strings.getErrorMessage(e.message)}
; + } + } + + return
{strings.getErrorMessage('Router Undefined')}
; +}; + +Link.contextTypes = { + router: PropTypes.object, +}; + +Link.propTypes = { + name: PropTypes.string.isRequired, + params: PropTypes.object, +}; diff --git a/x-pack/plugins/canvas/public/components/page_manager/index.js b/x-pack/plugins/canvas/public/components/page_manager/index.js deleted file mode 100644 index a198b7b8c3d8..000000000000 --- a/x-pack/plugins/canvas/public/components/page_manager/index.js +++ /dev/null @@ -1,37 +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 { connect } from 'react-redux'; -import { compose, withState } from 'recompose'; -import * as pageActions from '../../state/actions/pages'; -import { canUserWrite } from '../../state/selectors/app'; -import { getSelectedPage, getWorkpad, getPages, isWriteable } from '../../state/selectors/workpad'; -import { DEFAULT_WORKPAD_CSS } from '../../../common/lib/constants'; -import { PageManager as Component } from './page_manager'; - -const mapStateToProps = (state) => { - const { id, css } = getWorkpad(state); - - return { - isWriteable: isWriteable(state) && canUserWrite(state), - pages: getPages(state), - selectedPage: getSelectedPage(state), - workpadId: id, - workpadCSS: css || DEFAULT_WORKPAD_CSS, - }; -}; - -const mapDispatchToProps = (dispatch) => ({ - addPage: () => dispatch(pageActions.addPage()), - movePage: (id, position) => dispatch(pageActions.movePage(id, position)), - duplicatePage: (id) => dispatch(pageActions.duplicatePage(id)), - removePage: (id) => dispatch(pageActions.removePage(id)), -}); - -export const PageManager = compose( - connect(mapStateToProps, mapDispatchToProps), - withState('deleteId', 'setDeleteId', null) -)(Component); diff --git a/x-pack/plugins/canvas/public/components/page_manager/index.ts b/x-pack/plugins/canvas/public/components/page_manager/index.ts new file mode 100644 index 000000000000..d19540cd6a68 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/page_manager/index.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Dispatch } from 'redux'; +import { connect } from 'react-redux'; +// @ts-expect-error untyped local +import * as pageActions from '../../state/actions/pages'; +import { canUserWrite } from '../../state/selectors/app'; +import { getSelectedPage, getWorkpad, getPages, isWriteable } from '../../state/selectors/workpad'; +import { DEFAULT_WORKPAD_CSS } from '../../../common/lib/constants'; +import { PageManager as Component } from './page_manager'; +import { State } from '../../../types'; + +const mapStateToProps = (state: State) => ({ + isWriteable: isWriteable(state) && canUserWrite(state), + pages: getPages(state), + selectedPage: getSelectedPage(state), + workpadId: getWorkpad(state).id, + workpadCSS: getWorkpad(state).css || DEFAULT_WORKPAD_CSS, +}); + +const mapDispatchToProps = (dispatch: Dispatch) => ({ + onAddPage: () => dispatch(pageActions.addPage()), + onMovePage: (id: string, position: number) => dispatch(pageActions.movePage(id, position)), + onRemovePage: (id: string) => dispatch(pageActions.removePage(id)), +}); + +export const PageManager = connect(mapStateToProps, mapDispatchToProps)(Component); diff --git a/x-pack/plugins/canvas/public/components/page_manager/page_manager.js b/x-pack/plugins/canvas/public/components/page_manager/page_manager.tsx similarity index 63% rename from x-pack/plugins/canvas/public/components/page_manager/page_manager.js rename to x-pack/plugins/canvas/public/components/page_manager/page_manager.tsx index 3e2ff9dfe2b2..edc0d6201495 100644 --- a/x-pack/plugins/canvas/public/components/page_manager/page_manager.js +++ b/x-pack/plugins/canvas/public/components/page_manager/page_manager.tsx @@ -4,38 +4,63 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment } from 'react'; +import React, { Fragment, Component } from 'react'; import PropTypes from 'prop-types'; import { EuiIcon, EuiFlexGroup, EuiFlexItem, EuiText, EuiToolTip } from '@elastic/eui'; -import { DragDropContext, Droppable, Draggable } from 'react-beautiful-dnd'; +import { DragDropContext, Droppable, Draggable, DragDropContextProps } from 'react-beautiful-dnd'; +// @ts-expect-error untyped dependency import Style from 'style-it'; + import { ConfirmModal } from '../confirm_modal'; import { Link } from '../link'; import { PagePreview } from '../page_preview'; import { ComponentStrings } from '../../../i18n'; +import { CanvasPage } from '../../../types'; const { PageManager: strings } = ComponentStrings; -export class PageManager extends React.PureComponent { +export interface Props { + isWriteable: boolean; + onAddPage: () => void; + onMovePage: (pageId: string, position: number) => void; + onPreviousPage: () => void; + onRemovePage: (pageId: string) => void; + pages: CanvasPage[]; + selectedPage?: string; + workpadCSS?: string; + workpadId: string; +} + +interface State { + showTrayPop: boolean; + removeId: string | null; +} + +export class PageManager extends Component { static propTypes = { isWriteable: PropTypes.bool.isRequired, + onAddPage: PropTypes.func.isRequired, + onMovePage: PropTypes.func.isRequired, + onPreviousPage: PropTypes.func.isRequired, + onRemovePage: PropTypes.func.isRequired, pages: PropTypes.array.isRequired, - workpadId: PropTypes.string.isRequired, - addPage: PropTypes.func.isRequired, - movePage: PropTypes.func.isRequired, - previousPage: PropTypes.func.isRequired, - duplicatePage: PropTypes.func.isRequired, - removePage: PropTypes.func.isRequired, selectedPage: PropTypes.string, - deleteId: PropTypes.string, - setDeleteId: PropTypes.func.isRequired, workpadCSS: PropTypes.string, + workpadId: PropTypes.string.isRequired, }; - state = { - showTrayPop: true, - }; + constructor(props: Props) { + super(props); + this.state = { + showTrayPop: true, + removeId: null, + }; + } + + _isMounted: boolean = false; + _activePageRef: HTMLDivElement | null = null; + _pageListRef: HTMLDivElement | null = null; componentDidMount() { // keep track of whether or not the component is mounted, to prevent rogue setState calls @@ -44,11 +69,13 @@ export class PageManager extends React.PureComponent { // gives the tray pop animation time to finish setTimeout(() => { this.scrollToActivePage(); - this._isMounted && this.setState({ showTrayPop: false }); + if (this._isMounted) { + this.setState({ showTrayPop: false }); + } }, 1000); } - componentDidUpdate(prevProps) { + componentDidUpdate(prevProps: Props) { // scrolls to the active page on the next tick, otherwise new pages don't scroll completely into view if (prevProps.selectedPage !== this.props.selectedPage) { setTimeout(this.scrollToActivePage, 0); @@ -60,33 +87,33 @@ export class PageManager extends React.PureComponent { } scrollToActivePage = () => { - if (this.activePageRef && this.pageListRef) { + if (this._activePageRef && this._pageListRef) { // not all target browsers support element.scrollTo // TODO: replace this with something more cross-browser, maybe scrollIntoView - if (!this.pageListRef.scrollTo) { + if (!this._pageListRef.scrollTo) { return; } - const pageOffset = this.activePageRef.offsetLeft; + const pageOffset = this._activePageRef.offsetLeft; const { left: pageLeft, right: pageRight, width: pageWidth, - } = this.activePageRef.getBoundingClientRect(); + } = this._activePageRef.getBoundingClientRect(); const { left: listLeft, right: listRight, width: listWidth, - } = this.pageListRef.getBoundingClientRect(); + } = this._pageListRef.getBoundingClientRect(); if (pageLeft < listLeft) { - this.pageListRef.scrollTo({ + this._pageListRef.scrollTo({ left: pageOffset, behavior: 'smooth', }); } if (pageRight > listRight) { - this.pageListRef.scrollTo({ + this._pageListRef.scrollTo({ left: pageOffset - listWidth + pageWidth, behavior: 'smooth', }); @@ -94,22 +121,29 @@ export class PageManager extends React.PureComponent { } }; - confirmDelete = (pageId) => { - this._isMounted && this.props.setDeleteId(pageId); + onConfirmRemove = (removeId: string) => { + if (this._isMounted) { + this.setState({ removeId }); + } }; - resetDelete = () => this._isMounted && this.props.setDeleteId(null); + resetRemove = () => this._isMounted && this.setState({ removeId: null }); + + doRemove = () => { + const { onPreviousPage, onRemovePage, selectedPage } = this.props; + const { removeId } = this.state; + this.resetRemove(); + + if (removeId === selectedPage) { + onPreviousPage(); + } - doDelete = () => { - const { previousPage, removePage, deleteId, selectedPage } = this.props; - this.resetDelete(); - if (deleteId === selectedPage) { - previousPage(); + if (removeId !== null) { + onRemovePage(removeId); } - removePage(deleteId); }; - onDragEnd = ({ draggableId: pageId, source, destination }) => { + onDragEnd: DragDropContextProps['onDragEnd'] = ({ draggableId: pageId, source, destination }) => { // dropped outside the list if (!destination) { return; @@ -117,18 +151,11 @@ export class PageManager extends React.PureComponent { const position = destination.index - source.index; - this.props.movePage(pageId, position); + this.props.onMovePage(pageId, position); }; - renderPage = (page, i) => { - const { - isWriteable, - selectedPage, - workpadId, - movePage, - duplicatePage, - workpadCSS, - } = this.props; + renderPage = (page: CanvasPage, i: number) => { + const { isWriteable, selectedPage, workpadId, workpadCSS } = this.props; const pageNumber = i + 1; return ( @@ -141,7 +168,7 @@ export class PageManager extends React.PureComponent { }`} ref={(el) => { if (page.id === selectedPage) { - this.activePageRef = el; + this._activePageRef = el; } provided.innerRef(el); }} @@ -163,16 +190,7 @@ export class PageManager extends React.PureComponent { {Style.it( workpadCSS,
- +
)} @@ -185,8 +203,8 @@ export class PageManager extends React.PureComponent { }; render() { - const { pages, addPage, deleteId, isWriteable } = this.props; - const { showTrayPop } = this.state; + const { pages, onAddPage, isWriteable } = this.props; + const { showTrayPop, removeId } = this.state; return ( @@ -200,7 +218,7 @@ export class PageManager extends React.PureComponent { showTrayPop ? 'canvasPageManager--trayPop' : '' }`} ref={(el) => { - this.pageListRef = el; + this._pageListRef = el; provided.innerRef(el); }} {...provided.droppableProps} @@ -216,11 +234,11 @@ export class PageManager extends React.PureComponent { + + +
+ +
+ +
+
+
+
+ +
+ +
+
+
+
+ + + +
+ + + + +`; diff --git a/x-pack/plugins/canvas/public/components/asset_manager/__examples__/__snapshots__/asset_manager.stories.storyshot b/x-pack/plugins/canvas/public/components/asset_manager/__examples__/__snapshots__/asset_manager.stories.storyshot index 1b8f1480759f..11c5681ebf79 100644 --- a/x-pack/plugins/canvas/public/components/asset_manager/__examples__/__snapshots__/asset_manager.stories.storyshot +++ b/x-pack/plugins/canvas/public/components/asset_manager/__examples__/__snapshots__/asset_manager.stories.storyshot @@ -229,6 +229,545 @@ Array [ ] `; +exports[`Storyshots components/Assets/AssetManager redux 1`] = ` +Array [ +
, +
, +
+
+ +
+
+
+ Manage workpad assets +
+
+
+
+
+ +
+ +
+
+
+
+
+
+
+
+
+

+ Below are the image assets in this workpad. Any assets that are currently in use cannot be determined at this time. To reclaim space, delete assets. +

+
+
+
+
+
+
+
+
+ Asset thumbnail +
+
+
+
+

+ + airplane + +
+ + + ( + 1 + kb) + + +

+
+
+
+
+ + + +
+
+ +
+ +
+
+
+
+ +
+ +
+
+
+
+ + + +
+
+
+
+
+
+
+
+ Asset thumbnail +
+
+
+
+

+ + marker + +
+ + + ( + 1 + kb) + + +

+
+
+
+
+ + + +
+
+ +
+ +
+
+
+
+ +
+ +
+
+
+
+ + + +
+
+
+
+
+
+
+
+
+
+ +
+
+
+ 0% space used +
+
+
+ +
+
+
+
, +
, +] +`; + exports[`Storyshots components/Assets/AssetManager two assets 1`] = ` Array [
{story()}
) - .add('airplane', () => ( - - )) - .add('marker', () => ( - - )); diff --git a/x-pack/plugins/canvas/public/components/asset_manager/__examples__/asset_manager.stories.tsx b/x-pack/plugins/canvas/public/components/asset_manager/__examples__/asset_manager.stories.tsx index cb42823ccab7..1434ef60cf0d 100644 --- a/x-pack/plugins/canvas/public/components/asset_manager/__examples__/asset_manager.stories.tsx +++ b/x-pack/plugins/canvas/public/components/asset_manager/__examples__/asset_manager.stories.tsx @@ -7,42 +7,32 @@ import { action } from '@storybook/addon-actions'; import { storiesOf } from '@storybook/react'; import React from 'react'; -import { AssetType } from '../../../../types'; -import { AssetManager } from '../asset_manager'; -const AIRPLANE: AssetType = { - '@created': '2018-10-13T16:44:44.648Z', - id: 'airplane', - type: 'dataurl', - value: - 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA1Ni4zMSA1Ni4zMSI+PGRlZnM+PHN0eWxlPi5jbHMtMXtmaWxsOiNmZmY7c3Ryb2tlOiMwMDc4YTA7c3Ryb2tlLW1pdGVybGltaXQ6MTA7c3Ryb2tlLXdpZHRoOjJweDt9PC9zdHlsZT48L2RlZnM+PHRpdGxlPlBsYW5lIEljb248L3RpdGxlPjxnIGlkPSJMYXllcl8yIiBkYXRhLW5hbWU9IkxheWVyIDIiPjxnIGlkPSJMYXllcl8xLTIiIGRhdGEtbmFtZT0iTGF5ZXIgMSI+PHBhdGggY2xhc3M9ImNscy0xIiBkPSJNNDkuNTEsNDguOTMsNDEuMjYsMjIuNTIsNTMuNzYsMTBhNS4yOSw1LjI5LDAsMCwwLTcuNDgtNy40N2wtMTIuNSwxMi41TDcuMzgsNi43OUEuNy43LDAsMCwwLDYuNjksN0wxLjIsMTIuNDVhLjcuNywwLDAsMCwwLDFMMTkuODUsMjlsLTcuMjQsNy4yNC03Ljc0LS42YS43MS43MSwwLDAsMC0uNTMuMkwxLjIxLDM5YS42Ny42NywwLDAsMCwuMDgsMUw5LjQ1LDQ2bC4wNywwYy4xMS4xMy4yMi4yNi4zNC4zOHMuMjUuMjMuMzguMzRhLjM2LjM2LDAsMCwwLDAsLjA3TDE2LjMzLDU1YS42OC42OCwwLDAsMCwxLC4wN0wyMC40OSw1MmEuNjcuNjcsMCwwLDAsLjE5LS41NGwtLjU5LTcuNzQsNy4yNC03LjI0TDQyLjg1LDU1LjA2YS42OC42OCwwLDAsMCwxLDBsNS41LTUuNUEuNjYuNjYsMCwwLDAsNDkuNTEsNDguOTNaIi8+PC9nPjwvZz48L3N2Zz4=', -}; +import { AssetManager, AssetManagerComponent } from '../'; -const MARKER: AssetType = { - '@created': '2018-10-13T16:44:44.648Z', - id: 'marker', - type: 'dataurl', - value: - 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzOC4zOSA1Ny41NyI+PGRlZnM+PHN0eWxlPi5jbHMtMXtmaWxsOiNmZmY7c3Ryb2tlOiMwMTliOGY7c3Ryb2tlLW1pdGVybGltaXQ6MTA7c3Ryb2tlLXdpZHRoOjJweDt9PC9zdHlsZT48L2RlZnM+PHRpdGxlPkxvY2F0aW9uIEljb248L3RpdGxlPjxnIGlkPSJMYXllcl8yIiBkYXRhLW5hbWU9IkxheWVyIDIiPjxnIGlkPSJMYXllcl8xLTIiIGRhdGEtbmFtZT0iTGF5ZXIgMSI+PHBhdGggY2xhc3M9ImNscy0xIiBkPSJNMTkuMTksMUExOC4xOSwxOC4xOSwwLDAsMCwyLjk0LDI3LjM2aDBhMTkuNTEsMTkuNTEsMCwwLDAsMSwxLjc4TDE5LjE5LDU1LjU3LDM0LjM4LDI5LjIxQTE4LjE5LDE4LjE5LDAsMCwwLDE5LjE5LDFabTAsMjMuMjlhNS41Myw1LjUzLDAsMSwxLDUuNTMtNS41M0E1LjUzLDUuNTMsMCwwLDEsMTkuMTksMjQuMjlaIi8+PC9nPjwvZz48L3N2Zz4=', -}; +import { Provider, AIRPLANE, MARKER } from './provider'; storiesOf('components/Assets/AssetManager', module) + .add('redux: AssetManager', () => ( + + + + )) .add('no assets', () => ( - + + + )) .add('two assets', () => ( - + + + )); diff --git a/x-pack/plugins/canvas/public/components/asset_manager/__examples__/provider.tsx b/x-pack/plugins/canvas/public/components/asset_manager/__examples__/provider.tsx new file mode 100644 index 000000000000..1cd7562b59c4 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/asset_manager/__examples__/provider.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. + */ + +/* eslint-disable no-console */ + +/* + This Provider is temporary. See https://github.com/elastic/kibana/pull/69357 +*/ + +import React, { FC } from 'react'; +import { applyMiddleware, createStore, Dispatch, Store } from 'redux'; +import thunkMiddleware from 'redux-thunk'; +import { Provider as ReduxProvider } from 'react-redux'; + +// @ts-expect-error untyped local +import { appReady } from '../../../../public/state/middleware/app_ready'; +// @ts-expect-error untyped local +import { resolvedArgs } from '../../../../public/state/middleware/resolved_args'; + +// @ts-expect-error untyped local +import { getRootReducer } from '../../../../public/state/reducers'; + +// @ts-expect-error Untyped local +import { getDefaultWorkpad } from '../../../../public/state/defaults'; +import { State, AssetType } from '../../../../types'; + +export const AIRPLANE: AssetType = { + '@created': '2018-10-13T16:44:44.648Z', + id: 'airplane', + type: 'dataurl', + value: + 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA1Ni4zMSA1Ni4zMSI+PGRlZnM+PHN0eWxlPi5jbHMtMXtmaWxsOiNmZmY7c3Ryb2tlOiMwMDc4YTA7c3Ryb2tlLW1pdGVybGltaXQ6MTA7c3Ryb2tlLXdpZHRoOjJweDt9PC9zdHlsZT48L2RlZnM+PHRpdGxlPlBsYW5lIEljb248L3RpdGxlPjxnIGlkPSJMYXllcl8yIiBkYXRhLW5hbWU9IkxheWVyIDIiPjxnIGlkPSJMYXllcl8xLTIiIGRhdGEtbmFtZT0iTGF5ZXIgMSI+PHBhdGggY2xhc3M9ImNscy0xIiBkPSJNNDkuNTEsNDguOTMsNDEuMjYsMjIuNTIsNTMuNzYsMTBhNS4yOSw1LjI5LDAsMCwwLTcuNDgtNy40N2wtMTIuNSwxMi41TDcuMzgsNi43OUEuNy43LDAsMCwwLDYuNjksN0wxLjIsMTIuNDVhLjcuNywwLDAsMCwwLDFMMTkuODUsMjlsLTcuMjQsNy4yNC03Ljc0LS42YS43MS43MSwwLDAsMC0uNTMuMkwxLjIxLDM5YS42Ny42NywwLDAsMCwuMDgsMUw5LjQ1LDQ2bC4wNywwYy4xMS4xMy4yMi4yNi4zNC4zOHMuMjUuMjMuMzguMzRhLjM2LjM2LDAsMCwwLDAsLjA3TDE2LjMzLDU1YS42OC42OCwwLDAsMCwxLC4wN0wyMC40OSw1MmEuNjcuNjcsMCwwLDAsLjE5LS41NGwtLjU5LTcuNzQsNy4yNC03LjI0TDQyLjg1LDU1LjA2YS42OC42OCwwLDAsMCwxLDBsNS41LTUuNUEuNjYuNjYsMCwwLDAsNDkuNTEsNDguOTNaIi8+PC9nPjwvZz48L3N2Zz4=', +}; + +export const MARKER: AssetType = { + '@created': '2018-10-13T16:44:44.648Z', + id: 'marker', + type: 'dataurl', + value: + 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAzOC4zOSA1Ny41NyI+PGRlZnM+PHN0eWxlPi5jbHMtMXtmaWxsOiNmZmY7c3Ryb2tlOiMwMTliOGY7c3Ryb2tlLW1pdGVybGltaXQ6MTA7c3Ryb2tlLXdpZHRoOjJweDt9PC9zdHlsZT48L2RlZnM+PHRpdGxlPkxvY2F0aW9uIEljb248L3RpdGxlPjxnIGlkPSJMYXllcl8yIiBkYXRhLW5hbWU9IkxheWVyIDIiPjxnIGlkPSJMYXllcl8xLTIiIGRhdGEtbmFtZT0iTGF5ZXIgMSI+PHBhdGggY2xhc3M9ImNscy0xIiBkPSJNMTkuMTksMUExOC4xOSwxOC4xOSwwLDAsMCwyLjk0LDI3LjM2aDBhMTkuNTEsMTkuNTEsMCwwLDAsMSwxLjc4TDE5LjE5LDU1LjU3LDM0LjM4LDI5LjIxQTE4LjE5LDE4LjE5LDAsMCwwLDE5LjE5LDFabTAsMjMuMjlhNS41Myw1LjUzLDAsMSwxLDUuNTMtNS41M0E1LjUzLDUuNTMsMCwwLDEsMTkuMTksMjQuMjlaIi8+PC9nPjwvZz48L3N2Zz4=', +}; + +export const state: State = { + app: { + basePath: '/', + ready: true, + serverFunctions: [], + }, + assets: { + AIRPLANE, + MARKER, + }, + transient: { + canUserWrite: true, + zoomScale: 1, + elementStats: { + total: 0, + ready: 0, + pending: 0, + error: 0, + }, + inFlight: false, + fullScreen: false, + selectedTopLevelNodes: [], + resolvedArgs: {}, + refresh: { + interval: 0, + }, + autoplay: { + enabled: false, + interval: 10000, + }, + }, + persistent: { + schemaVersion: 2, + workpad: getDefaultWorkpad(), + }, +}; + +// @ts-expect-error untyped local +import { elementsRegistry } from '../../../lib/elements_registry'; +import { image } from '../../../../canvas_plugin_src/elements/image'; +elementsRegistry.register(image); + +export const patchDispatch: (store: Store, dispatch: Dispatch) => Dispatch = (store, dispatch) => ( + action +) => { + const previousState = store.getState(); + const returnValue = dispatch(action); + const newState = store.getState(); + + console.group(action.type || '(thunk)'); + console.log('Previous State', previousState); + console.log('New State', newState); + console.groupEnd(); + + return returnValue; +}; + +export const Provider: FC = ({ children }) => { + const middleware = applyMiddleware(thunkMiddleware); + const reducer = getRootReducer(state); + const store = createStore(reducer, state, middleware); + store.dispatch = patchDispatch(store, store.dispatch); + + return {children}; +}; diff --git a/x-pack/plugins/canvas/public/components/asset_manager/asset.component.tsx b/x-pack/plugins/canvas/public/components/asset_manager/asset.component.tsx new file mode 100644 index 000000000000..a04d37cf7f9f --- /dev/null +++ b/x-pack/plugins/canvas/public/components/asset_manager/asset.component.tsx @@ -0,0 +1,147 @@ +/* + * 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, { FC, useState } from 'react'; +import { + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiImage, + EuiPanel, + EuiSpacer, + EuiText, + EuiTextColor, + EuiToolTip, +} from '@elastic/eui'; + +import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; + +import { ConfirmModal } from '../confirm_modal'; +import { Clipboard } from '../clipboard'; +import { Download } from '../download'; +import { AssetType } from '../../../types'; + +import { ComponentStrings } from '../../../i18n'; + +const { Asset: strings } = ComponentStrings; + +interface Props { + /** The asset to be rendered */ + asset: AssetType; + /** The function to execute when the user clicks 'Create' */ + onCreate: (assetId: string) => void; + /** The function to execute when the user clicks 'Delete' */ + onDelete: (asset: AssetType) => void; +} + +export const Asset: FC = ({ asset, onCreate, onDelete }) => { + const { services } = useKibana(); + const [isConfirmModalVisible, setIsConfirmModalVisible] = useState(false); + + const onCopy = (result: boolean) => + result && services.canvas.notify.success(`Copied '${asset.id}' to clipboard`); + + const confirmModal = ( + { + setIsConfirmModalVisible(false); + onDelete(asset); + }} + onCancel={() => setIsConfirmModalVisible(false)} + /> + ); + + const createImage = ( + + + onCreate(asset.id)} + /> + + + ); + + const downloadAsset = ( + + + + + + + + ); + + const copyAsset = ( + + + + + + + + ); + + const deleteAsset = ( + + + setIsConfirmModalVisible(true)} + /> + + + ); + + const thumbnail = ( +
+ +
+ ); + + const assetLabel = ( + +

+ {asset.id} +
+ + ({Math.round(asset.value.length / 1024)} kb) + +

+
+ ); + + return ( + + + {thumbnail} + + {assetLabel} + + + {createImage} + {downloadAsset} + {copyAsset} + {deleteAsset} + + + {isConfirmModalVisible ? confirmModal : null} + + ); +}; diff --git a/x-pack/plugins/canvas/public/components/asset_manager/asset.tsx b/x-pack/plugins/canvas/public/components/asset_manager/asset.tsx index b0eaecc7b520..1a3ce8419aff 100644 --- a/x-pack/plugins/canvas/public/components/asset_manager/asset.tsx +++ b/x-pack/plugins/canvas/public/components/asset_manager/asset.tsx @@ -3,124 +3,59 @@ * 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, - EuiFlexGroup, - EuiFlexItem, - EuiImage, - EuiPanel, - EuiSpacer, - EuiText, - EuiTextColor, - EuiToolTip, -} from '@elastic/eui'; -import React, { FunctionComponent } from 'react'; - -import { ComponentStrings } from '../../../i18n'; - -import { Clipboard } from '../clipboard'; -import { Download } from '../download'; -import { AssetType } from '../../../types'; - -const { Asset: strings } = ComponentStrings; - -interface Props { - /** The asset to be rendered */ - asset: AssetType; - /** The function to execute when the user clicks 'Create' */ - onCreate: (asset: AssetType) => void; - /** The function to execute when the user clicks 'Copy' */ - onCopy: (asset: AssetType) => void; - /** The function to execute when the user clicks 'Delete' */ - onDelete: (asset: AssetType) => void; -} - -export const Asset: FunctionComponent = (props) => { - const { asset, onCreate, onCopy, onDelete } = props; - - const createImage = ( - - - onCreate(asset)} - /> - - - ); - - const downloadAsset = ( - - - - - - - - ); - - const copyAsset = ( - - - result && onCopy(asset)}> - - - - - ); - - const deleteAsset = ( - - - onDelete(asset)} - /> - - - ); - - const thumbnail = ( -
- -
- ); - - const assetLabel = ( - -

- {asset.id} -
- - ({Math.round(asset.value.length / 1024)} kb) - -

-
- ); - - return ( - - - {thumbnail} - - {assetLabel} - - - {createImage} - {downloadAsset} - {copyAsset} - {deleteAsset} - - - - ); -}; +import { Dispatch } from 'redux'; +import { connect } from 'react-redux'; +import { set } from '@elastic/safer-lodash-set'; + +import { fromExpression, toExpression } from '@kbn/interpreter/common'; + +// @ts-expect-error untyped local +import { elementsRegistry } from '../../lib/elements_registry'; +// @ts-expect-error untyped local +import { addElement } from '../../state/actions/elements'; +import { getSelectedPage } from '../../state/selectors/workpad'; +// @ts-expect-error untyped local +import { removeAsset } from '../../state/actions/assets'; +import { State, ExpressionAstExpression, AssetType } from '../../../types'; + +import { Asset as Component } from './asset.component'; + +export const Asset = connect( + (state: State) => ({ + selectedPage: getSelectedPage(state), + }), + (dispatch: Dispatch) => ({ + onCreate: (pageId: string) => (assetId: string) => { + const imageElement = elementsRegistry.get('image'); + const elementAST = fromExpression(imageElement.expression); + const selector = ['chain', '0', 'arguments', 'dataurl']; + const subExp: ExpressionAstExpression[] = [ + { + type: 'expression', + chain: [ + { + type: 'function', + function: 'asset', + arguments: { + _: [assetId], + }, + }, + ], + }, + ]; + const newAST = set(elementAST, selector, subExp); + imageElement.expression = toExpression(newAST); + dispatch(addElement(pageId, imageElement)); + }, + onDelete: (asset: AssetType) => dispatch(removeAsset(asset.id)), + }), + (stateProps, dispatchProps, ownProps) => { + const { onCreate, onDelete } = dispatchProps; + + return { + ...ownProps, + onCreate: onCreate(stateProps.selectedPage), + onDelete, + }; + } +)(Component); diff --git a/x-pack/plugins/canvas/public/components/asset_manager/asset_modal.tsx b/x-pack/plugins/canvas/public/components/asset_manager/asset_manager.component.tsx similarity index 69% rename from x-pack/plugins/canvas/public/components/asset_manager/asset_modal.tsx rename to x-pack/plugins/canvas/public/components/asset_manager/asset_manager.component.tsx index cb61bf1dc26c..98f3d8b48829 100644 --- a/x-pack/plugins/canvas/public/components/asset_manager/asset_modal.tsx +++ b/x-pack/plugins/canvas/public/components/asset_manager/asset_manager.component.tsx @@ -3,6 +3,9 @@ * 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, { FC, useState } from 'react'; +import PropTypes from 'prop-types'; import { EuiButton, EuiEmptyPrompt, @@ -21,48 +24,29 @@ import { EuiSpacer, EuiText, } from '@elastic/eui'; -import PropTypes from 'prop-types'; -import React, { FunctionComponent } from 'react'; - -import { ComponentStrings } from '../../../i18n'; import { ASSET_MAX_SIZE } from '../../../common/lib/constants'; import { Loading } from '../loading'; import { Asset } from './asset'; import { AssetType } from '../../../types'; +import { ComponentStrings } from '../../../i18n'; -const { AssetModal: strings } = ComponentStrings; +const { AssetManager: strings } = ComponentStrings; interface Props { /** The assets to display within the modal */ - assetValues: AssetType[]; - /** Indicates if assets are being loaded */ - isLoading: boolean; + assets: AssetType[]; /** Function to invoke when the modal is closed */ onClose: () => void; - /** Function to invoke when a file is uploaded */ - onFileUpload: (assets: FileList | null) => void; - /** Function to invoke when an asset is copied */ - onAssetCopy: (asset: AssetType) => void; - /** Function to invoke when an asset is created */ - onAssetCreate: (asset: AssetType) => void; - /** Function to invoke when an asset is deleted */ - onAssetDelete: (asset: AssetType) => void; + onAddAsset: (file: File) => void; } -export const AssetModal: FunctionComponent = (props) => { - const { - assetValues, - isLoading, - onAssetCopy, - onAssetCreate, - onAssetDelete, - onClose, - onFileUpload, - } = props; +export const AssetManager: FC = (props) => { + const { assets, onClose, onAddAsset } = props; + const [isLoading, setIsLoading] = useState(false); const assetsTotal = Math.round( - assetValues.reduce((total, { value }) => total + value.length, 0) / 1024 + assets.reduce((total, { value }) => total + value.length, 0) / 1024 ); const percentageUsed = Math.round((assetsTotal / ASSET_MAX_SIZE) * 100); @@ -77,10 +61,22 @@ export const AssetModal: FunctionComponent = (props) => { ); + const onFileUpload = (files: FileList | null) => { + if (files === null) { + return; + } + + setIsLoading(true); + + Promise.all(Array.from(files).map((file) => onAddAsset(file))).finally(() => { + setIsLoading(false); + }); + }; + return ( onClose()} className="canvasAssetManager canvasModal--fixedSize" maxWidth="1000px" > @@ -110,16 +106,10 @@ export const AssetModal: FunctionComponent = (props) => {

{strings.getDescription()}

- {assetValues.length ? ( + {assets.length ? ( - {assetValues.map((asset) => ( - + {assets.map((asset) => ( + ))} ) : ( @@ -143,7 +133,7 @@ export const AssetModal: FunctionComponent = (props) => { - + onClose()}> {strings.getModalCloseButtonLabel()} @@ -152,12 +142,8 @@ export const AssetModal: FunctionComponent = (props) => { ); }; -AssetModal.propTypes = { - assetValues: PropTypes.array, - isLoading: PropTypes.bool, +AssetManager.propTypes = { + assets: PropTypes.arrayOf(PropTypes.object).isRequired, onClose: PropTypes.func.isRequired, - onFileUpload: PropTypes.func.isRequired, - onAssetCopy: PropTypes.func.isRequired, - onAssetCreate: PropTypes.func.isRequired, - onAssetDelete: PropTypes.func.isRequired, + onAddAsset: PropTypes.func.isRequired, }; diff --git a/x-pack/plugins/canvas/public/components/asset_manager/asset_manager.ts b/x-pack/plugins/canvas/public/components/asset_manager/asset_manager.ts new file mode 100644 index 000000000000..f9bcfb266006 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/asset_manager/asset_manager.ts @@ -0,0 +1,70 @@ +/* + * 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 { Dispatch } from 'redux'; +import { connect } from 'react-redux'; +import { get } from 'lodash'; + +import { getId } from '../../lib/get_id'; +// @ts-expect-error untyped local +import { findExistingAsset } from '../../lib/find_existing_asset'; +import { VALID_IMAGE_TYPES } from '../../../common/lib/constants'; +import { encode } from '../../../common/lib/dataurl'; +// @ts-expect-error untyped local +import { elementsRegistry } from '../../lib/elements_registry'; +// @ts-expect-error untyped local +import { addElement } from '../../state/actions/elements'; +import { getAssets } from '../../state/selectors/assets'; +// @ts-expect-error untyped local +import { removeAsset, createAsset } from '../../state/actions/assets'; +import { State, AssetType } from '../../../types'; + +import { AssetManager as Component } from './asset_manager.component'; + +export const AssetManager = connect( + (state: State) => ({ + assets: getAssets(state), + }), + (dispatch: Dispatch) => ({ + onAddAsset: (type: string, content: string) => { + // make the ID here and pass it into the action + const assetId = getId('asset'); + dispatch(createAsset(type, content, assetId)); + + // then return the id, so the caller knows the id that will be created + return assetId; + }, + }), + (stateProps, dispatchProps, ownProps) => { + const { assets } = stateProps; + const { onAddAsset } = dispatchProps; + + // pull values out of assets object + // have to cast to AssetType[] because TS doesn't know about filtering + const assetValues = Object.values(assets).filter((asset) => !!asset) as AssetType[]; + + return { + ...ownProps, + assets: assetValues, + onAddAsset: (file: File) => { + const [type, subtype] = get(file, 'type', '').split('/'); + if (type === 'image' && VALID_IMAGE_TYPES.indexOf(subtype) >= 0) { + return encode(file).then((dataurl) => { + const dataurlType = 'dataurl'; + const existingId = findExistingAsset(dataurlType, dataurl, assetValues); + + if (existingId) { + return existingId; + } + + return onAddAsset(dataurlType, dataurl); + }); + } + + return false; + }, + }; + } +)(Component); diff --git a/x-pack/plugins/canvas/public/components/asset_manager/asset_manager.tsx b/x-pack/plugins/canvas/public/components/asset_manager/asset_manager.tsx deleted file mode 100644 index cb177591fd65..000000000000 --- a/x-pack/plugins/canvas/public/components/asset_manager/asset_manager.tsx +++ /dev/null @@ -1,111 +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 PropTypes from 'prop-types'; -import React, { Fragment, PureComponent } from 'react'; - -import { ComponentStrings } from '../../../i18n'; - -import { ConfirmModal } from '../confirm_modal'; -import { AssetType } from '../../../types'; -import { AssetModal } from './asset_modal'; - -const { AssetManager: strings } = ComponentStrings; - -export interface Props { - /** A list of assets, if available */ - assetValues: AssetType[]; - /** Function to invoke when an asset is selected to be added as an element to the workpad */ - onAddImageElement: (id: string) => void; - /** Function to invoke when an asset is deleted */ - onAssetDelete: (id: string | null) => void; - /** Function to invoke when an asset is copied */ - onAssetCopy: () => void; - /** Function to invoke when an asset is added */ - onAssetAdd: (asset: File) => void; - /** Function to invoke when an asset modal is closed */ - onClose: () => void; -} - -interface State { - /** The id of the asset to delete, if applicable. Is set if the viewer clicks the delete icon */ - deleteId: string | null; - /** Indicates if the modal is currently loading */ - isLoading: boolean; -} - -export class AssetManager extends PureComponent { - public static propTypes = { - assetValues: PropTypes.array, - onAddImageElement: PropTypes.func.isRequired, - onAssetAdd: PropTypes.func.isRequired, - onAssetCopy: PropTypes.func.isRequired, - onAssetDelete: PropTypes.func.isRequired, - onClose: PropTypes.func.isRequired, - }; - - public static defaultProps = { - assetValues: [], - }; - - public state = { - deleteId: null, - isLoading: false, - }; - - public render() { - const { isLoading } = this.state; - const { assetValues, onAssetCopy, onAddImageElement, onClose } = this.props; - - const assetModal = ( - { - onAddImageElement(createdAsset.id); - onClose(); - }} - onAssetDelete={(asset: AssetType) => this.setState({ deleteId: asset.id })} - onClose={onClose} - onFileUpload={this.handleFileUpload} - /> - ); - - const confirmModal = ( - - ); - - return ( - - {assetModal} - {confirmModal} - - ); - } - - private resetDelete = () => this.setState({ deleteId: null }); - - private doDelete = () => { - this.resetDelete(); - this.props.onAssetDelete(this.state.deleteId); - }; - - private handleFileUpload = (files: FileList | null) => { - if (files == null) return; - this.setState({ isLoading: true }); - Promise.all(Array.from(files).map((file) => this.props.onAssetAdd(file))).finally(() => { - this.setState({ isLoading: false }); - }); - }; -} diff --git a/x-pack/plugins/canvas/public/components/asset_manager/index.ts b/x-pack/plugins/canvas/public/components/asset_manager/index.ts index 9b4406f60786..5d586c07f4e4 100644 --- a/x-pack/plugins/canvas/public/components/asset_manager/index.ts +++ b/x-pack/plugins/canvas/public/components/asset_manager/index.ts @@ -4,107 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { connect } from 'react-redux'; -import { compose, withProps } from 'recompose'; -import { set } from '@elastic/safer-lodash-set'; -import { get } from 'lodash'; -import { fromExpression, toExpression } from '@kbn/interpreter/common'; -import { getAssets } from '../../state/selectors/assets'; -// @ts-expect-error untyped local -import { removeAsset, createAsset } from '../../state/actions/assets'; -// @ts-expect-error untyped local -import { elementsRegistry } from '../../lib/elements_registry'; -// @ts-expect-error untyped local -import { addElement } from '../../state/actions/elements'; -import { getSelectedPage } from '../../state/selectors/workpad'; -import { encode } from '../../../common/lib/dataurl'; -import { getId } from '../../lib/get_id'; -// @ts-expect-error untyped local -import { findExistingAsset } from '../../lib/find_existing_asset'; -import { VALID_IMAGE_TYPES } from '../../../common/lib/constants'; -import { withKibana } from '../../../../../../src/plugins/kibana_react/public'; -import { WithKibanaProps } from '../../'; -import { AssetManager as Component, Props as AssetManagerProps } from './asset_manager'; - -import { State, ExpressionAstExpression, AssetType } from '../../../types'; - -const mapStateToProps = (state: State) => ({ - assets: getAssets(state), - selectedPage: getSelectedPage(state), -}); - -const mapDispatchToProps = (dispatch: (action: any) => void) => ({ - onAddImageElement: (pageId: string) => (assetId: string) => { - const imageElement = elementsRegistry.get('image'); - const elementAST = fromExpression(imageElement.expression); - const selector = ['chain', '0', 'arguments', 'dataurl']; - const subExp: ExpressionAstExpression[] = [ - { - type: 'expression', - chain: [ - { - type: 'function', - function: 'asset', - arguments: { - _: [assetId], - }, - }, - ], - }, - ]; - const newAST = set(elementAST, selector, subExp); - imageElement.expression = toExpression(newAST); - dispatch(addElement(pageId, imageElement)); - }, - onAssetAdd: (type: string, content: string) => { - // make the ID here and pass it into the action - const assetId = getId('asset'); - dispatch(createAsset(type, content, assetId)); - - // then return the id, so the caller knows the id that will be created - return assetId; - }, - onAssetDelete: (assetId: string) => dispatch(removeAsset(assetId)), -}); - -const mergeProps = ( - stateProps: ReturnType, - dispatchProps: ReturnType, - ownProps: AssetManagerProps -) => { - const { assets, selectedPage } = stateProps; - const { onAssetAdd } = dispatchProps; - const assetValues = Object.values(assets); // pull values out of assets object - - return { - ...ownProps, - ...dispatchProps, - onAddImageElement: dispatchProps.onAddImageElement(stateProps.selectedPage), - selectedPage, - assetValues, - onAssetAdd: (file: File) => { - const [type, subtype] = get(file, 'type', '').split('/'); - if (type === 'image' && VALID_IMAGE_TYPES.indexOf(subtype) >= 0) { - return encode(file).then((dataurl) => { - const dataurlType = 'dataurl'; - const existingId = findExistingAsset(dataurlType, dataurl, assetValues); - if (existingId) { - return existingId; - } - return onAssetAdd(dataurlType, dataurl); - }); - } - - return false; - }, - }; -}; - -export const AssetManager = compose( - connect(mapStateToProps, mapDispatchToProps, mergeProps), - withKibana, - withProps(({ kibana }: WithKibanaProps) => ({ - onAssetCopy: (asset: AssetType) => - kibana.services.canvas.notify.success(`Copied '${asset.id}' to clipboard`), - })) -)(Component); +export { Asset } from './asset'; +export { Asset as AssetComponent } from './asset.component'; +export { AssetManager } from './asset_manager'; +export { AssetManager as AssetManagerComponent } from './asset_manager.component'; diff --git a/x-pack/plugins/canvas/storybook/storyshots.test.js b/x-pack/plugins/canvas/storybook/storyshots.test.js index ba4013f7cc81..e3a9654bb49f 100644 --- a/x-pack/plugins/canvas/storybook/storyshots.test.js +++ b/x-pack/plugins/canvas/storybook/storyshots.test.js @@ -94,4 +94,5 @@ addSerializer(styleSheetSerializer); initStoryshots({ configPath: path.resolve(__dirname, './../storybook'), test: multiSnapshotWithOptions({}), + storyNameRegex: /^((?!.*?redux).)*$/, }); diff --git a/x-pack/plugins/canvas/storybook/webpack.config.js b/x-pack/plugins/canvas/storybook/webpack.config.js index 1e0e36f79612..927f71b832ba 100644 --- a/x-pack/plugins/canvas/storybook/webpack.config.js +++ b/x-pack/plugins/canvas/storybook/webpack.config.js @@ -47,6 +47,12 @@ module.exports = async ({ config }) => { ], }); + config.module.rules.push({ + test: /\.mjs$/, + include: /node_modules/, + type: 'javascript/auto', + }); + // Parse props data for .tsx files // This is notoriously slow, and is making Storybook unusable. Disabling for now. // See: https://github.com/storybookjs/storybook/issues/7998 @@ -117,6 +123,15 @@ module.exports = async ({ config }) => { ], }); + // Exclude large-dependency modules that need not be included in Storybook. + config.module.rules.push({ + test: [ + path.resolve(__dirname, '../public/components/embeddable_flyout'), + path.resolve(__dirname, '../../reporting/public'), + ], + use: 'null-loader', + }); + // Ensure jQuery is global for Storybook, specifically for the runtime. config.plugins.push( new webpack.ProvidePlugin({ @@ -216,5 +231,7 @@ module.exports = async ({ config }) => { config.resolve.alias.ui = path.resolve(KIBANA_ROOT, 'src/legacy/ui/public'); config.resolve.alias.ng_mock$ = path.resolve(KIBANA_ROOT, 'src/test_utils/public/ng_mock'); + config.resolve.extensions.push('.mjs'); + return config; }; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 4175f76ad7ba..451557ff9309 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -4685,14 +4685,14 @@ "xpack.canvas.argFormArgSimpleForm.requiredTooltip": "この引数は必須です。数値を入力してください。", "xpack.canvas.argFormPendingArgValue.loadingMessage": "読み込み中", "xpack.canvas.argFormSimpleFailure.failureTooltip": "この引数のインターフェースが値を解析できなかったため、フォールバックインプットが使用されています", + "xpack.canvas.asset.confirmModalButtonLabel": "削除", + "xpack.canvas.asset.confirmModalDetail": "このアセットを削除してよろしいですか?", + "xpack.canvas.asset.confirmModalTitle": "アセットの削除", "xpack.canvas.asset.copyAssetTooltip": "ID をクリップボードにコピー", "xpack.canvas.asset.createImageTooltip": "画像エレメントを作成", "xpack.canvas.asset.deleteAssetTooltip": "削除", "xpack.canvas.asset.downloadAssetTooltip": "ダウンロード", "xpack.canvas.asset.thumbnailAltText": "アセットのサムネイル", - "xpack.canvas.assetManager.confirmModalButtonLabel": "削除", - "xpack.canvas.assetManager.confirmModalDetail": "このアセットを削除してよろしいですか?", - "xpack.canvas.assetManager.confirmModalTitle": "アセットの削除", "xpack.canvas.assetManager.manageButtonLabel": "アセットの管理", "xpack.canvas.assetModal.emptyAssetsDescription": "アセットをインポートして開始します", "xpack.canvas.assetModal.filePickerPromptText": "画像を選択するかドラッグ &amp; ドロップしてください", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 33d60dbd1770..3a2ef39d49ec 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -4689,14 +4689,14 @@ "xpack.canvas.argFormArgSimpleForm.requiredTooltip": "此参数为必需,应指定值。", "xpack.canvas.argFormPendingArgValue.loadingMessage": "正在加载", "xpack.canvas.argFormSimpleFailure.failureTooltip": "此参数的接口无法解析该值,因此将使用回退输入", + "xpack.canvas.asset.confirmModalButtonLabel": "删除", + "xpack.canvas.asset.confirmModalDetail": "确定要删除此资产?", + "xpack.canvas.asset.confirmModalTitle": "删除资产", "xpack.canvas.asset.copyAssetTooltip": "将 ID 复制到剪贴板", "xpack.canvas.asset.createImageTooltip": "创建图像元素", "xpack.canvas.asset.deleteAssetTooltip": "删除", "xpack.canvas.asset.downloadAssetTooltip": "下载", "xpack.canvas.asset.thumbnailAltText": "资产缩略图", - "xpack.canvas.assetManager.confirmModalButtonLabel": "删除", - "xpack.canvas.assetManager.confirmModalDetail": "确定要删除此资产?", - "xpack.canvas.assetManager.confirmModalTitle": "删除资产", "xpack.canvas.assetManager.manageButtonLabel": "管理资产", "xpack.canvas.assetModal.emptyAssetsDescription": "导入您的资产以开始", "xpack.canvas.assetModal.filePickerPromptText": "选择或拖放图像", From ba643bd2984fddba32b9dfbab762de1ae13683e8 Mon Sep 17 00:00:00 2001 From: Ryland Herrick Date: Tue, 21 Jul 2020 18:31:54 -0500 Subject: [PATCH 016/142] [Security Solution][Detections] Validate file type of value lists (#72746) * UI validates file type of uploaded value list * file picker itself is restricted to text/csv and text/plain * if they drag/drop an invalid file, we disable the upload button and display an error message * refactors form state to be a File instead of a FileList * Refactor validation and error message in terms of file type Instead of maintaining lists of both valid extensions and valid mime types, we simply use the latter. --- .../form.test.tsx | 27 +++++++++++++-- .../value_lists_management_modal/form.tsx | 33 ++++++++++++++----- .../translations.ts | 6 ++++ 3 files changed, 55 insertions(+), 11 deletions(-) diff --git a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/form.test.tsx b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/form.test.tsx index e2e793b34eaf..591e1c81cd2a 100644 --- a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/form.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/form.test.tsx @@ -16,7 +16,7 @@ const mockUseImportList = useImportList as jest.Mock; const mockFile = ({ name: 'foo.csv', - path: '/home/foo.csv', + type: 'text/csv', } as unknown) as File; const mockSelectFile:

(container: ReactWrapper

, file: File) => Promise = async ( @@ -26,7 +26,7 @@ const mockSelectFile:

(container: ReactWrapper

, file: File) => Promise { if (fileChange) { - fileChange(([file] as unknown) as FormEvent); + fileChange(({ item: () => file } as unknown) as FormEvent); } }); }; @@ -83,6 +83,29 @@ describe('ValueListsForm', () => { expect(onError).toHaveBeenCalledWith('whoops'); }); + it('disables upload and displays an error if file has invalid extension', async () => { + const badMockFile = ({ + name: 'foo.pdf', + type: 'application/pdf', + } as unknown) as File; + + const container = mount( + + + + ); + + await mockSelectFile(container, badMockFile); + + expect( + container.find('button[data-test-subj="value-lists-form-import-action"]').prop('disabled') + ).toEqual(true); + + expect(container.find('div[data-test-subj="value-list-file-picker-row"]').text()).toContain( + 'File must be one of the following types: [text/csv, text/plain]' + ); + }); + it('calls onSuccess if import succeeds', async () => { mockUseImportList.mockImplementation(() => ({ start: jest.fn(), diff --git a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/form.tsx b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/form.tsx index b8416c3242e4..aab665289e80 100644 --- a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/form.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/form.tsx @@ -46,6 +46,7 @@ const options: ListTypeOptions[] = [ ]; const defaultListType: Type = 'keyword'; +const validFileTypes = ['text/csv', 'text/plain']; export interface ValueListsFormProps { onError: (error: Error) => void; @@ -54,23 +55,29 @@ export interface ValueListsFormProps { export const ValueListsFormComponent: React.FC = ({ onError, onSuccess }) => { const ctrl = useRef(new AbortController()); - const [files, setFiles] = useState(null); + const [file, setFile] = useState(null); const [type, setType] = useState(defaultListType); const filePickerRef = useRef(null); const { http } = useKibana().services; const { start: importList, ...importState } = useImportList(); + const fileIsValid = !file || validFileTypes.some((fileType) => file.type === fileType); + // EuiRadioGroup's onChange only infers 'string' from our options const handleRadioChange = useCallback((t: string) => setType(t as Type), [setType]); + const handleFileChange = useCallback((files: FileList | null) => { + setFile(files?.item(0) ?? null); + }, []); + const resetForm = useCallback(() => { if (filePickerRef.current?.fileInput) { filePickerRef.current.fileInput.value = ''; filePickerRef.current.handleChange(); } - setFiles(null); + setFile(null); setType(defaultListType); - }, [setType]); + }, []); const handleCancel = useCallback(() => { ctrl.current.abort(); @@ -91,17 +98,17 @@ export const ValueListsFormComponent: React.FC = ({ onError ); const handleImport = useCallback(() => { - if (!importState.loading && files && files.length) { + if (!importState.loading && file) { ctrl.current = new AbortController(); importList({ - file: files[0], + file, listId: undefined, http, signal: ctrl.current.signal, type, }); } - }, [importState.loading, files, importList, http, type]); + }, [importState.loading, file, importList, http, type]); useEffect(() => { if (!importState.loading && importState.result) { @@ -117,14 +124,22 @@ export const ValueListsFormComponent: React.FC = ({ onError return ( - + @@ -151,7 +166,7 @@ export const ValueListsFormComponent: React.FC = ({ onError {i18n.UPLOAD_BUTTON} diff --git a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/translations.ts b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/translations.ts index dca6e43a9814..91f3f3797f42 100644 --- a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/translations.ts @@ -24,6 +24,12 @@ export const FILE_PICKER_PROMPT = i18n.translate( } ); +export const FILE_PICKER_INVALID_FILE_TYPE = (fileTypes: string): string => + i18n.translate('xpack.securitySolution.lists.uploadValueListExtensionValidationMessage', { + values: { fileTypes }, + defaultMessage: 'File must be one of the following types: [{fileTypes}]', + }); + export const CLOSE_BUTTON = i18n.translate( 'xpack.securitySolution.lists.closeValueListsModalTitle', { From eddc62ad4b9aad30121624e90223e85821f47d3d Mon Sep 17 00:00:00 2001 From: Frank Hassanabad Date: Tue, 21 Jul 2020 17:50:25 -0600 Subject: [PATCH 017/142] [SIEM][Detection Engine][Lists] Adds version and immutability data structures (#72730) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Summary The intent is to get the data structures in similar to rules so that we can have eventually immutable and versioned lists in later releases without too much hassle of upgrading the list and list item data structures. * Adds version and immutability data structures to the exception lists and the value lists. * Adds an optional version number to the update route of each so that you can modify the number either direction or you can omit it and it works like the detection rules where it will auto-increment the number. * Does _not_ add a version and immutability to the exception list items and value list items. * Does _not_ update the version number when you add a new exception list item or value list item. **Examples:** ❯ ./post_list.sh ```json { "_version": "WzAsMV0=", "id": "ip_list", "created_at": "2020-07-21T20:31:11.679Z", "created_by": "yo", "description": "This list describes bad internet ip", "immutable": false, "name": "Simple list with an ip", "tie_breaker_id": "d6bd7552-84d1-4f95-88c4-cc504517b4e5", "type": "ip", "updated_at": "2020-07-21T20:31:11.679Z", "updated_by": "yo", "version": 1 } ``` ❯ ./post_exception_list.sh ```json { "_tags": [ "endpoint", "process", "malware", "os:linux" ], "_version": "WzMzOTgsMV0=", "created_at": "2020-07-21T20:31:35.933Z", "created_by": "yo", "description": "This is a sample endpoint type exception", "id": "2c24b100-cb91-11ea-a872-adfddf68361e", "immutable": false, "list_id": "simple_list", "name": "Sample Endpoint Exception List", "namespace_type": "single", "tags": [ "user added string for a tag", "malware" ], "tie_breaker_id": "c11c4d53-d0be-4904-870e-d33ec7ca387f", "type": "detection", "updated_at": "2020-07-21T20:31:35.952Z", "updated_by": "yo", "version": 1 } ``` ```json ❯ ./update_list.sh { "_version": "WzEsMV0=", "created_at": "2020-07-21T20:31:11.679Z", "created_by": "yo", "description": "Some other description here for you", "id": "ip_list", "immutable": false, "name": "Changed the name here to something else", "tie_breaker_id": "d6bd7552-84d1-4f95-88c4-cc504517b4e5", "type": "ip", "updated_at": "2020-07-21T20:31:47.089Z", "updated_by": "yo", "version": 2 } ``` ```json ❯ ./update_exception_list.sh { "_tags": [ "endpoint", "process", "malware", "os:linux" ], "_version": "WzMzOTksMV0=", "created_at": "2020-07-21T20:31:35.933Z", "created_by": "yo", "description": "Different description", "id": "2c24b100-cb91-11ea-a872-adfddf68361e", "immutable": false, "list_id": "simple_list", "name": "Sample Endpoint Exception List", "namespace_type": "single", "tags": [ "user added string for a tag", "malware" ], "tie_breaker_id": "c11c4d53-d0be-4904-870e-d33ec7ca387f", "type": "endpoint", "updated_at": "2020-07-21T20:31:56.628Z", "updated_by": "yo", "version": 2 } ``` ### Checklist - [x] [Unit or functional tests](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility) were updated or added to match the most common scenarios --- x-pack/plugins/lists/common/constants.mock.ts | 2 ++ .../lists/common/schemas/common/schemas.ts | 12 ++++++++++++ .../index_es_list_schema.mock.ts | 4 ++++ .../elastic_query/index_es_list_schema.ts | 4 ++++ .../search_es_list_schema.mock.ts | 4 ++++ .../elastic_response/search_es_list_schema.ts | 4 ++++ .../create_exception_list_schema.mock.ts | 10 +++++++++- .../request/create_exception_list_schema.ts | 8 +++++++- .../request/create_list_schema.mock.ts | 3 ++- .../schemas/request/create_list_schema.ts | 15 +++++++++++++-- .../schemas/request/patch_list_schema.ts | 12 ++++++++++-- .../request/update_exception_list_schema.ts | 2 ++ .../schemas/request/update_list_schema.ts | 3 ++- .../create_endpoint_list_schema.test.ts | 2 +- .../response/exception_list_schema.mock.ts | 4 ++++ .../schemas/response/exception_list_schema.ts | 4 ++++ .../schemas/response/list_schema.mock.ts | 4 ++++ .../common/schemas/response/list_schema.ts | 4 ++++ .../exceptions_list_so_schema.ts | 7 +++++++ x-pack/plugins/lists/common/shared_imports.ts | 2 ++ .../routes/create_exception_list_route.ts | 3 +++ .../lists/server/routes/create_list_route.ts | 19 ++++++++++++++++--- .../server/routes/import_list_item_route.ts | 2 ++ .../lists/server/routes/patch_list_route.ts | 4 ++-- .../routes/update_exception_list_route.ts | 2 ++ .../lists/server/routes/update_list_route.ts | 4 ++-- .../server/saved_objects/exception_list.ts | 6 ++++++ .../exception_lists/create_endpoint_list.ts | 6 +++++- .../exception_lists/create_exception_list.ts | 8 ++++++++ .../create_exception_list_item.ts | 2 ++ .../exception_lists/exception_list_client.ts | 7 +++++++ .../exception_list_client_types.ts | 6 ++++++ .../exception_lists/update_exception_list.ts | 5 +++++ .../server/services/exception_lists/utils.ts | 18 +++++++++++++++++- .../write_lines_to_bulk_list_items.mock.ts | 2 ++ .../items/write_lines_to_bulk_list_items.ts | 5 +++++ .../server/services/lists/create_list.mock.ts | 4 ++++ .../server/services/lists/create_list.ts | 8 ++++++++ .../lists/create_list_if_it_does_not_exist.ts | 8 ++++++++ .../server/services/lists/list_client.ts | 12 ++++++++++++ .../services/lists/list_client_types.ts | 9 +++++++++ .../server/services/lists/list_mappings.json | 6 ++++++ .../server/services/lists/update_list.mock.ts | 2 ++ .../server/services/lists/update_list.ts | 6 ++++++ .../schemas/types/default_version_number.ts | 2 ++ .../common/shared_exports.ts | 4 ++++ .../autocomplete/field_value_lists.test.tsx | 4 +++- 47 files changed, 255 insertions(+), 19 deletions(-) diff --git a/x-pack/plugins/lists/common/constants.mock.ts b/x-pack/plugins/lists/common/constants.mock.ts index 6ed1d19611c6..4f01d43f47ec 100644 --- a/x-pack/plugins/lists/common/constants.mock.ts +++ b/x-pack/plugins/lists/common/constants.mock.ts @@ -61,3 +61,5 @@ export const COMMENTS = []; export const FILTER = 'name:Nicolas Bourbaki'; export const CURSOR = 'c29tZXN0cmluZ2ZvcnlvdQ=='; export const _VERSION = 'WzI5NywxXQ=='; +export const VERSION = 1; +export const IMMUTABLE = false; diff --git a/x-pack/plugins/lists/common/schemas/common/schemas.ts b/x-pack/plugins/lists/common/schemas/common/schemas.ts index 8f1666bb542d..26511f89c32b 100644 --- a/x-pack/plugins/lists/common/schemas/common/schemas.ts +++ b/x-pack/plugins/lists/common/schemas/common/schemas.ts @@ -311,3 +311,15 @@ export type DeserializerOrUndefined = t.TypeOf; export const _version = t.string; export const _versionOrUndefined = t.union([_version, t.undefined]); export type _VersionOrUndefined = t.TypeOf; + +export const version = t.number; +export type Version = t.TypeOf; + +export const versionOrUndefined = t.union([version, t.undefined]); +export type VersionOrUndefined = t.TypeOf; + +export const immutable = t.boolean; +export type Immutable = t.TypeOf; + +export const immutableOrUndefined = t.union([immutable, t.undefined]); +export type ImmutableOrUndefined = t.TypeOf; diff --git a/x-pack/plugins/lists/common/schemas/elastic_query/index_es_list_schema.mock.ts b/x-pack/plugins/lists/common/schemas/elastic_query/index_es_list_schema.mock.ts index 85a6b1362a58..81cbaea21d6f 100644 --- a/x-pack/plugins/lists/common/schemas/elastic_query/index_es_list_schema.mock.ts +++ b/x-pack/plugins/lists/common/schemas/elastic_query/index_es_list_schema.mock.ts @@ -8,11 +8,13 @@ import { IndexEsListSchema } from '../../../common/schemas'; import { DATE_NOW, DESCRIPTION, + IMMUTABLE, META, NAME, TIE_BREAKER, TYPE, USER, + VERSION, } from '../../../common/constants.mock'; export const getIndexESListMock = (): IndexEsListSchema => ({ @@ -20,6 +22,7 @@ export const getIndexESListMock = (): IndexEsListSchema => ({ created_by: USER, description: DESCRIPTION, deserializer: undefined, + immutable: IMMUTABLE, meta: META, name: NAME, serializer: undefined, @@ -27,4 +30,5 @@ export const getIndexESListMock = (): IndexEsListSchema => ({ type: TYPE, updated_at: DATE_NOW, updated_by: USER, + version: VERSION, }); diff --git a/x-pack/plugins/lists/common/schemas/elastic_query/index_es_list_schema.ts b/x-pack/plugins/lists/common/schemas/elastic_query/index_es_list_schema.ts index 3ee598291149..be41e57f9942 100644 --- a/x-pack/plugins/lists/common/schemas/elastic_query/index_es_list_schema.ts +++ b/x-pack/plugins/lists/common/schemas/elastic_query/index_es_list_schema.ts @@ -13,6 +13,7 @@ import { created_by, description, deserializerOrUndefined, + immutable, metaOrUndefined, name, serializerOrUndefined, @@ -20,6 +21,7 @@ import { type, updated_at, updated_by, + version, } from '../common/schemas'; export const indexEsListSchema = t.exact( @@ -28,6 +30,7 @@ export const indexEsListSchema = t.exact( created_by, description, deserializer: deserializerOrUndefined, + immutable, meta: metaOrUndefined, name, serializer: serializerOrUndefined, @@ -35,6 +38,7 @@ export const indexEsListSchema = t.exact( type, updated_at, updated_by, + version, }) ); diff --git a/x-pack/plugins/lists/common/schemas/elastic_response/search_es_list_schema.mock.ts b/x-pack/plugins/lists/common/schemas/elastic_response/search_es_list_schema.mock.ts index 703d0d0f654a..1562a2192a17 100644 --- a/x-pack/plugins/lists/common/schemas/elastic_response/search_es_list_schema.mock.ts +++ b/x-pack/plugins/lists/common/schemas/elastic_response/search_es_list_schema.mock.ts @@ -10,6 +10,7 @@ import { SearchEsListSchema } from '../../../common/schemas'; import { DATE_NOW, DESCRIPTION, + IMMUTABLE, LIST_ID, LIST_INDEX, META, @@ -17,6 +18,7 @@ import { TIE_BREAKER, TYPE, USER, + VERSION, } from '../../../common/constants.mock'; import { getShardMock } from '../../get_shard.mock'; @@ -25,6 +27,7 @@ export const getSearchEsListMock = (): SearchEsListSchema => ({ created_by: USER, description: DESCRIPTION, deserializer: undefined, + immutable: IMMUTABLE, meta: META, name: NAME, serializer: undefined, @@ -32,6 +35,7 @@ export const getSearchEsListMock = (): SearchEsListSchema => ({ type: TYPE, updated_at: DATE_NOW, updated_by: USER, + version: VERSION, }); export const getSearchListMock = (): SearchResponse => ({ diff --git a/x-pack/plugins/lists/common/schemas/elastic_response/search_es_list_schema.ts b/x-pack/plugins/lists/common/schemas/elastic_response/search_es_list_schema.ts index 46005b81ef68..6807201cf18d 100644 --- a/x-pack/plugins/lists/common/schemas/elastic_response/search_es_list_schema.ts +++ b/x-pack/plugins/lists/common/schemas/elastic_response/search_es_list_schema.ts @@ -13,6 +13,7 @@ import { created_by, description, deserializerOrUndefined, + immutable, metaOrUndefined, name, serializerOrUndefined, @@ -20,6 +21,7 @@ import { type, updated_at, updated_by, + version, } from '../common/schemas'; export const searchEsListSchema = t.exact( @@ -28,6 +30,7 @@ export const searchEsListSchema = t.exact( created_by, description, deserializer: deserializerOrUndefined, + immutable, meta: metaOrUndefined, name, serializer: serializerOrUndefined, @@ -35,6 +38,7 @@ export const searchEsListSchema = t.exact( type, updated_at, updated_by, + version, }) ); diff --git a/x-pack/plugins/lists/common/schemas/request/create_exception_list_schema.mock.ts b/x-pack/plugins/lists/common/schemas/request/create_exception_list_schema.mock.ts index 22a56f7d42b7..d9c047461036 100644 --- a/x-pack/plugins/lists/common/schemas/request/create_exception_list_schema.mock.ts +++ b/x-pack/plugins/lists/common/schemas/request/create_exception_list_schema.mock.ts @@ -4,7 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { DESCRIPTION, ENDPOINT_TYPE, META, NAME, NAMESPACE_TYPE } from '../../constants.mock'; +import { + DESCRIPTION, + ENDPOINT_TYPE, + META, + NAME, + NAMESPACE_TYPE, + VERSION, +} from '../../constants.mock'; import { CreateExceptionListSchema } from './create_exception_list_schema'; @@ -17,4 +24,5 @@ export const getCreateExceptionListSchemaMock = (): CreateExceptionListSchema => namespace_type: NAMESPACE_TYPE, tags: [], type: ENDPOINT_TYPE, + version: VERSION, }); diff --git a/x-pack/plugins/lists/common/schemas/request/create_exception_list_schema.ts b/x-pack/plugins/lists/common/schemas/request/create_exception_list_schema.ts index 8f714760621f..94a4e1588f5a 100644 --- a/x-pack/plugins/lists/common/schemas/request/create_exception_list_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/create_exception_list_schema.ts @@ -21,7 +21,11 @@ import { tags, } from '../common/schemas'; import { RequiredKeepUndefined } from '../../types'; -import { DefaultUuid } from '../../siem_common_deps'; +import { + DefaultUuid, + DefaultVersionNumber, + DefaultVersionNumberDecoded, +} from '../../siem_common_deps'; import { NamespaceType } from '../types'; export const createExceptionListSchema = t.intersection([ @@ -39,6 +43,7 @@ export const createExceptionListSchema = t.intersection([ meta, // defaults to undefined if not set during decode namespace_type, // defaults to 'single' if not set during decode tags, // defaults to empty array if not set during decode + version: DefaultVersionNumber, // defaults to numerical 1 if not set during decode }) ), ]); @@ -54,4 +59,5 @@ export type CreateExceptionListSchemaDecoded = Omit< tags: Tags; list_id: ListId; namespace_type: NamespaceType; + version: DefaultVersionNumberDecoded; }; diff --git a/x-pack/plugins/lists/common/schemas/request/create_list_schema.mock.ts b/x-pack/plugins/lists/common/schemas/request/create_list_schema.mock.ts index 482fabb3b997..461890b944bf 100644 --- a/x-pack/plugins/lists/common/schemas/request/create_list_schema.mock.ts +++ b/x-pack/plugins/lists/common/schemas/request/create_list_schema.mock.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { DESCRIPTION, LIST_ID, META, NAME, TYPE } from '../../constants.mock'; +import { DESCRIPTION, LIST_ID, META, NAME, TYPE, VERSION } from '../../constants.mock'; import { CreateListSchema } from './create_list_schema'; @@ -16,4 +16,5 @@ export const getCreateListSchemaMock = (): CreateListSchema => ({ name: NAME, serializer: undefined, type: TYPE, + version: VERSION, }); diff --git a/x-pack/plugins/lists/common/schemas/request/create_list_schema.ts b/x-pack/plugins/lists/common/schemas/request/create_list_schema.ts index 38d6167ea63f..18ed0f42ccd6 100644 --- a/x-pack/plugins/lists/common/schemas/request/create_list_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/create_list_schema.ts @@ -8,6 +8,7 @@ import * as t from 'io-ts'; import { description, deserializer, id, meta, name, serializer, type } from '../common/schemas'; import { RequiredKeepUndefined } from '../../types'; +import { DefaultVersionNumber, DefaultVersionNumberDecoded } from '../../siem_common_deps'; export const createListSchema = t.intersection([ t.exact( @@ -17,8 +18,18 @@ export const createListSchema = t.intersection([ type, }) ), - t.exact(t.partial({ deserializer, id, meta, serializer })), + t.exact( + t.partial({ + deserializer, // defaults to undefined if not set during decode + id, // defaults to undefined if not set during decode + meta, // defaults to undefined if not set during decode + serializer, // defaults to undefined if not set during decode + version: DefaultVersionNumber, // defaults to a numerical 1 if not set during decode + }) + ), ]); export type CreateListSchema = t.OutputOf; -export type CreateListSchemaDecoded = RequiredKeepUndefined>; +export type CreateListSchemaDecoded = RequiredKeepUndefined< + Omit, 'version'> +> & { version: DefaultVersionNumberDecoded }; diff --git a/x-pack/plugins/lists/common/schemas/request/patch_list_schema.ts b/x-pack/plugins/lists/common/schemas/request/patch_list_schema.ts index e0cd1571afc8..c92abd2e912e 100644 --- a/x-pack/plugins/lists/common/schemas/request/patch_list_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/patch_list_schema.ts @@ -8,7 +8,7 @@ import * as t from 'io-ts'; -import { _version, description, id, meta, name } from '../common/schemas'; +import { _version, description, id, meta, name, version } from '../common/schemas'; import { RequiredKeepUndefined } from '../../types'; export const patchListSchema = t.intersection([ @@ -17,7 +17,15 @@ export const patchListSchema = t.intersection([ id, }) ), - t.exact(t.partial({ _version, description, meta, name })), + t.exact( + t.partial({ + _version, // is undefined if not set during decode + description, // is undefined if not set during decode + meta, // is undefined if not set during decode + name, // is undefined if not set during decode + version, // is undefined if not set during decode + }) + ), ]); export type PatchListSchema = t.OutputOf; diff --git a/x-pack/plugins/lists/common/schemas/request/update_exception_list_schema.ts b/x-pack/plugins/lists/common/schemas/request/update_exception_list_schema.ts index 5d7294ae27af..dd1bc65d1823 100644 --- a/x-pack/plugins/lists/common/schemas/request/update_exception_list_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/update_exception_list_schema.ts @@ -21,6 +21,7 @@ import { name, namespace_type, tags, + version, } from '../common/schemas'; import { RequiredKeepUndefined } from '../../types'; import { NamespaceType } from '../types'; @@ -42,6 +43,7 @@ export const updateExceptionListSchema = t.intersection([ meta, // defaults to undefined if not set during decode namespace_type, // defaults to 'single' if not set during decode tags, // defaults to empty array if not set during decode + version, // defaults to undefined if not set during decode }) ), ]); diff --git a/x-pack/plugins/lists/common/schemas/request/update_list_schema.ts b/x-pack/plugins/lists/common/schemas/request/update_list_schema.ts index 19a39d362c24..a9778f23f130 100644 --- a/x-pack/plugins/lists/common/schemas/request/update_list_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/update_list_schema.ts @@ -8,7 +8,7 @@ import * as t from 'io-ts'; -import { _version, description, id, meta, name } from '../common/schemas'; +import { _version, description, id, meta, name, version } from '../common/schemas'; import { RequiredKeepUndefined } from '../../types'; export const updateListSchema = t.intersection([ @@ -23,6 +23,7 @@ export const updateListSchema = t.intersection([ t.partial({ _version, // defaults to undefined if not set during decode meta, // defaults to undefined if not set during decode + version, // defaults to undefined if not set during decode }) ), ]); diff --git a/x-pack/plugins/lists/common/schemas/response/create_endpoint_list_schema.test.ts b/x-pack/plugins/lists/common/schemas/response/create_endpoint_list_schema.test.ts index 646cc3d97f8e..5fccaaac22e3 100644 --- a/x-pack/plugins/lists/common/schemas/response/create_endpoint_list_schema.test.ts +++ b/x-pack/plugins/lists/common/schemas/response/create_endpoint_list_schema.test.ts @@ -41,7 +41,7 @@ describe('create_endpoint_list_schema', () => { const message = pipe(checked, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ - 'invalid keys "_tags,["endpoint","process","malware","os:linux"],_version,created_at,created_by,description,id,meta,{},name,namespace_type,tags,["user added string for a tag","malware"],tie_breaker_id,type,updated_at,updated_by"', + 'invalid keys "_tags,["endpoint","process","malware","os:linux"],_version,created_at,created_by,description,id,immutable,meta,{},name,namespace_type,tags,["user added string for a tag","malware"],tie_breaker_id,type,updated_at,updated_by,version"', ]); expect(message.schema).toEqual({}); }); diff --git a/x-pack/plugins/lists/common/schemas/response/exception_list_schema.mock.ts b/x-pack/plugins/lists/common/schemas/response/exception_list_schema.mock.ts index f790ad9544d5..2655b09631b2 100644 --- a/x-pack/plugins/lists/common/schemas/response/exception_list_schema.mock.ts +++ b/x-pack/plugins/lists/common/schemas/response/exception_list_schema.mock.ts @@ -8,9 +8,11 @@ import { DATE_NOW, DESCRIPTION, ENDPOINT_TYPE, + IMMUTABLE, META, TIE_BREAKER, USER, + VERSION, _VERSION, } from '../../constants.mock'; import { ENDPOINT_LIST_ID } from '../..'; @@ -23,6 +25,7 @@ export const getExceptionListSchemaMock = (): ExceptionListSchema => ({ created_by: USER, description: DESCRIPTION, id: '1', + immutable: IMMUTABLE, list_id: ENDPOINT_LIST_ID, meta: META, name: 'Sample Endpoint Exception List', @@ -32,4 +35,5 @@ export const getExceptionListSchemaMock = (): ExceptionListSchema => ({ type: ENDPOINT_TYPE, updated_at: DATE_NOW, updated_by: 'user_name', + version: VERSION, }); diff --git a/x-pack/plugins/lists/common/schemas/response/exception_list_schema.ts b/x-pack/plugins/lists/common/schemas/response/exception_list_schema.ts index 11c23bc2ff35..2dbabb0e2bc3 100644 --- a/x-pack/plugins/lists/common/schemas/response/exception_list_schema.ts +++ b/x-pack/plugins/lists/common/schemas/response/exception_list_schema.ts @@ -16,6 +16,7 @@ import { description, exceptionListType, id, + immutable, list_id, metaOrUndefined, name, @@ -24,6 +25,7 @@ import { tie_breaker_id, updated_at, updated_by, + version, } from '../common/schemas'; export const exceptionListSchema = t.exact( @@ -34,6 +36,7 @@ export const exceptionListSchema = t.exact( created_by, description, id, + immutable, list_id, meta: metaOrUndefined, name, @@ -43,6 +46,7 @@ export const exceptionListSchema = t.exact( type: exceptionListType, updated_at, updated_by, + version, }) ); diff --git a/x-pack/plugins/lists/common/schemas/response/list_schema.mock.ts b/x-pack/plugins/lists/common/schemas/response/list_schema.mock.ts index 339beddb00f8..900c7ea4322a 100644 --- a/x-pack/plugins/lists/common/schemas/response/list_schema.mock.ts +++ b/x-pack/plugins/lists/common/schemas/response/list_schema.mock.ts @@ -8,12 +8,14 @@ import { ListSchema } from '../../../common/schemas'; import { DATE_NOW, DESCRIPTION, + IMMUTABLE, LIST_ID, META, NAME, TIE_BREAKER, TYPE, USER, + VERSION, } from '../../../common/constants.mock'; export const getListResponseMock = (): ListSchema => ({ @@ -23,6 +25,7 @@ export const getListResponseMock = (): ListSchema => ({ description: DESCRIPTION, deserializer: undefined, id: LIST_ID, + immutable: IMMUTABLE, meta: META, name: NAME, serializer: undefined, @@ -30,4 +33,5 @@ export const getListResponseMock = (): ListSchema => ({ type: TYPE, updated_at: DATE_NOW, updated_by: USER, + version: VERSION, }); diff --git a/x-pack/plugins/lists/common/schemas/response/list_schema.ts b/x-pack/plugins/lists/common/schemas/response/list_schema.ts index 7e2bc202a652..539c6221fcb0 100644 --- a/x-pack/plugins/lists/common/schemas/response/list_schema.ts +++ b/x-pack/plugins/lists/common/schemas/response/list_schema.ts @@ -15,6 +15,7 @@ import { description, deserializerOrUndefined, id, + immutable, metaOrUndefined, name, serializerOrUndefined, @@ -22,6 +23,7 @@ import { type, updated_at, updated_by, + version, } from '../common/schemas'; export const listSchema = t.exact( @@ -32,6 +34,7 @@ export const listSchema = t.exact( description, deserializer: deserializerOrUndefined, id, + immutable, meta: metaOrUndefined, name, serializer: serializerOrUndefined, @@ -39,6 +42,7 @@ export const listSchema = t.exact( type, updated_at, updated_by, + version, }) ); diff --git a/x-pack/plugins/lists/common/schemas/saved_objects/exceptions_list_so_schema.ts b/x-pack/plugins/lists/common/schemas/saved_objects/exceptions_list_so_schema.ts index 0b61f122463f..2bd2a51ca8c7 100644 --- a/x-pack/plugins/lists/common/schemas/saved_objects/exceptions_list_so_schema.ts +++ b/x-pack/plugins/lists/common/schemas/saved_objects/exceptions_list_so_schema.ts @@ -16,6 +16,7 @@ import { description, exceptionListItemType, exceptionListType, + immutableOrUndefined, itemIdOrUndefined, list_id, list_type, @@ -24,8 +25,12 @@ import { tags, tie_breaker_id, updated_by, + versionOrUndefined, } from '../common/schemas'; +/** + * Superset saved object of both lists and list items since they share the same saved object type. + */ export const exceptionListSoSchema = t.exact( t.type({ _tags, @@ -34,6 +39,7 @@ export const exceptionListSoSchema = t.exact( created_by, description, entries: entriesArrayOrUndefined, + immutable: immutableOrUndefined, item_id: itemIdOrUndefined, list_id, list_type, @@ -43,6 +49,7 @@ export const exceptionListSoSchema = t.exact( tie_breaker_id, type: t.union([exceptionListType, exceptionListItemType]), updated_by, + version: versionOrUndefined, }) ); diff --git a/x-pack/plugins/lists/common/shared_imports.ts b/x-pack/plugins/lists/common/shared_imports.ts index ad7c24b3db61..e5302b5cd5d8 100644 --- a/x-pack/plugins/lists/common/shared_imports.ts +++ b/x-pack/plugins/lists/common/shared_imports.ts @@ -8,6 +8,8 @@ export { NonEmptyString, DefaultUuid, DefaultStringArray, + DefaultVersionNumber, + DefaultVersionNumberDecoded, exactCheck, getPaths, foldLeftRight, diff --git a/x-pack/plugins/lists/server/routes/create_exception_list_route.ts b/x-pack/plugins/lists/server/routes/create_exception_list_route.ts index 897d82d6a9ba..fbe9c6ec9d83 100644 --- a/x-pack/plugins/lists/server/routes/create_exception_list_route.ts +++ b/x-pack/plugins/lists/server/routes/create_exception_list_route.ts @@ -43,6 +43,7 @@ export const createExceptionListRoute = (router: IRouter): void => { description, list_id: listId, type, + version, } = request.body; const exceptionLists = getExceptionListClient(context); const exceptionList = await exceptionLists.getExceptionList({ @@ -59,12 +60,14 @@ export const createExceptionListRoute = (router: IRouter): void => { const createdList = await exceptionLists.createExceptionList({ _tags, description, + immutable: false, listId, meta, name, namespaceType, tags, type, + version, }); const [validated, errors] = validate(createdList, exceptionListSchema); if (errors != null) { diff --git a/x-pack/plugins/lists/server/routes/create_list_route.ts b/x-pack/plugins/lists/server/routes/create_list_route.ts index ff041699054c..297dcfc49db3 100644 --- a/x-pack/plugins/lists/server/routes/create_list_route.ts +++ b/x-pack/plugins/lists/server/routes/create_list_route.ts @@ -9,7 +9,7 @@ import { IRouter } from 'kibana/server'; import { LIST_URL } from '../../common/constants'; import { buildRouteValidation, buildSiemResponse, transformError } from '../siem_server_deps'; import { validate } from '../../common/siem_common_deps'; -import { createListSchema, listSchema } from '../../common/schemas'; +import { CreateListSchemaDecoded, createListSchema, listSchema } from '../../common/schemas'; import { getListClient } from '.'; @@ -21,13 +21,24 @@ export const createListRoute = (router: IRouter): void => { }, path: LIST_URL, validate: { - body: buildRouteValidation(createListSchema), + body: buildRouteValidation( + createListSchema + ), }, }, async (context, request, response) => { const siemResponse = buildSiemResponse(response); try { - const { name, description, deserializer, id, serializer, type, meta } = request.body; + const { + name, + description, + deserializer, + id, + serializer, + type, + meta, + version, + } = request.body; const lists = getListClient(context); const listExists = await lists.getListIndexExists(); if (!listExists) { @@ -49,10 +60,12 @@ export const createListRoute = (router: IRouter): void => { description, deserializer, id, + immutable: false, meta, name, serializer, type, + version, }); const [validated, errors] = validate(list, listSchema); if (errors != null) { diff --git a/x-pack/plugins/lists/server/routes/import_list_item_route.ts b/x-pack/plugins/lists/server/routes/import_list_item_route.ts index 5e88ca0f2569..1003a0c52a79 100644 --- a/x-pack/plugins/lists/server/routes/import_list_item_route.ts +++ b/x-pack/plugins/lists/server/routes/import_list_item_route.ts @@ -55,6 +55,7 @@ export const importListItemRoute = (router: IRouter, config: ConfigType): void = serializer: list.serializer, stream, type: list.type, + version: 1, }); const [validated, errors] = validate(list, listSchema); @@ -71,6 +72,7 @@ export const importListItemRoute = (router: IRouter, config: ConfigType): void = serializer, stream, type, + version: 1, }); if (importedList == null) { return siemResponse.error({ diff --git a/x-pack/plugins/lists/server/routes/patch_list_route.ts b/x-pack/plugins/lists/server/routes/patch_list_route.ts index 681581c6ff6b..421f1279f261 100644 --- a/x-pack/plugins/lists/server/routes/patch_list_route.ts +++ b/x-pack/plugins/lists/server/routes/patch_list_route.ts @@ -27,9 +27,9 @@ export const patchListRoute = (router: IRouter): void => { async (context, request, response) => { const siemResponse = buildSiemResponse(response); try { - const { name, description, id, meta, _version } = request.body; + const { name, description, id, meta, _version, version } = request.body; const lists = getListClient(context); - const list = await lists.updateList({ _version, description, id, meta, name }); + const list = await lists.updateList({ _version, description, id, meta, name, version }); if (list == null) { return siemResponse.error({ body: `list id: "${id}" found found`, diff --git a/x-pack/plugins/lists/server/routes/update_exception_list_route.ts b/x-pack/plugins/lists/server/routes/update_exception_list_route.ts index 403a9f6db934..6fcee81ed573 100644 --- a/x-pack/plugins/lists/server/routes/update_exception_list_route.ts +++ b/x-pack/plugins/lists/server/routes/update_exception_list_route.ts @@ -45,6 +45,7 @@ export const updateExceptionListRoute = (router: IRouter): void => { meta, namespace_type: namespaceType, type, + version, } = request.body; const exceptionLists = getExceptionListClient(context); if (id == null && listId == null) { @@ -64,6 +65,7 @@ export const updateExceptionListRoute = (router: IRouter): void => { namespaceType, tags, type, + version, }); if (list == null) { return siemResponse.error({ diff --git a/x-pack/plugins/lists/server/routes/update_list_route.ts b/x-pack/plugins/lists/server/routes/update_list_route.ts index 78aed23db13f..6206c0943a8f 100644 --- a/x-pack/plugins/lists/server/routes/update_list_route.ts +++ b/x-pack/plugins/lists/server/routes/update_list_route.ts @@ -27,9 +27,9 @@ export const updateListRoute = (router: IRouter): void => { async (context, request, response) => { const siemResponse = buildSiemResponse(response); try { - const { name, description, id, meta, _version } = request.body; + const { name, description, id, meta, _version, version } = request.body; const lists = getListClient(context); - const list = await lists.updateList({ _version, description, id, meta, name }); + const list = await lists.updateList({ _version, description, id, meta, name, version }); if (list == null) { return siemResponse.error({ body: `list id: "${id}" found found`, diff --git a/x-pack/plugins/lists/server/saved_objects/exception_list.ts b/x-pack/plugins/lists/server/saved_objects/exception_list.ts index fc04c5e278d6..3bde3545837c 100644 --- a/x-pack/plugins/lists/server/saved_objects/exception_list.ts +++ b/x-pack/plugins/lists/server/saved_objects/exception_list.ts @@ -30,6 +30,9 @@ export const commonMapping: SavedObjectsType['mappings'] = { description: { type: 'keyword', }, + immutable: { + type: 'boolean', + }, list_id: { type: 'keyword', }, @@ -54,6 +57,9 @@ export const commonMapping: SavedObjectsType['mappings'] = { updated_by: { type: 'keyword', }, + version: { + type: 'keyword', + }, }, }; diff --git a/x-pack/plugins/lists/server/services/exception_lists/create_endpoint_list.ts b/x-pack/plugins/lists/server/services/exception_lists/create_endpoint_list.ts index b9a0194e2007..b596b831f2d6 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/create_endpoint_list.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/create_endpoint_list.ts @@ -12,7 +12,7 @@ import { ENDPOINT_LIST_ID, ENDPOINT_LIST_NAME, } from '../../../common/constants'; -import { ExceptionListSchema, ExceptionListSoSchema } from '../../../common/schemas'; +import { ExceptionListSchema, ExceptionListSoSchema, Version } from '../../../common/schemas'; import { getSavedObjectType, transformSavedObjectToExceptionList } from './utils'; @@ -20,12 +20,14 @@ interface CreateEndpointListOptions { savedObjectsClient: SavedObjectsClientContract; user: string; tieBreaker?: string; + version: Version; } export const createEndpointList = async ({ savedObjectsClient, user, tieBreaker, + version, }: CreateEndpointListOptions): Promise => { const savedObjectType = getSavedObjectType({ namespaceType: 'agnostic' }); const dateNow = new Date().toISOString(); @@ -39,6 +41,7 @@ export const createEndpointList = async ({ created_by: user, description: ENDPOINT_LIST_DESCRIPTION, entries: undefined, + immutable: false, item_id: undefined, list_id: ENDPOINT_LIST_ID, list_type: 'list', @@ -48,6 +51,7 @@ export const createEndpointList = async ({ tie_breaker_id: tieBreaker ?? uuid.v4(), type: 'endpoint', updated_by: user, + version, }, { // We intentionally hard coding the id so that there can only be one exception list within the space diff --git a/x-pack/plugins/lists/server/services/exception_lists/create_exception_list.ts b/x-pack/plugins/lists/server/services/exception_lists/create_exception_list.ts index 4da74c7df48b..c8d709ca340a 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/create_exception_list.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/create_exception_list.ts @@ -12,11 +12,13 @@ import { ExceptionListSchema, ExceptionListSoSchema, ExceptionListType, + Immutable, ListId, MetaOrUndefined, Name, NamespaceType, Tags, + Version, _Tags, } from '../../../common/schemas'; @@ -29,16 +31,19 @@ interface CreateExceptionListOptions { namespaceType: NamespaceType; name: Name; description: Description; + immutable: Immutable; meta: MetaOrUndefined; user: string; tags: Tags; tieBreaker?: string; type: ExceptionListType; + version: Version; } export const createExceptionList = async ({ _tags, listId, + immutable, savedObjectsClient, namespaceType, name, @@ -48,6 +53,7 @@ export const createExceptionList = async ({ tags, tieBreaker, type, + version, }: CreateExceptionListOptions): Promise => { const savedObjectType = getSavedObjectType({ namespaceType }); const dateNow = new Date().toISOString(); @@ -58,6 +64,7 @@ export const createExceptionList = async ({ created_by: user, description, entries: undefined, + immutable, item_id: undefined, list_id: listId, list_type: 'list', @@ -67,6 +74,7 @@ export const createExceptionList = async ({ tie_breaker_id: tieBreaker ?? uuid.v4(), type, updated_by: user, + version, }); return transformSavedObjectToExceptionList({ savedObject }); }; diff --git a/x-pack/plugins/lists/server/services/exception_lists/create_exception_list_item.ts b/x-pack/plugins/lists/server/services/exception_lists/create_exception_list_item.ts index 1acc880c851a..a90ec61aef4a 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/create_exception_list_item.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/create_exception_list_item.ts @@ -72,6 +72,7 @@ export const createExceptionListItem = async ({ created_by: user, description, entries, + immutable: undefined, item_id: itemId, list_id: listId, list_type: 'item', @@ -81,6 +82,7 @@ export const createExceptionListItem = async ({ tie_breaker_id: tieBreaker ?? uuid.v4(), type, updated_by: user, + version: undefined, }); return transformSavedObjectToExceptionListItem({ savedObject }); }; diff --git a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts index 08b1f517036a..11302e64b353 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts @@ -85,6 +85,7 @@ export class ExceptionListClient { return createEndpointList({ savedObjectsClient, user, + version: 1, }); }; @@ -176,17 +177,20 @@ export class ExceptionListClient { public createExceptionList = async ({ _tags, description, + immutable, listId, meta, name, namespaceType, tags, type, + version, }: CreateExceptionListOptions): Promise => { const { savedObjectsClient, user } = this; return createExceptionList({ _tags, description, + immutable, listId, meta, name, @@ -195,6 +199,7 @@ export class ExceptionListClient { tags, type, user, + version, }); }; @@ -209,6 +214,7 @@ export class ExceptionListClient { namespaceType, tags, type, + version, }: UpdateExceptionListOptions): Promise => { const { savedObjectsClient, user } = this; return updateExceptionList({ @@ -224,6 +230,7 @@ export class ExceptionListClient { tags, type, user, + version, }); }; diff --git a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts index b972b6564bb8..51e3a7ee8046 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts @@ -21,6 +21,7 @@ import { ExceptionListTypeOrUndefined, FilterOrUndefined, IdOrUndefined, + Immutable, ItemId, ItemIdOrUndefined, ListId, @@ -36,6 +37,8 @@ import { Tags, TagsOrUndefined, UpdateCommentsArray, + Version, + VersionOrUndefined, _Tags, _TagsOrUndefined, _VersionOrUndefined, @@ -61,6 +64,8 @@ export interface CreateExceptionListOptions { meta: MetaOrUndefined; tags: Tags; type: ExceptionListType; + immutable: Immutable; + version: Version; } export interface UpdateExceptionListOptions { @@ -74,6 +79,7 @@ export interface UpdateExceptionListOptions { meta: MetaOrUndefined; tags: TagsOrUndefined; type: ExceptionListTypeOrUndefined; + version: VersionOrUndefined; } export interface DeleteExceptionListOptions { diff --git a/x-pack/plugins/lists/server/services/exception_lists/update_exception_list.ts b/x-pack/plugins/lists/server/services/exception_lists/update_exception_list.ts index 99c42e56f488..c26ff1bca448 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/update_exception_list.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/update_exception_list.ts @@ -17,6 +17,7 @@ import { NameOrUndefined, NamespaceType, TagsOrUndefined, + VersionOrUndefined, _TagsOrUndefined, _VersionOrUndefined, } from '../../../common/schemas'; @@ -38,6 +39,7 @@ interface UpdateExceptionListOptions { tags: TagsOrUndefined; tieBreaker?: string; type: ExceptionListTypeOrUndefined; + version: VersionOrUndefined; } export const updateExceptionList = async ({ @@ -53,12 +55,14 @@ export const updateExceptionList = async ({ user, tags, type, + version, }: UpdateExceptionListOptions): Promise => { const savedObjectType = getSavedObjectType({ namespaceType }); const exceptionList = await getExceptionList({ id, listId, namespaceType, savedObjectsClient }); if (exceptionList == null) { return null; } else { + const calculatedVersion = version == null ? exceptionList.version + 1 : version; const savedObject = await savedObjectsClient.update( savedObjectType, exceptionList.id, @@ -70,6 +74,7 @@ export const updateExceptionList = async ({ tags, type, updated_by: user, + version: calculatedVersion, }, { version: _version, diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils.ts b/x-pack/plugins/lists/server/services/exception_lists/utils.ts index d5e1965efcc8..b168fae74182 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/utils.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/utils.ts @@ -78,6 +78,7 @@ export const transformSavedObjectToExceptionList = ({ created_at, created_by, description, + immutable, list_id, meta, name, @@ -85,6 +86,7 @@ export const transformSavedObjectToExceptionList = ({ tie_breaker_id, type, updated_by, + version, }, id, updated_at: updatedAt, @@ -99,6 +101,7 @@ export const transformSavedObjectToExceptionList = ({ created_by, description, id, + immutable: immutable ?? false, // This should never be undefined for a list (only a list item) list_id, meta, name, @@ -108,6 +111,7 @@ export const transformSavedObjectToExceptionList = ({ type: exceptionListType.is(type) ? type : 'detection', updated_at: updatedAt ?? dateNow, updated_by, + version: version ?? 1, // This should never be undefined for a list (only a list item) }; }; @@ -121,7 +125,17 @@ export const transformSavedObjectUpdateToExceptionList = ({ const dateNow = new Date().toISOString(); const { version: _version, - attributes: { _tags, description, meta, name, tags, type, updated_by: updatedBy }, + attributes: { + _tags, + description, + immutable, + meta, + name, + tags, + type, + updated_by: updatedBy, + version, + }, id, updated_at: updatedAt, } = savedObject; @@ -135,6 +149,7 @@ export const transformSavedObjectUpdateToExceptionList = ({ created_by: exceptionList.created_by, description: description ?? exceptionList.description, id, + immutable: immutable ?? exceptionList.immutable, list_id: exceptionList.list_id, meta: meta ?? exceptionList.meta, name: name ?? exceptionList.name, @@ -144,6 +159,7 @@ export const transformSavedObjectUpdateToExceptionList = ({ type: exceptionListType.is(type) ? type : exceptionList.type, updated_at: updatedAt ?? dateNow, updated_by: updatedBy ?? exceptionList.updated_by, + version: version ?? exceptionList.version, }; }; diff --git a/x-pack/plugins/lists/server/services/items/write_lines_to_bulk_list_items.mock.ts b/x-pack/plugins/lists/server/services/items/write_lines_to_bulk_list_items.mock.ts index d868351fc4b3..758fabf3d97d 100644 --- a/x-pack/plugins/lists/server/services/items/write_lines_to_bulk_list_items.mock.ts +++ b/x-pack/plugins/lists/server/services/items/write_lines_to_bulk_list_items.mock.ts @@ -12,6 +12,7 @@ import { META, TYPE, USER, + VERSION, } from '../../../common/constants.mock'; import { getConfigMockDecoded } from '../../config.mock'; @@ -29,6 +30,7 @@ export const getImportListItemsToStreamOptionsMock = (): ImportListItemsToStream stream: new TestReadable(), type: TYPE, user: USER, + version: VERSION, }); export const getWriteBufferToItemsOptionsMock = (): WriteBufferToItemsOptions => ({ diff --git a/x-pack/plugins/lists/server/services/items/write_lines_to_bulk_list_items.ts b/x-pack/plugins/lists/server/services/items/write_lines_to_bulk_list_items.ts index 2bffe338e907..c026b247a90a 100644 --- a/x-pack/plugins/lists/server/services/items/write_lines_to_bulk_list_items.ts +++ b/x-pack/plugins/lists/server/services/items/write_lines_to_bulk_list_items.ts @@ -16,6 +16,7 @@ import { MetaOrUndefined, SerializerOrUndefined, Type, + Version, } from '../../../common/schemas'; import { ConfigType } from '../../config'; @@ -34,6 +35,7 @@ export interface ImportListItemsToStreamOptions { type: Type; user: string; meta: MetaOrUndefined; + version: Version; } export const importListItemsToStream = ({ @@ -48,6 +50,7 @@ export const importListItemsToStream = ({ type, user, meta, + version, }: ImportListItemsToStreamOptions): Promise => { return new Promise((resolve) => { const readBuffer = new BufferLines({ bufferSize: config.importBufferSize, input: stream }); @@ -62,12 +65,14 @@ export const importListItemsToStream = ({ description: `File uploaded from file system of ${fileNameEmitted}`, deserializer, id: fileNameEmitted, + immutable: false, listIndex, meta, name: fileNameEmitted, serializer, type, user, + version, }); } readBuffer.resume(); diff --git a/x-pack/plugins/lists/server/services/lists/create_list.mock.ts b/x-pack/plugins/lists/server/services/lists/create_list.mock.ts index 84273ff4cf81..befbe095f2d1 100644 --- a/x-pack/plugins/lists/server/services/lists/create_list.mock.ts +++ b/x-pack/plugins/lists/server/services/lists/create_list.mock.ts @@ -9,6 +9,7 @@ import { CreateListOptions } from '../lists'; import { DATE_NOW, DESCRIPTION, + IMMUTABLE, LIST_ID, LIST_INDEX, META, @@ -16,6 +17,7 @@ import { TIE_BREAKER, TYPE, USER, + VERSION, } from '../../../common/constants.mock'; export const getCreateListOptionsMock = (): CreateListOptions => ({ @@ -24,6 +26,7 @@ export const getCreateListOptionsMock = (): CreateListOptions => ({ description: DESCRIPTION, deserializer: undefined, id: LIST_ID, + immutable: IMMUTABLE, listIndex: LIST_INDEX, meta: META, name: NAME, @@ -31,4 +34,5 @@ export const getCreateListOptionsMock = (): CreateListOptions => ({ tieBreaker: TIE_BREAKER, type: TYPE, user: USER, + version: VERSION, }); diff --git a/x-pack/plugins/lists/server/services/lists/create_list.ts b/x-pack/plugins/lists/server/services/lists/create_list.ts index f97399e6dc13..85214ffb2784 100644 --- a/x-pack/plugins/lists/server/services/lists/create_list.ts +++ b/x-pack/plugins/lists/server/services/lists/create_list.ts @@ -13,12 +13,14 @@ import { Description, DeserializerOrUndefined, IdOrUndefined, + Immutable, IndexEsListSchema, ListSchema, MetaOrUndefined, Name, SerializerOrUndefined, Type, + Version, } from '../../../common/schemas'; export interface CreateListOptions { @@ -34,6 +36,8 @@ export interface CreateListOptions { meta: MetaOrUndefined; dateNow?: string; tieBreaker?: string; + immutable: Immutable; + version: Version; } export const createList = async ({ @@ -49,6 +53,8 @@ export const createList = async ({ meta, dateNow, tieBreaker, + immutable, + version, }: CreateListOptions): Promise => { const createdAt = dateNow ?? new Date().toISOString(); const body: IndexEsListSchema = { @@ -56,6 +62,7 @@ export const createList = async ({ created_by: user, description, deserializer, + immutable, meta, name, serializer, @@ -63,6 +70,7 @@ export const createList = async ({ type, updated_at: createdAt, updated_by: user, + version, }; const response = await callCluster('index', { body, diff --git a/x-pack/plugins/lists/server/services/lists/create_list_if_it_does_not_exist.ts b/x-pack/plugins/lists/server/services/lists/create_list_if_it_does_not_exist.ts index 84f5ac030819..03a59940641c 100644 --- a/x-pack/plugins/lists/server/services/lists/create_list_if_it_does_not_exist.ts +++ b/x-pack/plugins/lists/server/services/lists/create_list_if_it_does_not_exist.ts @@ -10,11 +10,13 @@ import { Description, DeserializerOrUndefined, Id, + Immutable, ListSchema, MetaOrUndefined, Name, SerializerOrUndefined, Type, + Version, } from '../../../common/schemas'; import { getList } from './get_list'; @@ -27,12 +29,14 @@ export interface CreateListIfItDoesNotExistOptions { deserializer: DeserializerOrUndefined; serializer: SerializerOrUndefined; description: Description; + immutable: Immutable; callCluster: LegacyAPICaller; listIndex: string; user: string; meta: MetaOrUndefined; dateNow?: string; tieBreaker?: string; + version: Version; } export const createListIfItDoesNotExist = async ({ @@ -48,6 +52,8 @@ export const createListIfItDoesNotExist = async ({ serializer, dateNow, tieBreaker, + version, + immutable, }: CreateListIfItDoesNotExistOptions): Promise => { const list = await getList({ callCluster, id, listIndex }); if (list == null) { @@ -57,6 +63,7 @@ export const createListIfItDoesNotExist = async ({ description, deserializer, id, + immutable, listIndex, meta, name, @@ -64,6 +71,7 @@ export const createListIfItDoesNotExist = async ({ tieBreaker, type, user, + version, }); } else { return list; diff --git a/x-pack/plugins/lists/server/services/lists/list_client.ts b/x-pack/plugins/lists/server/services/lists/list_client.ts index 9bece64fa943..590bfef6625f 100644 --- a/x-pack/plugins/lists/server/services/lists/list_client.ts +++ b/x-pack/plugins/lists/server/services/lists/list_client.ts @@ -110,11 +110,13 @@ export class ListClient { public createList = async ({ id, deserializer, + immutable, serializer, name, description, type, meta, + version, }: CreateListOptions): Promise => { const { callCluster, user } = this; const listIndex = this.getListIndex(); @@ -123,12 +125,14 @@ export class ListClient { description, deserializer, id, + immutable, listIndex, meta, name, serializer, type, user, + version, }); }; @@ -138,8 +142,10 @@ export class ListClient { serializer, name, description, + immutable, type, meta, + version, }: CreateListIfItDoesNotExistOptions): Promise => { const { callCluster, user } = this; const listIndex = this.getListIndex(); @@ -148,12 +154,14 @@ export class ListClient { description, deserializer, id, + immutable, listIndex, meta, name, serializer, type, user, + version, }); }; @@ -334,6 +342,7 @@ export class ListClient { listId, stream, meta, + version, }: ImportListItemsToStreamOptions): Promise => { const { callCluster, user, config } = this; const listItemIndex = this.getListItemIndex(); @@ -350,6 +359,7 @@ export class ListClient { stream, type, user, + version, }); }; @@ -419,6 +429,7 @@ export class ListClient { name, description, meta, + version, }: UpdateListOptions): Promise => { const { callCluster, user } = this; const listIndex = this.getListIndex(); @@ -431,6 +442,7 @@ export class ListClient { meta, name, user, + version, }); }; diff --git a/x-pack/plugins/lists/server/services/lists/list_client_types.ts b/x-pack/plugins/lists/server/services/lists/list_client_types.ts index 7fa1727be118..ea983b38c7e5 100644 --- a/x-pack/plugins/lists/server/services/lists/list_client_types.ts +++ b/x-pack/plugins/lists/server/services/lists/list_client_types.ts @@ -15,6 +15,7 @@ import { Filter, Id, IdOrUndefined, + Immutable, ListId, ListIdOrUndefined, MetaOrUndefined, @@ -26,6 +27,8 @@ import { SortFieldOrUndefined, SortOrderOrUndefined, Type, + Version, + VersionOrUndefined, _VersionOrUndefined, } from '../../../common/schemas'; import { ConfigType } from '../../config'; @@ -52,11 +55,13 @@ export interface DeleteListItemOptions { export interface CreateListOptions { id: IdOrUndefined; deserializer: DeserializerOrUndefined; + immutable: Immutable; serializer: SerializerOrUndefined; name: Name; description: Description; type: Type; meta: MetaOrUndefined; + version: Version; } export interface CreateListIfItDoesNotExistOptions { @@ -67,6 +72,8 @@ export interface CreateListIfItDoesNotExistOptions { description: Description; type: Type; meta: MetaOrUndefined; + version: Version; + immutable: Immutable; } export interface DeleteListItemByValueOptions { @@ -94,6 +101,7 @@ export interface ImportListItemsToStreamOptions { type: Type; stream: Readable; meta: MetaOrUndefined; + version: Version; } export interface CreateListItemOptions { @@ -119,6 +127,7 @@ export interface UpdateListOptions { name: NameOrUndefined; description: DescriptionOrUndefined; meta: MetaOrUndefined; + version: VersionOrUndefined; } export interface GetListItemOptions { diff --git a/x-pack/plugins/lists/server/services/lists/list_mappings.json b/x-pack/plugins/lists/server/services/lists/list_mappings.json index da9cfec18719..d00b00b6469a 100644 --- a/x-pack/plugins/lists/server/services/lists/list_mappings.json +++ b/x-pack/plugins/lists/server/services/lists/list_mappings.json @@ -34,6 +34,12 @@ }, "updated_by": { "type": "keyword" + }, + "version": { + "type": "keyword" + }, + "immutable": { + "type": "boolean" } } } diff --git a/x-pack/plugins/lists/server/services/lists/update_list.mock.ts b/x-pack/plugins/lists/server/services/lists/update_list.mock.ts index fc3d63277c5b..dd33c85aca98 100644 --- a/x-pack/plugins/lists/server/services/lists/update_list.mock.ts +++ b/x-pack/plugins/lists/server/services/lists/update_list.mock.ts @@ -13,6 +13,7 @@ import { META, NAME, USER, + VERSION, } from '../../../common/constants.mock'; export const getUpdateListOptionsMock = (): UpdateListOptions => ({ @@ -25,4 +26,5 @@ export const getUpdateListOptionsMock = (): UpdateListOptions => ({ meta: META, name: NAME, user: USER, + version: VERSION, }); diff --git a/x-pack/plugins/lists/server/services/lists/update_list.ts b/x-pack/plugins/lists/server/services/lists/update_list.ts index fba57ca744f9..67d44be2ae1a 100644 --- a/x-pack/plugins/lists/server/services/lists/update_list.ts +++ b/x-pack/plugins/lists/server/services/lists/update_list.ts @@ -16,6 +16,7 @@ import { MetaOrUndefined, NameOrUndefined, UpdateEsListSchema, + VersionOrUndefined, _VersionOrUndefined, } from '../../../common/schemas'; @@ -31,6 +32,7 @@ export interface UpdateListOptions { description: DescriptionOrUndefined; meta: MetaOrUndefined; dateNow?: string; + version: VersionOrUndefined; } export const updateList = async ({ @@ -43,12 +45,14 @@ export const updateList = async ({ user, meta, dateNow, + version, }: UpdateListOptions): Promise => { const updatedAt = dateNow ?? new Date().toISOString(); const list = await getList({ callCluster, id, listIndex }); if (list == null) { return null; } else { + const calculatedVersion = version == null ? list.version + 1 : version; const doc: UpdateEsListSchema = { description, meta, @@ -70,6 +74,7 @@ export const updateList = async ({ description: description ?? list.description, deserializer: list.deserializer, id: response._id, + immutable: list.immutable, meta, name: name ?? list.name, serializer: list.serializer, @@ -77,6 +82,7 @@ export const updateList = async ({ type: list.type, updated_at: updatedAt, updated_by: user, + version: calculatedVersion, }; } }; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_version_number.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_version_number.ts index bbba7c5b8f3b..a2f5ca3da1b7 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_version_number.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_version_number.ts @@ -19,3 +19,5 @@ export const DefaultVersionNumber = new t.Type; diff --git a/x-pack/plugins/security_solution/common/shared_exports.ts b/x-pack/plugins/security_solution/common/shared_exports.ts index 1b5b17ef35ca..bd1086a3f21e 100644 --- a/x-pack/plugins/security_solution/common/shared_exports.ts +++ b/x-pack/plugins/security_solution/common/shared_exports.ts @@ -7,6 +7,10 @@ export { NonEmptyString } from './detection_engine/schemas/types/non_empty_string'; export { DefaultUuid } from './detection_engine/schemas/types/default_uuid'; export { DefaultStringArray } from './detection_engine/schemas/types/default_string_array'; +export { + DefaultVersionNumber, + DefaultVersionNumberDecoded, +} from './detection_engine/schemas/types/default_version_number'; export { exactCheck } from './exact_check'; export { getPaths, foldLeftRight } from './test_utils'; export { validate, validateEither } from './validate'; diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_lists.test.tsx b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_lists.test.tsx index 1ff5d770521f..90e195b6e95a 100644 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_lists.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_lists.test.tsx @@ -15,7 +15,7 @@ import { getField } from '../../../../../../../src/plugins/data/common/index_pat import { ListSchema } from '../../../lists_plugin_deps'; import { getFoundListSchemaMock } from '../../../../../lists/common/schemas/response/found_list_schema.mock'; import { getListResponseMock } from '../../../../../lists/common/schemas/response/list_schema.mock'; -import { DATE_NOW } from '../../../../../lists/common/constants.mock'; +import { DATE_NOW, VERSION, IMMUTABLE } from '../../../../../lists/common/constants.mock'; import { AutocompleteFieldListsComponent } from './field_value_lists'; @@ -221,6 +221,8 @@ describe('AutocompleteFieldListsComponent', () => { type: 'ip', updated_at: DATE_NOW, updated_by: 'some user', + version: VERSION, + immutable: IMMUTABLE, }); }); }); From 073bd66a8612f3f453aedbe0b8e0f29afb9766bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Patryk=20Kopyci=C5=84ski?= Date: Wed, 22 Jul 2020 02:18:28 +0200 Subject: [PATCH 018/142] [Detections] Add validation for Threshold value field (#72611) --- .../rules/step_define_rule/schema.tsx | 14 ++++++++ .../bulk_create_threshold_signals.test.ts | 35 +++++++++++++++++++ .../signals/bulk_create_threshold_signals.ts | 2 +- 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/schema.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/schema.tsx index 67d795ccf90f..333b28bf27bb 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/schema.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/schema.tsx @@ -202,6 +202,20 @@ export const schema: FormSchema = { defaultMessage: 'Threshold', } ), + validations: [ + { + validator: fieldValidators.numberGreaterThanField({ + than: 1, + message: i18n.translate( + 'xpack.securitySolution.detectionEngine.validations.thresholdValueFieldData.numberGreaterThanOrEqualOneErrorMessage', + { + defaultMessage: 'Value must be greater than or equal one.', + } + ), + allowEquality: true, + }), + }, + ], }, }, }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/bulk_create_threshold_signals.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/bulk_create_threshold_signals.test.ts index 744e2b0c06ef..d97dc4ba2cbd 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/bulk_create_threshold_signals.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/bulk_create_threshold_signals.test.ts @@ -193,4 +193,39 @@ describe('getThresholdSignalQueryFields', () => { 'event.dataset': 'traefik.access', }); }); + + it('should return proper object for exists filters', () => { + const filters = { + bool: { + should: [ + { + bool: { + should: [ + { + exists: { + field: 'process.name', + }, + }, + ], + minimum_should_match: 1, + }, + }, + { + bool: { + should: [ + { + exists: { + field: 'event.type', + }, + }, + ], + minimum_should_match: 1, + }, + }, + ], + minimum_should_match: 1, + }, + }; + expect(getThresholdSignalQueryFields(filters)).toEqual({}); + }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/bulk_create_threshold_signals.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/bulk_create_threshold_signals.ts index ef9fbe485b92..e2f3d16bd6d0 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/bulk_create_threshold_signals.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/bulk_create_threshold_signals.ts @@ -83,7 +83,7 @@ export const getThresholdSignalQueryFields = (filter: unknown) => { return { ...acc, ...item.match_phrase }; } - if (item.bool.should && (item.bool.should[0].match || item.bool.should[0].match_phrase)) { + if (item.bool?.should && (item.bool.should[0].match || item.bool.should[0].match_phrase)) { return { ...acc, ...(item.bool.should[0].match || item.bool.should[0].match_phrase) }; } From 9c7d65cfc2ad88282a52654265c7d64e0442929f Mon Sep 17 00:00:00 2001 From: Yara Tercero Date: Tue, 21 Jul 2020 21:00:46 -0400 Subject: [PATCH 019/142] [Security Solution][Exceptions] - Require non empty entries and non empty string values in exception list items (#72748) ## Summary This PR updates the exception list entries schemas. - **Prior:** `entries` could be `undefined` or empty array on `ExceptionListItemSchema` - **Now:** `entries` is a required field that cannot be empty - there's really no use for an item without `entries` - **Prior:** `field` and `value` could be empty string in `EntryMatch` - **Now:** `field` and `value` can no longer be empty strings - **Prior:** `field` could be empty string and `value` could be empty array in `EntryMatchAny` - **Now:** `field` and `value` can no longer be empty string and array respectively - **Prior:** `field` and `list.id` could be empty string in `EntryList` - **Now:** `field` and `list.id` can no longer be empty strings - **Prior:** `field` could be empty string in `EntryExists` - **Now:** `field` can no longer be empty string - **Prior:** `field` could be empty string in `EntryNested` - **Now:** `field` can no longer be empty string - **Prior:** `entries` could be empty array in `EntryNested` - **Now:** `entries` can no longer be empty array --- .../create_endpoint_list_item_schema.test.ts | 8 +- .../create_endpoint_list_item_schema.ts | 4 +- .../create_exception_list_item_schema.test.ts | 8 +- .../create_exception_list_item_schema.ts | 4 +- .../update_endpoint_list_item_schema.test.ts | 8 +- .../update_endpoint_list_item_schema.ts | 4 +- .../update_exception_list_item_schema.test.ts | 8 +- .../update_exception_list_item_schema.ts | 4 +- .../types/default_entries_array.test.ts | 99 ------ .../schemas/types/default_entries_array.ts | 22 -- .../common/schemas/types/entries.mock.ts | 70 +--- .../common/schemas/types/entries.test.ts | 334 ++++-------------- .../lists/common/schemas/types/entries.ts | 57 +-- .../common/schemas/types/entry_exists.mock.ts | 15 + .../common/schemas/types/entry_exists.test.ts | 79 +++++ .../common/schemas/types/entry_exists.ts | 21 ++ .../common/schemas/types/entry_list.mock.ts | 16 + .../common/schemas/types/entry_list.test.ts | 95 +++++ .../lists/common/schemas/types/entry_list.ts | 22 ++ .../common/schemas/types/entry_match.mock.ts | 16 + .../common/schemas/types/entry_match.test.ts | 107 ++++++ .../lists/common/schemas/types/entry_match.ts | 22 ++ .../schemas/types/entry_match_any.mock.ts | 16 + .../schemas/types/entry_match_any.test.ts | 105 ++++++ .../common/schemas/types/entry_match_any.ts | 24 ++ .../common/schemas/types/entry_nested.mock.ts | 17 + .../common/schemas/types/entry_nested.test.ts | 124 +++++++ .../common/schemas/types/entry_nested.ts | 22 ++ .../lists/common/schemas/types/index.ts | 9 +- .../types/non_empty_entries_array.test.ts | 123 +++++++ .../schemas/types/non_empty_entries_array.ts | 31 ++ .../non_empty_nested_entries_array.test.ts | 131 +++++++ .../types/non_empty_nested_entries_array.ts | 40 +++ ...non_empty_or_nullable_string_array.test.ts | 69 ++++ .../non_empty_or_nullable_string_array.ts | 34 ++ .../new/exception_list_item.json | 2 +- .../exception_list_client_types.ts | 5 +- .../update_exception_list_item.ts | 4 +- .../build_exceptions_query.test.ts | 95 ++++- .../build_exceptions_query.ts | 2 +- .../common/components/autocomplete/field.tsx | 7 +- .../autocomplete/field_value_lists.tsx | 5 + .../autocomplete/field_value_match.tsx | 8 +- .../autocomplete/field_value_match_any.tsx | 8 +- .../components/autocomplete/helpers.test.ts | 8 +- .../common/components/autocomplete/helpers.ts | 2 +- .../builder/builder_exception_item.test.tsx | 6 +- .../exceptions/builder/entry_item.tsx | 4 + .../components/exceptions/helpers.test.tsx | 107 +++++- .../common/components/exceptions/helpers.tsx | 16 +- .../endpoint/lib/artifacts/lists.test.ts | 2 +- .../server/endpoint/lib/artifacts/lists.ts | 2 +- 52 files changed, 1481 insertions(+), 570 deletions(-) delete mode 100644 x-pack/plugins/lists/common/schemas/types/default_entries_array.test.ts delete mode 100644 x-pack/plugins/lists/common/schemas/types/default_entries_array.ts create mode 100644 x-pack/plugins/lists/common/schemas/types/entry_exists.mock.ts create mode 100644 x-pack/plugins/lists/common/schemas/types/entry_exists.test.ts create mode 100644 x-pack/plugins/lists/common/schemas/types/entry_exists.ts create mode 100644 x-pack/plugins/lists/common/schemas/types/entry_list.mock.ts create mode 100644 x-pack/plugins/lists/common/schemas/types/entry_list.test.ts create mode 100644 x-pack/plugins/lists/common/schemas/types/entry_list.ts create mode 100644 x-pack/plugins/lists/common/schemas/types/entry_match.mock.ts create mode 100644 x-pack/plugins/lists/common/schemas/types/entry_match.test.ts create mode 100644 x-pack/plugins/lists/common/schemas/types/entry_match.ts create mode 100644 x-pack/plugins/lists/common/schemas/types/entry_match_any.mock.ts create mode 100644 x-pack/plugins/lists/common/schemas/types/entry_match_any.test.ts create mode 100644 x-pack/plugins/lists/common/schemas/types/entry_match_any.ts create mode 100644 x-pack/plugins/lists/common/schemas/types/entry_nested.mock.ts create mode 100644 x-pack/plugins/lists/common/schemas/types/entry_nested.test.ts create mode 100644 x-pack/plugins/lists/common/schemas/types/entry_nested.ts create mode 100644 x-pack/plugins/lists/common/schemas/types/non_empty_entries_array.test.ts create mode 100644 x-pack/plugins/lists/common/schemas/types/non_empty_entries_array.ts create mode 100644 x-pack/plugins/lists/common/schemas/types/non_empty_nested_entries_array.test.ts create mode 100644 x-pack/plugins/lists/common/schemas/types/non_empty_nested_entries_array.ts create mode 100644 x-pack/plugins/lists/common/schemas/types/non_empty_or_nullable_string_array.test.ts create mode 100644 x-pack/plugins/lists/common/schemas/types/non_empty_or_nullable_string_array.ts diff --git a/x-pack/plugins/lists/common/schemas/request/create_endpoint_list_item_schema.test.ts b/x-pack/plugins/lists/common/schemas/request/create_endpoint_list_item_schema.test.ts index 916e8db48345..5de9fbb0d5b5 100644 --- a/x-pack/plugins/lists/common/schemas/request/create_endpoint_list_item_schema.test.ts +++ b/x-pack/plugins/lists/common/schemas/request/create_endpoint_list_item_schema.test.ts @@ -142,7 +142,7 @@ describe('create_endpoint_list_item_schema', () => { expect(message.schema).toEqual({}); }); - test('it should validate an undefined for "entries" but return an array', () => { + test('it should NOT validate an undefined for "entries"', () => { const inputPayload = getCreateEndpointListItemSchemaMock(); const outputPayload = getCreateEndpointListItemSchemaMock(); delete inputPayload.entries; @@ -151,8 +151,10 @@ describe('create_endpoint_list_item_schema', () => { const checked = exactCheck(inputPayload, decoded); const message = pipe(checked, foldLeftRight); delete (message.schema as CreateEndpointListItemSchema).item_id; - expect(getPaths(left(message.errors))).toEqual([]); - expect(message.schema).toEqual(outputPayload); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "entries"', + ]); + expect(message.schema).toEqual({}); }); test('it should validate an undefined for "tags" but return an array and generate a correct body not counting the auto generated uuid', () => { diff --git a/x-pack/plugins/lists/common/schemas/request/create_endpoint_list_item_schema.ts b/x-pack/plugins/lists/common/schemas/request/create_endpoint_list_item_schema.ts index 3f0e1a12894d..ab30e8e35548 100644 --- a/x-pack/plugins/lists/common/schemas/request/create_endpoint_list_item_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/create_endpoint_list_item_schema.ts @@ -20,7 +20,7 @@ import { tags, } from '../common/schemas'; import { RequiredKeepUndefined } from '../../types'; -import { CreateCommentsArray, DefaultCreateCommentsArray, DefaultEntryArray } from '../types'; +import { CreateCommentsArray, DefaultCreateCommentsArray, nonEmptyEntriesArray } from '../types'; import { EntriesArray } from '../types/entries'; import { DefaultUuid } from '../../siem_common_deps'; @@ -28,6 +28,7 @@ export const createEndpointListItemSchema = t.intersection([ t.exact( t.type({ description, + entries: nonEmptyEntriesArray, name, type: exceptionListItemType, }) @@ -36,7 +37,6 @@ export const createEndpointListItemSchema = t.intersection([ t.partial({ _tags, // defaults to empty array if not set during decode comments: DefaultCreateCommentsArray, // defaults to empty array if not set during decode - entries: DefaultEntryArray, // defaults to empty array if not set during decode item_id: DefaultUuid, // defaults to GUID (uuid v4) if not set during decode meta, // defaults to undefined if not set during decode tags, // defaults to empty array if not set during decode diff --git a/x-pack/plugins/lists/common/schemas/request/create_exception_list_item_schema.test.ts b/x-pack/plugins/lists/common/schemas/request/create_exception_list_item_schema.test.ts index 34551b74d8c9..08f3966af08d 100644 --- a/x-pack/plugins/lists/common/schemas/request/create_exception_list_item_schema.test.ts +++ b/x-pack/plugins/lists/common/schemas/request/create_exception_list_item_schema.test.ts @@ -130,7 +130,7 @@ describe('create_exception_list_item_schema', () => { expect(message.schema).toEqual({}); }); - test('it should validate an undefined for "entries" but return an array', () => { + test('it should NOT validate an undefined for "entries"', () => { const inputPayload = getCreateExceptionListItemSchemaMock(); const outputPayload = getCreateExceptionListItemSchemaMock(); delete inputPayload.entries; @@ -139,8 +139,10 @@ describe('create_exception_list_item_schema', () => { const checked = exactCheck(inputPayload, decoded); const message = pipe(checked, foldLeftRight); delete (message.schema as CreateExceptionListItemSchema).item_id; - expect(getPaths(left(message.errors))).toEqual([]); - expect(message.schema).toEqual(outputPayload); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "entries"', + ]); + expect(message.schema).toEqual({}); }); test('it should validate an undefined for "namespace_type" but return enum "single" and generate a correct body not counting the auto generated uuid', () => { diff --git a/x-pack/plugins/lists/common/schemas/request/create_exception_list_item_schema.ts b/x-pack/plugins/lists/common/schemas/request/create_exception_list_item_schema.ts index c2ccf18ed872..c3f41cac90c6 100644 --- a/x-pack/plugins/lists/common/schemas/request/create_exception_list_item_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/create_exception_list_item_schema.ts @@ -25,8 +25,8 @@ import { RequiredKeepUndefined } from '../../types'; import { CreateCommentsArray, DefaultCreateCommentsArray, - DefaultEntryArray, NamespaceType, + nonEmptyEntriesArray, } from '../types'; import { EntriesArray } from '../types/entries'; import { DefaultUuid } from '../../siem_common_deps'; @@ -35,6 +35,7 @@ export const createExceptionListItemSchema = t.intersection([ t.exact( t.type({ description, + entries: nonEmptyEntriesArray, list_id, name, type: exceptionListItemType, @@ -44,7 +45,6 @@ export const createExceptionListItemSchema = t.intersection([ t.partial({ _tags, // defaults to empty array if not set during decode comments: DefaultCreateCommentsArray, // defaults to empty array if not set during decode - entries: DefaultEntryArray, // defaults to empty array if not set during decode item_id: DefaultUuid, // defaults to GUID (uuid v4) if not set during decode meta, // defaults to undefined if not set during decode namespace_type, // defaults to 'single' if not set during decode diff --git a/x-pack/plugins/lists/common/schemas/request/update_endpoint_list_item_schema.test.ts b/x-pack/plugins/lists/common/schemas/request/update_endpoint_list_item_schema.test.ts index 838cb81d84c1..db5bc45ad028 100644 --- a/x-pack/plugins/lists/common/schemas/request/update_endpoint_list_item_schema.test.ts +++ b/x-pack/plugins/lists/common/schemas/request/update_endpoint_list_item_schema.test.ts @@ -97,7 +97,7 @@ describe('update_endpoint_list_item_schema', () => { expect(message.schema).toEqual(outputPayload); }); - test('it should accept an undefined for "entries" but return an array', () => { + test('it should NOT accept an undefined for "entries"', () => { const inputPayload = getUpdateEndpointListItemSchemaMock(); const outputPayload = getUpdateEndpointListItemSchemaMock(); delete inputPayload.entries; @@ -105,8 +105,10 @@ describe('update_endpoint_list_item_schema', () => { const decoded = updateEndpointListItemSchema.decode(inputPayload); const checked = exactCheck(inputPayload, decoded); const message = pipe(checked, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual([]); - expect(message.schema).toEqual(outputPayload); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "entries"', + ]); + expect(message.schema).toEqual({}); }); test('it should accept an undefined for "tags" but return an array', () => { diff --git a/x-pack/plugins/lists/common/schemas/request/update_endpoint_list_item_schema.ts b/x-pack/plugins/lists/common/schemas/request/update_endpoint_list_item_schema.ts index 4430aa98b8e3..5bf0cb3b7984 100644 --- a/x-pack/plugins/lists/common/schemas/request/update_endpoint_list_item_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/update_endpoint_list_item_schema.ts @@ -22,16 +22,17 @@ import { } from '../common/schemas'; import { RequiredKeepUndefined } from '../../types'; import { - DefaultEntryArray, DefaultUpdateCommentsArray, EntriesArray, UpdateCommentsArray, + nonEmptyEntriesArray, } from '../types'; export const updateEndpointListItemSchema = t.intersection([ t.exact( t.type({ description, + entries: nonEmptyEntriesArray, name, type: exceptionListItemType, }) @@ -41,7 +42,6 @@ export const updateEndpointListItemSchema = t.intersection([ _tags, // defaults to empty array if not set during decode _version, // defaults to undefined if not set during decode comments: DefaultUpdateCommentsArray, // defaults to empty array if not set during decode - entries: DefaultEntryArray, // defaults to empty array if not set during decode id, // defaults to undefined if not set during decode item_id: t.union([t.string, t.undefined]), meta, // defaults to undefined if not set during decode diff --git a/x-pack/plugins/lists/common/schemas/request/update_exception_list_item_schema.test.ts b/x-pack/plugins/lists/common/schemas/request/update_exception_list_item_schema.test.ts index 2592e44888ff..ce589fb097a6 100644 --- a/x-pack/plugins/lists/common/schemas/request/update_exception_list_item_schema.test.ts +++ b/x-pack/plugins/lists/common/schemas/request/update_exception_list_item_schema.test.ts @@ -97,7 +97,7 @@ describe('update_exception_list_item_schema', () => { expect(message.schema).toEqual(outputPayload); }); - test('it should accept an undefined for "entries" but return an array', () => { + test('it should NOT accept an undefined for "entries"', () => { const inputPayload = getUpdateExceptionListItemSchemaMock(); const outputPayload = getUpdateExceptionListItemSchemaMock(); delete inputPayload.entries; @@ -105,8 +105,10 @@ describe('update_exception_list_item_schema', () => { const decoded = updateExceptionListItemSchema.decode(inputPayload); const checked = exactCheck(inputPayload, decoded); const message = pipe(checked, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual([]); - expect(message.schema).toEqual(outputPayload); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "entries"', + ]); + expect(message.schema).toEqual({}); }); test('it should accept an undefined for "namespace_type" but return enum "single"', () => { diff --git a/x-pack/plugins/lists/common/schemas/request/update_exception_list_item_schema.ts b/x-pack/plugins/lists/common/schemas/request/update_exception_list_item_schema.ts index 9e0a1759fc9f..7fbd5cd65f04 100644 --- a/x-pack/plugins/lists/common/schemas/request/update_exception_list_item_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/update_exception_list_item_schema.ts @@ -23,17 +23,18 @@ import { } from '../common/schemas'; import { RequiredKeepUndefined } from '../../types'; import { - DefaultEntryArray, DefaultUpdateCommentsArray, EntriesArray, NamespaceType, UpdateCommentsArray, + nonEmptyEntriesArray, } from '../types'; export const updateExceptionListItemSchema = t.intersection([ t.exact( t.type({ description, + entries: nonEmptyEntriesArray, name, type: exceptionListItemType, }) @@ -43,7 +44,6 @@ export const updateExceptionListItemSchema = t.intersection([ _tags, // defaults to empty array if not set during decode _version, // defaults to undefined if not set during decode comments: DefaultUpdateCommentsArray, // defaults to empty array if not set during decode - entries: DefaultEntryArray, // defaults to empty array if not set during decode id, // defaults to undefined if not set during decode item_id: t.union([t.string, t.undefined]), meta, // defaults to undefined if not set during decode diff --git a/x-pack/plugins/lists/common/schemas/types/default_entries_array.test.ts b/x-pack/plugins/lists/common/schemas/types/default_entries_array.test.ts deleted file mode 100644 index 21115690c0a5..000000000000 --- a/x-pack/plugins/lists/common/schemas/types/default_entries_array.test.ts +++ /dev/null @@ -1,99 +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 { pipe } from 'fp-ts/lib/pipeable'; -import { left } from 'fp-ts/lib/Either'; - -import { foldLeftRight, getPaths } from '../../siem_common_deps'; - -import { DefaultEntryArray } from './default_entries_array'; -import { EntriesArray } from './entries'; -import { getEntriesArrayMock, getEntryMatchMock, getEntryNestedMock } from './entries.mock'; - -// NOTE: This may seem weird, but when validating schemas that use a union -// it checks against every item in that union. Since entries consist of 5 -// different entry types, it returns 5 of these. To make more readable, -// extracted here. -const returnedSchemaError = - '"Array<({| field: string, operator: "excluded" | "included", type: "match", value: string |} | {| field: string, operator: "excluded" | "included", type: "match_any", value: DefaultStringArray |} | {| field: string, list: {| id: string, type: "binary" | "boolean" | "byte" | "date" | "date_nanos" | "date_range" | "double" | "double_range" | "float" | "float_range" | "geo_point" | "geo_shape" | "half_float" | "integer" | "integer_range" | "ip" | "ip_range" | "keyword" | "long" | "long_range" | "shape" | "short" | "text" |}, operator: "excluded" | "included", type: "list" |} | {| field: string, operator: "excluded" | "included", type: "exists" |} | {| entries: Array<{| field: string, operator: "excluded" | "included", type: "match", value: string |}>, field: string, type: "nested" |})>"'; - -describe('default_entries_array', () => { - test('it should validate an empty array', () => { - const payload: EntriesArray = []; - const decoded = DefaultEntryArray.decode(payload); - const message = pipe(decoded, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual([]); - expect(message.schema).toEqual(payload); - }); - - test('it should validate an array of regular and nested entries', () => { - const payload: EntriesArray = getEntriesArrayMock(); - const decoded = DefaultEntryArray.decode(payload); - const message = pipe(decoded, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual([]); - expect(message.schema).toEqual(payload); - }); - - test('it should validate an array of nested entries', () => { - const payload: EntriesArray = [{ ...getEntryNestedMock() }]; - const decoded = DefaultEntryArray.decode(payload); - const message = pipe(decoded, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual([]); - expect(message.schema).toEqual(payload); - }); - - test('it should validate an array of non nested entries', () => { - const payload: EntriesArray = [{ ...getEntryMatchMock() }]; - const decoded = DefaultEntryArray.decode(payload); - const message = pipe(decoded, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual([]); - expect(message.schema).toEqual(payload); - }); - - test('it should NOT validate an array of numbers', () => { - const payload = [1]; - const decoded = DefaultEntryArray.decode(payload); - const message = pipe(decoded, foldLeftRight); - - // TODO: Known weird error formatting that is on our list to address - expect(getPaths(left(message.errors))).toEqual([ - `Invalid value "1" supplied to ${returnedSchemaError}`, - `Invalid value "1" supplied to ${returnedSchemaError}`, - `Invalid value "1" supplied to ${returnedSchemaError}`, - `Invalid value "1" supplied to ${returnedSchemaError}`, - `Invalid value "1" supplied to ${returnedSchemaError}`, - ]); - expect(message.schema).toEqual({}); - }); - - test('it should NOT validate an array of strings', () => { - const payload = ['some string']; - const decoded = DefaultEntryArray.decode(payload); - const message = pipe(decoded, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual([ - `Invalid value "some string" supplied to ${returnedSchemaError}`, - `Invalid value "some string" supplied to ${returnedSchemaError}`, - `Invalid value "some string" supplied to ${returnedSchemaError}`, - `Invalid value "some string" supplied to ${returnedSchemaError}`, - `Invalid value "some string" supplied to ${returnedSchemaError}`, - ]); - expect(message.schema).toEqual({}); - }); - - test('it should return a default array entry', () => { - const payload = null; - const decoded = DefaultEntryArray.decode(payload); - const message = pipe(decoded, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual([]); - expect(message.schema).toEqual([]); - }); -}); diff --git a/x-pack/plugins/lists/common/schemas/types/default_entries_array.ts b/x-pack/plugins/lists/common/schemas/types/default_entries_array.ts deleted file mode 100644 index a85fdf8537f3..000000000000 --- a/x-pack/plugins/lists/common/schemas/types/default_entries_array.ts +++ /dev/null @@ -1,22 +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 * as t from 'io-ts'; -import { Either } from 'fp-ts/lib/Either'; - -import { EntriesArray, entriesArray } from './entries'; - -/** - * Types the DefaultEntriesArray as: - * - If null or undefined, then a default array of type entry will be set - */ -export const DefaultEntryArray = new t.Type( - 'DefaultEntryArray', - entriesArray.is, - (input): Either => - input == null ? t.success([]) : entriesArray.decode(input), - t.identity -); diff --git a/x-pack/plugins/lists/common/schemas/types/entries.mock.ts b/x-pack/plugins/lists/common/schemas/types/entries.mock.ts index 8af18c970c6a..3ed3f4e7ff88 100644 --- a/x-pack/plugins/lists/common/schemas/types/entries.mock.ts +++ b/x-pack/plugins/lists/common/schemas/types/entries.mock.ts @@ -4,65 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - ENTRY_VALUE, - EXISTS, - FIELD, - LIST, - LIST_ID, - MATCH, - MATCH_ANY, - NESTED, - OPERATOR, - TYPE, -} from '../../constants.mock'; - -import { - EntriesArray, - EntryExists, - EntryList, - EntryMatch, - EntryMatchAny, - EntryNested, -} from './entries'; - -export const getEntryMatchMock = (): EntryMatch => ({ - field: FIELD, - operator: OPERATOR, - type: MATCH, - value: ENTRY_VALUE, -}); - -export const getEntryMatchAnyMock = (): EntryMatchAny => ({ - field: FIELD, - operator: OPERATOR, - type: MATCH_ANY, - value: [ENTRY_VALUE], -}); - -export const getEntryListMock = (): EntryList => ({ - field: FIELD, - list: { id: LIST_ID, type: TYPE }, - operator: OPERATOR, - type: LIST, -}); - -export const getEntryExistsMock = (): EntryExists => ({ - field: FIELD, - operator: OPERATOR, - type: EXISTS, -}); - -export const getEntryNestedMock = (): EntryNested => ({ - entries: [getEntryMatchMock(), getEntryMatchMock()], - field: FIELD, - type: NESTED, -}); +import { EntriesArray } from './entries'; +import { getEntryMatchMock } from './entry_match.mock'; +import { getEntryMatchAnyMock } from './entry_match_any.mock'; +import { getEntryListMock } from './entry_list.mock'; +import { getEntryExistsMock } from './entry_exists.mock'; +import { getEntryNestedMock } from './entry_nested.mock'; export const getEntriesArrayMock = (): EntriesArray => [ - getEntryMatchMock(), - getEntryMatchAnyMock(), - getEntryListMock(), - getEntryExistsMock(), - getEntryNestedMock(), + { ...getEntryMatchMock() }, + { ...getEntryMatchAnyMock() }, + { ...getEntryListMock() }, + { ...getEntryExistsMock() }, + { ...getEntryNestedMock() }, ]; diff --git a/x-pack/plugins/lists/common/schemas/types/entries.test.ts b/x-pack/plugins/lists/common/schemas/types/entries.test.ts index 01f82f12f2b2..cad94220a232 100644 --- a/x-pack/plugins/lists/common/schemas/types/entries.test.ts +++ b/x-pack/plugins/lists/common/schemas/types/entries.test.ts @@ -9,359 +9,147 @@ import { left } from 'fp-ts/lib/Either'; import { foldLeftRight, getPaths } from '../../siem_common_deps'; -import { - getEntryExistsMock, - getEntryListMock, - getEntryMatchAnyMock, - getEntryMatchMock, - getEntryNestedMock, -} from './entries.mock'; -import { - EntryExists, - EntryList, - EntryMatch, - EntryMatchAny, - EntryNested, - entriesExists, - entriesList, - entriesMatch, - entriesMatchAny, - entriesNested, -} from './entries'; +import { getEntryMatchMock } from './entry_match.mock'; +import { getEntryMatchAnyMock } from './entry_match_any.mock'; +import { getEntryListMock } from './entry_list.mock'; +import { getEntryExistsMock } from './entry_exists.mock'; +import { getEntryNestedMock } from './entry_nested.mock'; +import { getEntriesArrayMock } from './entries.mock'; +import { entriesArray, entriesArrayOrUndefined, entry } from './entries'; describe('Entries', () => { - describe('entriesMatch', () => { - test('it should validate an entry', () => { - const payload = getEntryMatchMock(); - const decoded = entriesMatch.decode(payload); + describe('entry', () => { + test('it should validate a match entry', () => { + const payload = { ...getEntryMatchMock() }; + const decoded = entry.decode(payload); const message = pipe(decoded, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([]); expect(message.schema).toEqual(payload); }); - test('it should validate when operator is "included"', () => { - const payload = getEntryMatchMock(); - const decoded = entriesMatch.decode(payload); + test('it should validate a match_any entry', () => { + const payload = { ...getEntryMatchAnyMock() }; + const decoded = entry.decode(payload); const message = pipe(decoded, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([]); expect(message.schema).toEqual(payload); }); - test('it should validate when "operator" is "excluded"', () => { - const payload = getEntryMatchMock(); - payload.operator = 'excluded'; - const decoded = entriesMatch.decode(payload); + test('it should validate a exists entry', () => { + const payload = { ...getEntryExistsMock() }; + const decoded = entry.decode(payload); const message = pipe(decoded, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([]); expect(message.schema).toEqual(payload); }); - test('it should not validate when "value" is not string', () => { - const payload: Omit & { value: string[] } = { - ...getEntryMatchMock(), - value: ['some value'], - }; - const decoded = entriesMatch.decode(payload); - const message = pipe(decoded, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "["some value"]" supplied to "value"', - ]); - expect(message.schema).toEqual({}); - }); - - test('it should not validate when "type" is not "match"', () => { - const payload: Omit & { type: string } = { - ...getEntryMatchMock(), - type: 'match_any', - }; - const decoded = entriesMatch.decode(payload); - const message = pipe(decoded, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "match_any" supplied to "type"', - ]); - expect(message.schema).toEqual({}); - }); - - test('it should strip out extra keys', () => { - const payload: EntryMatch & { - extraKey?: string; - } = getEntryMatchMock(); - payload.extraKey = 'some value'; - const decoded = entriesMatch.decode(payload); - const message = pipe(decoded, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual([]); - expect(message.schema).toEqual(getEntryMatchMock()); - }); - }); - - describe('entriesMatchAny', () => { - test('it should validate an entry', () => { - const payload = getEntryMatchAnyMock(); - const decoded = entriesMatchAny.decode(payload); + test('it should validate a list entry', () => { + const payload = { ...getEntryListMock() }; + const decoded = entry.decode(payload); const message = pipe(decoded, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([]); expect(message.schema).toEqual(payload); }); - test('it should validate when operator is "included"', () => { - const payload = getEntryMatchAnyMock(); - const decoded = entriesMatchAny.decode(payload); - const message = pipe(decoded, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual([]); - expect(message.schema).toEqual(payload); - }); - - test('it should validate when operator is "excluded"', () => { - const payload = getEntryMatchAnyMock(); - payload.operator = 'excluded'; - const decoded = entriesMatchAny.decode(payload); - const message = pipe(decoded, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual([]); - expect(message.schema).toEqual(payload); - }); - - test('it should not validate when value is not string array', () => { - const payload: Omit & { value: string } = { - ...getEntryMatchAnyMock(), - value: 'some string', - }; - const decoded = entriesMatchAny.decode(payload); + test('it should NOT validate a nested entry', () => { + const payload = { ...getEntryNestedMock() }; + const decoded = entry.decode(payload); const message = pipe(decoded, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "some string" supplied to "value"', + 'Invalid value "undefined" supplied to "operator"', + 'Invalid value "nested" supplied to "type"', + 'Invalid value "undefined" supplied to "value"', + 'Invalid value "undefined" supplied to "operator"', + 'Invalid value "nested" supplied to "type"', + 'Invalid value "undefined" supplied to "value"', + 'Invalid value "undefined" supplied to "list"', + 'Invalid value "undefined" supplied to "operator"', + 'Invalid value "nested" supplied to "type"', + 'Invalid value "undefined" supplied to "operator"', + 'Invalid value "nested" supplied to "type"', ]); expect(message.schema).toEqual({}); }); - - test('it should not validate when "type" is not "match_any"', () => { - const payload: Omit & { type: string } = { - ...getEntryMatchAnyMock(), - type: 'match', - }; - const decoded = entriesMatchAny.decode(payload); - const message = pipe(decoded, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual(['Invalid value "match" supplied to "type"']); - expect(message.schema).toEqual({}); - }); - - test('it should strip out extra keys', () => { - const payload: EntryMatchAny & { - extraKey?: string; - } = getEntryMatchAnyMock(); - payload.extraKey = 'some extra key'; - const decoded = entriesMatchAny.decode(payload); - const message = pipe(decoded, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual([]); - expect(message.schema).toEqual(getEntryMatchAnyMock()); - }); }); - describe('entriesExists', () => { - test('it should validate an entry', () => { - const payload = getEntryExistsMock(); - const decoded = entriesExists.decode(payload); + describe('entriesArray', () => { + test('it should validate an array with match entry', () => { + const payload = [{ ...getEntryMatchMock() }]; + const decoded = entriesArray.decode(payload); const message = pipe(decoded, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([]); expect(message.schema).toEqual(payload); }); - test('it should validate when "operator" is "included"', () => { - const payload = getEntryExistsMock(); - const decoded = entriesExists.decode(payload); + test('it should validate an array with match_any entry', () => { + const payload = [{ ...getEntryMatchAnyMock() }]; + const decoded = entriesArray.decode(payload); const message = pipe(decoded, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([]); expect(message.schema).toEqual(payload); }); - test('it should validate when "operator" is "excluded"', () => { - const payload = getEntryExistsMock(); - payload.operator = 'excluded'; - const decoded = entriesExists.decode(payload); + test('it should validate an array with exists entry', () => { + const payload = [{ ...getEntryExistsMock() }]; + const decoded = entriesArray.decode(payload); const message = pipe(decoded, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([]); expect(message.schema).toEqual(payload); }); - test('it should strip out extra keys', () => { - const payload: EntryExists & { - extraKey?: string; - } = getEntryExistsMock(); - payload.extraKey = 'some extra key'; - const decoded = entriesExists.decode(payload); - const message = pipe(decoded, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual([]); - expect(message.schema).toEqual(getEntryExistsMock()); - }); - - test('it should not validate when "type" is not "exists"', () => { - const payload: Omit & { type: string } = { - ...getEntryExistsMock(), - type: 'match', - }; - const decoded = entriesExists.decode(payload); - const message = pipe(decoded, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual(['Invalid value "match" supplied to "type"']); - expect(message.schema).toEqual({}); - }); - }); - - describe('entriesList', () => { - test('it should validate an entry', () => { - const payload = getEntryListMock(); - const decoded = entriesList.decode(payload); + test('it should validate an array with list entry', () => { + const payload = [{ ...getEntryListMock() }]; + const decoded = entriesArray.decode(payload); const message = pipe(decoded, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([]); expect(message.schema).toEqual(payload); }); - test('it should validate when operator is "included"', () => { - const payload = getEntryListMock(); - const decoded = entriesList.decode(payload); + test('it should validate an array with nested entry', () => { + const payload = [{ ...getEntryNestedMock() }]; + const decoded = entriesArray.decode(payload); const message = pipe(decoded, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([]); expect(message.schema).toEqual(payload); }); - test('it should validate when "operator" is "excluded"', () => { - const payload = getEntryListMock(); - payload.operator = 'excluded'; - const decoded = entriesList.decode(payload); + test('it should validate an array with all types of entries', () => { + const payload = [...getEntriesArrayMock()]; + const decoded = entriesArray.decode(payload); const message = pipe(decoded, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([]); expect(message.schema).toEqual(payload); }); - - test('it should not validate when "list" is not expected value', () => { - const payload: Omit & { list: string } = { - ...getEntryListMock(), - list: 'someListId', - }; - const decoded = entriesList.decode(payload); - const message = pipe(decoded, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "someListId" supplied to "list"', - ]); - expect(message.schema).toEqual({}); - }); - - test('it should not validate when "type" is not "lists"', () => { - const payload: Omit & { type: 'match_any' } = { - ...getEntryListMock(), - type: 'match_any', - }; - const decoded = entriesList.decode(payload); - const message = pipe(decoded, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "match_any" supplied to "type"', - ]); - expect(message.schema).toEqual({}); - }); - - test('it should strip out extra keys', () => { - const payload: EntryList & { - extraKey?: string; - } = getEntryListMock(); - payload.extraKey = 'some extra key'; - const decoded = entriesList.decode(payload); - const message = pipe(decoded, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual([]); - expect(message.schema).toEqual(getEntryListMock()); - }); }); - describe('entriesNested', () => { - test('it should validate a nested entry', () => { - const payload = getEntryNestedMock(); - const decoded = entriesNested.decode(payload); + describe('entriesArrayOrUndefined', () => { + test('it should validate undefined', () => { + const payload = undefined; + const decoded = entriesArrayOrUndefined.decode(payload); const message = pipe(decoded, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([]); expect(message.schema).toEqual(payload); }); - test('it should NOT validate when "type" is not "nested"', () => { - const payload: Omit & { type: 'match' } = { - ...getEntryNestedMock(), - type: 'match', - }; - const decoded = entriesNested.decode(payload); - const message = pipe(decoded, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual(['Invalid value "match" supplied to "type"']); - expect(message.schema).toEqual({}); - }); - - test('it should NOT validate when "field" is not a string', () => { - const payload: Omit & { - field: number; - } = { ...getEntryNestedMock(), field: 1 }; - const decoded = entriesNested.decode(payload); - const message = pipe(decoded, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual(['Invalid value "1" supplied to "field"']); - expect(message.schema).toEqual({}); - }); - - test('it should NOT validate when "entries" is not a an array', () => { - const payload: Omit & { - entries: string; - } = { ...getEntryNestedMock(), entries: 'im a string' }; - const decoded = entriesNested.decode(payload); - const message = pipe(decoded, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "im a string" supplied to "entries"', - ]); - expect(message.schema).toEqual({}); - }); - - test('it should NOT validate when "entries" contains an entry item that is not type "match"', () => { - const payload: Omit & { - entries: EntryMatchAny[]; - } = { ...getEntryNestedMock(), entries: [getEntryMatchAnyMock()] }; - const decoded = entriesNested.decode(payload); - const message = pipe(decoded, foldLeftRight); - - expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "match_any" supplied to "entries,type"', - 'Invalid value "["some host name"]" supplied to "entries,value"', - ]); - expect(message.schema).toEqual({}); - }); - - test('it should strip out extra keys', () => { - const payload: EntryNested & { - extraKey?: string; - } = getEntryNestedMock(); - payload.extraKey = 'some extra key'; - const decoded = entriesNested.decode(payload); + test('it should validate an array with nested entry', () => { + const payload = [{ ...getEntryNestedMock() }]; + const decoded = entriesArrayOrUndefined.decode(payload); const message = pipe(decoded, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([]); - expect(message.schema).toEqual(getEntryNestedMock()); + expect(message.schema).toEqual(payload); }); }); }); diff --git a/x-pack/plugins/lists/common/schemas/types/entries.ts b/x-pack/plugins/lists/common/schemas/types/entries.ts index c379f77b862c..4f20b9278d3f 100644 --- a/x-pack/plugins/lists/common/schemas/types/entries.ts +++ b/x-pack/plugins/lists/common/schemas/types/entries.ts @@ -8,62 +8,19 @@ import * as t from 'io-ts'; -import { operator, type } from '../common/schemas'; -import { DefaultStringArray } from '../../siem_common_deps'; - -export const entriesMatch = t.exact( - t.type({ - field: t.string, - operator, - type: t.keyof({ match: null }), - value: t.string, - }) -); -export type EntryMatch = t.TypeOf; - -export const entriesMatchAny = t.exact( - t.type({ - field: t.string, - operator, - type: t.keyof({ match_any: null }), - value: DefaultStringArray, - }) -); -export type EntryMatchAny = t.TypeOf; - -export const entriesList = t.exact( - t.type({ - field: t.string, - list: t.exact(t.type({ id: t.string, type })), - operator, - type: t.keyof({ list: null }), - }) -); -export type EntryList = t.TypeOf; - -export const entriesExists = t.exact( - t.type({ - field: t.string, - operator, - type: t.keyof({ exists: null }), - }) -); -export type EntryExists = t.TypeOf; - -export const entriesNested = t.exact( - t.type({ - entries: t.array(entriesMatch), - field: t.string, - type: t.keyof({ nested: null }), - }) -); -export type EntryNested = t.TypeOf; +import { entriesMatchAny } from './entry_match_any'; +import { entriesMatch } from './entry_match'; +import { entriesExists } from './entry_exists'; +import { entriesList } from './entry_list'; +import { entriesNested } from './entry_nested'; export const entry = t.union([entriesMatch, entriesMatchAny, entriesList, entriesExists]); export type Entry = t.TypeOf; + export const entriesArray = t.array( t.union([entriesMatch, entriesMatchAny, entriesList, entriesExists, entriesNested]) ); export type EntriesArray = t.TypeOf; + export const entriesArrayOrUndefined = t.union([entriesArray, t.undefined]); export type EntriesArrayOrUndefined = t.TypeOf; diff --git a/x-pack/plugins/lists/common/schemas/types/entry_exists.mock.ts b/x-pack/plugins/lists/common/schemas/types/entry_exists.mock.ts new file mode 100644 index 000000000000..aa93eee6374a --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/entry_exists.mock.ts @@ -0,0 +1,15 @@ +/* + * 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 { EXISTS, FIELD, OPERATOR } from '../../constants.mock'; + +import { EntryExists } from './entry_exists'; + +export const getEntryExistsMock = (): EntryExists => ({ + field: FIELD, + operator: OPERATOR, + type: EXISTS, +}); diff --git a/x-pack/plugins/lists/common/schemas/types/entry_exists.test.ts b/x-pack/plugins/lists/common/schemas/types/entry_exists.test.ts new file mode 100644 index 000000000000..9d5b669333db --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/entry_exists.test.ts @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pipe } from 'fp-ts/lib/pipeable'; +import { left } from 'fp-ts/lib/Either'; + +import { foldLeftRight, getPaths } from '../../siem_common_deps'; + +import { getEntryExistsMock } from './entry_exists.mock'; +import { EntryExists, entriesExists } from './entry_exists'; + +describe('entriesExists', () => { + test('it should validate an entry', () => { + const payload = { ...getEntryExistsMock() }; + const decoded = entriesExists.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should validate when "operator" is "included"', () => { + const payload = { ...getEntryExistsMock() }; + const decoded = entriesExists.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should validate when "operator" is "excluded"', () => { + const payload = { ...getEntryExistsMock() }; + payload.operator = 'excluded'; + const decoded = entriesExists.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should not validate when "field" is empty string', () => { + const payload: Omit & { field: string } = { + ...getEntryExistsMock(), + field: '', + }; + const decoded = entriesExists.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual(['Invalid value "" supplied to "field"']); + expect(message.schema).toEqual({}); + }); + + test('it should strip out extra keys', () => { + const payload: EntryExists & { + extraKey?: string; + } = { ...getEntryExistsMock() }; + payload.extraKey = 'some extra key'; + const decoded = entriesExists.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual({ ...getEntryExistsMock() }); + }); + + test('it should not validate when "type" is not "exists"', () => { + const payload: Omit & { type: string } = { + ...getEntryExistsMock(), + type: 'match', + }; + const decoded = entriesExists.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual(['Invalid value "match" supplied to "type"']); + expect(message.schema).toEqual({}); + }); +}); diff --git a/x-pack/plugins/lists/common/schemas/types/entry_exists.ts b/x-pack/plugins/lists/common/schemas/types/entry_exists.ts new file mode 100644 index 000000000000..05c82d253221 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/entry_exists.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* eslint-disable @typescript-eslint/camelcase */ + +import * as t from 'io-ts'; + +import { NonEmptyString } from '../../siem_common_deps'; +import { operator } from '../common/schemas'; + +export const entriesExists = t.exact( + t.type({ + field: NonEmptyString, + operator, + type: t.keyof({ exists: null }), + }) +); +export type EntryExists = t.TypeOf; diff --git a/x-pack/plugins/lists/common/schemas/types/entry_list.mock.ts b/x-pack/plugins/lists/common/schemas/types/entry_list.mock.ts new file mode 100644 index 000000000000..d5166b7984c9 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/entry_list.mock.ts @@ -0,0 +1,16 @@ +/* + * 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 { FIELD, LIST, LIST_ID, OPERATOR, TYPE } from '../../constants.mock'; + +import { EntryList } from './entry_list'; + +export const getEntryListMock = (): EntryList => ({ + field: FIELD, + list: { id: LIST_ID, type: TYPE }, + operator: OPERATOR, + type: LIST, +}); diff --git a/x-pack/plugins/lists/common/schemas/types/entry_list.test.ts b/x-pack/plugins/lists/common/schemas/types/entry_list.test.ts new file mode 100644 index 000000000000..14857edad5e3 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/entry_list.test.ts @@ -0,0 +1,95 @@ +/* + * 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 { pipe } from 'fp-ts/lib/pipeable'; +import { left } from 'fp-ts/lib/Either'; + +import { foldLeftRight, getPaths } from '../../siem_common_deps'; + +import { getEntryListMock } from './entry_list.mock'; +import { EntryList, entriesList } from './entry_list'; + +describe('entriesList', () => { + test('it should validate an entry', () => { + const payload = { ...getEntryListMock() }; + const decoded = entriesList.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should validate when operator is "included"', () => { + const payload = { ...getEntryListMock() }; + const decoded = entriesList.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should validate when "operator" is "excluded"', () => { + const payload = { ...getEntryListMock() }; + payload.operator = 'excluded'; + const decoded = entriesList.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should not validate when "list" is not expected value', () => { + const payload: Omit & { list: string } = { + ...getEntryListMock(), + list: 'someListId', + }; + const decoded = entriesList.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "someListId" supplied to "list"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should not validate when "list.id" is empty string', () => { + const payload: Omit & { list: { id: string; type: 'ip' } } = { + ...getEntryListMock(), + list: { id: '', type: 'ip' }, + }; + const decoded = entriesList.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual(['Invalid value "" supplied to "list,id"']); + expect(message.schema).toEqual({}); + }); + + test('it should not validate when "type" is not "lists"', () => { + const payload: Omit & { type: 'match_any' } = { + ...getEntryListMock(), + type: 'match_any', + }; + const decoded = entriesList.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "match_any" supplied to "type"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should strip out extra keys', () => { + const payload: EntryList & { + extraKey?: string; + } = { ...getEntryListMock() }; + payload.extraKey = 'some extra key'; + const decoded = entriesList.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual({ ...getEntryListMock() }); + }); +}); diff --git a/x-pack/plugins/lists/common/schemas/types/entry_list.ts b/x-pack/plugins/lists/common/schemas/types/entry_list.ts new file mode 100644 index 000000000000..ae9de967db02 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/entry_list.ts @@ -0,0 +1,22 @@ +/* + * 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. + */ + +/* eslint-disable @typescript-eslint/camelcase */ + +import * as t from 'io-ts'; + +import { NonEmptyString } from '../../siem_common_deps'; +import { operator, type } from '../common/schemas'; + +export const entriesList = t.exact( + t.type({ + field: NonEmptyString, + list: t.exact(t.type({ id: NonEmptyString, type })), + operator, + type: t.keyof({ list: null }), + }) +); +export type EntryList = t.TypeOf; diff --git a/x-pack/plugins/lists/common/schemas/types/entry_match.mock.ts b/x-pack/plugins/lists/common/schemas/types/entry_match.mock.ts new file mode 100644 index 000000000000..5f3a09f17eb3 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/entry_match.mock.ts @@ -0,0 +1,16 @@ +/* + * 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 { ENTRY_VALUE, FIELD, MATCH, OPERATOR } from '../../constants.mock'; + +import { EntryMatch } from './entry_match'; + +export const getEntryMatchMock = (): EntryMatch => ({ + field: FIELD, + operator: OPERATOR, + type: MATCH, + value: ENTRY_VALUE, +}); diff --git a/x-pack/plugins/lists/common/schemas/types/entry_match.test.ts b/x-pack/plugins/lists/common/schemas/types/entry_match.test.ts new file mode 100644 index 000000000000..2c64592518eb --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/entry_match.test.ts @@ -0,0 +1,107 @@ +/* + * 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 { pipe } from 'fp-ts/lib/pipeable'; +import { left } from 'fp-ts/lib/Either'; + +import { foldLeftRight, getPaths } from '../../siem_common_deps'; + +import { getEntryMatchMock } from './entry_match.mock'; +import { EntryMatch, entriesMatch } from './entry_match'; + +describe('entriesMatch', () => { + test('it should validate an entry', () => { + const payload = { ...getEntryMatchMock() }; + const decoded = entriesMatch.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should validate when operator is "included"', () => { + const payload = { ...getEntryMatchMock() }; + const decoded = entriesMatch.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should validate when "operator" is "excluded"', () => { + const payload = { ...getEntryMatchMock() }; + payload.operator = 'excluded'; + const decoded = entriesMatch.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should not validate when "field" is empty string', () => { + const payload: Omit & { field: string } = { + ...getEntryMatchMock(), + field: '', + }; + const decoded = entriesMatch.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual(['Invalid value "" supplied to "field"']); + expect(message.schema).toEqual({}); + }); + + test('it should not validate when "value" is not string', () => { + const payload: Omit & { value: string[] } = { + ...getEntryMatchMock(), + value: ['some value'], + }; + const decoded = entriesMatch.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "["some value"]" supplied to "value"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should not validate when "value" is empty string', () => { + const payload: Omit & { value: string } = { + ...getEntryMatchMock(), + value: '', + }; + const decoded = entriesMatch.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual(['Invalid value "" supplied to "value"']); + expect(message.schema).toEqual({}); + }); + + test('it should not validate when "type" is not "match"', () => { + const payload: Omit & { type: string } = { + ...getEntryMatchMock(), + type: 'match_any', + }; + const decoded = entriesMatch.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "match_any" supplied to "type"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should strip out extra keys', () => { + const payload: EntryMatch & { + extraKey?: string; + } = { ...getEntryMatchMock() }; + payload.extraKey = 'some value'; + const decoded = entriesMatch.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual({ ...getEntryMatchMock() }); + }); +}); diff --git a/x-pack/plugins/lists/common/schemas/types/entry_match.ts b/x-pack/plugins/lists/common/schemas/types/entry_match.ts new file mode 100644 index 000000000000..a21f83f317e3 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/entry_match.ts @@ -0,0 +1,22 @@ +/* + * 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. + */ + +/* eslint-disable @typescript-eslint/camelcase */ + +import * as t from 'io-ts'; + +import { NonEmptyString } from '../../siem_common_deps'; +import { operator } from '../common/schemas'; + +export const entriesMatch = t.exact( + t.type({ + field: NonEmptyString, + operator, + type: t.keyof({ match: null }), + value: NonEmptyString, + }) +); +export type EntryMatch = t.TypeOf; diff --git a/x-pack/plugins/lists/common/schemas/types/entry_match_any.mock.ts b/x-pack/plugins/lists/common/schemas/types/entry_match_any.mock.ts new file mode 100644 index 000000000000..ac4ef69207c8 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/entry_match_any.mock.ts @@ -0,0 +1,16 @@ +/* + * 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 { ENTRY_VALUE, FIELD, MATCH_ANY, OPERATOR } from '../../constants.mock'; + +import { EntryMatchAny } from './entry_match_any'; + +export const getEntryMatchAnyMock = (): EntryMatchAny => ({ + field: FIELD, + operator: OPERATOR, + type: MATCH_ANY, + value: [ENTRY_VALUE], +}); diff --git a/x-pack/plugins/lists/common/schemas/types/entry_match_any.test.ts b/x-pack/plugins/lists/common/schemas/types/entry_match_any.test.ts new file mode 100644 index 000000000000..4dab2f45711f --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/entry_match_any.test.ts @@ -0,0 +1,105 @@ +/* + * 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 { pipe } from 'fp-ts/lib/pipeable'; +import { left } from 'fp-ts/lib/Either'; + +import { foldLeftRight, getPaths } from '../../siem_common_deps'; + +import { getEntryMatchAnyMock } from './entry_match_any.mock'; +import { EntryMatchAny, entriesMatchAny } from './entry_match_any'; + +describe('entriesMatchAny', () => { + test('it should validate an entry', () => { + const payload = { ...getEntryMatchAnyMock() }; + const decoded = entriesMatchAny.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should validate when operator is "included"', () => { + const payload = { ...getEntryMatchAnyMock() }; + const decoded = entriesMatchAny.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should validate when operator is "excluded"', () => { + const payload = { ...getEntryMatchAnyMock() }; + payload.operator = 'excluded'; + const decoded = entriesMatchAny.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should not validate when field is empty string', () => { + const payload: Omit & { field: string } = { + ...getEntryMatchAnyMock(), + field: '', + }; + const decoded = entriesMatchAny.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual(['Invalid value "" supplied to "field"']); + expect(message.schema).toEqual({}); + }); + + test('it should not validate when value is empty array', () => { + const payload: Omit & { value: string[] } = { + ...getEntryMatchAnyMock(), + value: [], + }; + const decoded = entriesMatchAny.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual(['Invalid value "[]" supplied to "value"']); + expect(message.schema).toEqual({}); + }); + + test('it should not validate when value is not string array', () => { + const payload: Omit & { value: string } = { + ...getEntryMatchAnyMock(), + value: 'some string', + }; + const decoded = entriesMatchAny.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "some string" supplied to "value"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should not validate when "type" is not "match_any"', () => { + const payload: Omit & { type: string } = { + ...getEntryMatchAnyMock(), + type: 'match', + }; + const decoded = entriesMatchAny.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual(['Invalid value "match" supplied to "type"']); + expect(message.schema).toEqual({}); + }); + + test('it should strip out extra keys', () => { + const payload: EntryMatchAny & { + extraKey?: string; + } = { ...getEntryMatchAnyMock() }; + payload.extraKey = 'some extra key'; + const decoded = entriesMatchAny.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual({ ...getEntryMatchAnyMock() }); + }); +}); diff --git a/x-pack/plugins/lists/common/schemas/types/entry_match_any.ts b/x-pack/plugins/lists/common/schemas/types/entry_match_any.ts new file mode 100644 index 000000000000..e93ad4aa131d --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/entry_match_any.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* eslint-disable @typescript-eslint/camelcase */ + +import * as t from 'io-ts'; + +import { NonEmptyString } from '../../siem_common_deps'; +import { operator } from '../common/schemas'; + +import { nonEmptyOrNullableStringArray } from './non_empty_or_nullable_string_array'; + +export const entriesMatchAny = t.exact( + t.type({ + field: NonEmptyString, + operator, + type: t.keyof({ match_any: null }), + value: nonEmptyOrNullableStringArray, + }) +); +export type EntryMatchAny = t.TypeOf; diff --git a/x-pack/plugins/lists/common/schemas/types/entry_nested.mock.ts b/x-pack/plugins/lists/common/schemas/types/entry_nested.mock.ts new file mode 100644 index 000000000000..f645bc9e40d7 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/entry_nested.mock.ts @@ -0,0 +1,17 @@ +/* + * 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 { FIELD, NESTED } from '../../constants.mock'; + +import { EntryNested } from './entry_nested'; +import { getEntryMatchMock } from './entry_match.mock'; +import { getEntryMatchAnyMock } from './entry_match_any.mock'; + +export const getEntryNestedMock = (): EntryNested => ({ + entries: [{ ...getEntryMatchMock() }, { ...getEntryMatchAnyMock() }], + field: FIELD, + type: NESTED, +}); diff --git a/x-pack/plugins/lists/common/schemas/types/entry_nested.test.ts b/x-pack/plugins/lists/common/schemas/types/entry_nested.test.ts new file mode 100644 index 000000000000..d9b58855413b --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/entry_nested.test.ts @@ -0,0 +1,124 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pipe } from 'fp-ts/lib/pipeable'; +import { left } from 'fp-ts/lib/Either'; + +import { foldLeftRight, getPaths } from '../../siem_common_deps'; + +import { getEntryNestedMock } from './entry_nested.mock'; +import { EntryNested, entriesNested } from './entry_nested'; +import { getEntryMatchAnyMock } from './entry_match_any.mock'; +import { getEntryExistsMock } from './entry_exists.mock'; + +describe('entriesNested', () => { + test('it should validate a nested entry', () => { + const payload = { ...getEntryNestedMock() }; + const decoded = entriesNested.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should NOT validate when "type" is not "nested"', () => { + const payload: Omit & { type: 'match' } = { + ...getEntryNestedMock(), + type: 'match', + }; + const decoded = entriesNested.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual(['Invalid value "match" supplied to "type"']); + expect(message.schema).toEqual({}); + }); + + test('it should NOT validate when "field" is empty string', () => { + const payload: Omit & { + field: string; + } = { ...getEntryNestedMock(), field: '' }; + const decoded = entriesNested.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual(['Invalid value "" supplied to "field"']); + expect(message.schema).toEqual({}); + }); + + test('it should NOT validate when "field" is not a string', () => { + const payload: Omit & { + field: number; + } = { ...getEntryNestedMock(), field: 1 }; + const decoded = entriesNested.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual(['Invalid value "1" supplied to "field"']); + expect(message.schema).toEqual({}); + }); + + test('it should NOT validate when "entries" is not a an array', () => { + const payload: Omit & { + entries: string; + } = { ...getEntryNestedMock(), entries: 'im a string' }; + const decoded = entriesNested.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "im a string" supplied to "entries"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should validate when "entries" contains an entry item that is type "match"', () => { + const payload = { ...getEntryNestedMock(), entries: [{ ...getEntryMatchAnyMock() }] }; + const decoded = entriesNested.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual({ + entries: [ + { + field: 'host.name', + operator: 'included', + type: 'match_any', + value: ['some host name'], + }, + ], + field: 'host.name', + type: 'nested', + }); + }); + + test('it should validate when "entries" contains an entry item that is type "exists"', () => { + const payload = { ...getEntryNestedMock(), entries: [{ ...getEntryExistsMock() }] }; + const decoded = entriesNested.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual({ + entries: [ + { + field: 'host.name', + operator: 'included', + type: 'exists', + }, + ], + field: 'host.name', + type: 'nested', + }); + }); + + test('it should strip out extra keys', () => { + const payload: EntryNested & { + extraKey?: string; + } = { ...getEntryNestedMock() }; + payload.extraKey = 'some extra key'; + const decoded = entriesNested.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual({ ...getEntryNestedMock() }); + }); +}); diff --git a/x-pack/plugins/lists/common/schemas/types/entry_nested.ts b/x-pack/plugins/lists/common/schemas/types/entry_nested.ts new file mode 100644 index 000000000000..9989f501d433 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/entry_nested.ts @@ -0,0 +1,22 @@ +/* + * 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. + */ + +/* eslint-disable @typescript-eslint/camelcase */ + +import * as t from 'io-ts'; + +import { NonEmptyString } from '../../siem_common_deps'; + +import { nonEmptyNestedEntriesArray } from './non_empty_nested_entries_array'; + +export const entriesNested = t.exact( + t.type({ + entries: nonEmptyNestedEntriesArray, + field: NonEmptyString, + type: t.keyof({ nested: null }), + }) +); +export type EntryNested = t.TypeOf; diff --git a/x-pack/plugins/lists/common/schemas/types/index.ts b/x-pack/plugins/lists/common/schemas/types/index.ts index 16433e00f2b1..463f7cfe51ce 100644 --- a/x-pack/plugins/lists/common/schemas/types/index.ts +++ b/x-pack/plugins/lists/common/schemas/types/index.ts @@ -10,5 +10,12 @@ export * from './default_comments_array'; export * from './default_create_comments_array'; export * from './default_update_comments_array'; export * from './default_namespace'; -export * from './default_entries_array'; export * from './entries'; +export * from './entry_match'; +export * from './entry_match_any'; +export * from './entry_list'; +export * from './entry_exists'; +export * from './entry_nested'; +export * from './non_empty_entries_array'; +export * from './non_empty_or_nullable_string_array'; +export * from './non_empty_nested_entries_array'; diff --git a/x-pack/plugins/lists/common/schemas/types/non_empty_entries_array.test.ts b/x-pack/plugins/lists/common/schemas/types/non_empty_entries_array.test.ts new file mode 100644 index 000000000000..ab7002982cf2 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/non_empty_entries_array.test.ts @@ -0,0 +1,123 @@ +/* + * 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 { pipe } from 'fp-ts/lib/pipeable'; +import { left } from 'fp-ts/lib/Either'; + +import { foldLeftRight, getPaths } from '../../siem_common_deps'; + +import { getEntryMatchMock } from './entry_match.mock'; +import { getEntryMatchAnyMock } from './entry_match_any.mock'; +import { getEntryListMock } from './entry_list.mock'; +import { getEntryExistsMock } from './entry_exists.mock'; +import { getEntryNestedMock } from './entry_nested.mock'; +import { getEntriesArrayMock } from './entries.mock'; +import { nonEmptyEntriesArray } from './non_empty_entries_array'; +import { EntriesArray } from './entries'; + +describe('non_empty_entries_array', () => { + test('it should NOT validate an empty array', () => { + const payload: EntriesArray = []; + const decoded = nonEmptyEntriesArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "[]" supplied to "NonEmptyEntriesArray"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should NOT validate "undefined"', () => { + const payload = undefined; + const decoded = nonEmptyEntriesArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "NonEmptyEntriesArray"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should NOT validate "null"', () => { + const payload = null; + const decoded = nonEmptyEntriesArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "null" supplied to "NonEmptyEntriesArray"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should validate an array of "match" entries', () => { + const payload: EntriesArray = [{ ...getEntryMatchMock() }, { ...getEntryMatchMock() }]; + const decoded = nonEmptyEntriesArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should validate an array of "match_any" entries', () => { + const payload: EntriesArray = [{ ...getEntryMatchAnyMock() }, { ...getEntryMatchAnyMock() }]; + const decoded = nonEmptyEntriesArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should validate an array of "exists" entries', () => { + const payload: EntriesArray = [{ ...getEntryExistsMock() }, { ...getEntryExistsMock() }]; + const decoded = nonEmptyEntriesArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should validate an array of "list" entries', () => { + const payload: EntriesArray = [{ ...getEntryListMock() }, { ...getEntryListMock() }]; + const decoded = nonEmptyEntriesArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should validate an array of "nested" entries', () => { + const payload: EntriesArray = [{ ...getEntryNestedMock() }, { ...getEntryNestedMock() }]; + const decoded = nonEmptyEntriesArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should validate an array of entries', () => { + const payload: EntriesArray = [...getEntriesArrayMock()]; + const decoded = nonEmptyEntriesArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should NOT validate an array of non entries', () => { + const payload = [1]; + const decoded = nonEmptyEntriesArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "1" supplied to "NonEmptyEntriesArray"', + 'Invalid value "1" supplied to "NonEmptyEntriesArray"', + 'Invalid value "1" supplied to "NonEmptyEntriesArray"', + 'Invalid value "1" supplied to "NonEmptyEntriesArray"', + 'Invalid value "1" supplied to "NonEmptyEntriesArray"', + ]); + expect(message.schema).toEqual({}); + }); +}); diff --git a/x-pack/plugins/lists/common/schemas/types/non_empty_entries_array.ts b/x-pack/plugins/lists/common/schemas/types/non_empty_entries_array.ts new file mode 100644 index 000000000000..1370fe022c25 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/non_empty_entries_array.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as t from 'io-ts'; +import { Either } from 'fp-ts/lib/Either'; + +import { EntriesArray, entriesArray } from './entries'; + +/** + * Types the nonEmptyEntriesArray as: + * - An array of entries of length 1 or greater + * + */ +export const nonEmptyEntriesArray = new t.Type( + 'NonEmptyEntriesArray', + entriesArray.is, + (input, context): Either => { + if (Array.isArray(input) && input.length === 0) { + return t.failure(input, context); + } else { + return entriesArray.validate(input, context); + } + }, + t.identity +); + +export type NonEmptyEntriesArray = t.OutputOf; +export type NonEmptyEntriesArrayDecoded = t.TypeOf; diff --git a/x-pack/plugins/lists/common/schemas/types/non_empty_nested_entries_array.test.ts b/x-pack/plugins/lists/common/schemas/types/non_empty_nested_entries_array.test.ts new file mode 100644 index 000000000000..1154f2b6098d --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/non_empty_nested_entries_array.test.ts @@ -0,0 +1,131 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pipe } from 'fp-ts/lib/pipeable'; +import { left } from 'fp-ts/lib/Either'; + +import { foldLeftRight, getPaths } from '../../siem_common_deps'; + +import { getEntryMatchMock } from './entry_match.mock'; +import { getEntryMatchAnyMock } from './entry_match_any.mock'; +import { getEntryExistsMock } from './entry_exists.mock'; +import { getEntryNestedMock } from './entry_nested.mock'; +import { nonEmptyNestedEntriesArray } from './non_empty_nested_entries_array'; +import { EntriesArray } from './entries'; + +describe('non_empty_nested_entries_array', () => { + test('it should NOT validate an empty array', () => { + const payload: EntriesArray = []; + const decoded = nonEmptyNestedEntriesArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "[]" supplied to "NonEmptyNestedEntriesArray"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should NOT validate "undefined"', () => { + const payload = undefined; + const decoded = nonEmptyNestedEntriesArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "NonEmptyNestedEntriesArray"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should NOT validate "null"', () => { + const payload = null; + const decoded = nonEmptyNestedEntriesArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "null" supplied to "NonEmptyNestedEntriesArray"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should validate an array of "match" entries', () => { + const payload: EntriesArray = [{ ...getEntryMatchMock() }, { ...getEntryMatchMock() }]; + const decoded = nonEmptyNestedEntriesArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should validate an array of "match_any" entries', () => { + const payload: EntriesArray = [{ ...getEntryMatchAnyMock() }, { ...getEntryMatchAnyMock() }]; + const decoded = nonEmptyNestedEntriesArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should validate an array of "exists" entries', () => { + const payload: EntriesArray = [{ ...getEntryExistsMock() }, { ...getEntryExistsMock() }]; + const decoded = nonEmptyNestedEntriesArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should NOT validate an array of "nested" entries', () => { + const payload: EntriesArray = [{ ...getEntryNestedMock() }, { ...getEntryNestedMock() }]; + const decoded = nonEmptyNestedEntriesArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "operator"', + 'Invalid value "nested" supplied to "type"', + 'Invalid value "undefined" supplied to "value"', + 'Invalid value "undefined" supplied to "operator"', + 'Invalid value "nested" supplied to "type"', + 'Invalid value "undefined" supplied to "value"', + 'Invalid value "undefined" supplied to "operator"', + 'Invalid value "nested" supplied to "type"', + 'Invalid value "undefined" supplied to "operator"', + 'Invalid value "nested" supplied to "type"', + 'Invalid value "undefined" supplied to "value"', + 'Invalid value "undefined" supplied to "operator"', + 'Invalid value "nested" supplied to "type"', + 'Invalid value "undefined" supplied to "value"', + 'Invalid value "undefined" supplied to "operator"', + 'Invalid value "nested" supplied to "type"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should validate an array of entries', () => { + const payload: EntriesArray = [ + { ...getEntryExistsMock() }, + { ...getEntryMatchAnyMock() }, + { ...getEntryMatchMock() }, + ]; + const decoded = nonEmptyNestedEntriesArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should NOT validate an array of non entries', () => { + const payload = [1]; + const decoded = nonEmptyNestedEntriesArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "1" supplied to "NonEmptyNestedEntriesArray"', + 'Invalid value "1" supplied to "NonEmptyNestedEntriesArray"', + 'Invalid value "1" supplied to "NonEmptyNestedEntriesArray"', + ]); + expect(message.schema).toEqual({}); + }); +}); diff --git a/x-pack/plugins/lists/common/schemas/types/non_empty_nested_entries_array.ts b/x-pack/plugins/lists/common/schemas/types/non_empty_nested_entries_array.ts new file mode 100644 index 000000000000..88a0f09b3cef --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/non_empty_nested_entries_array.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as t from 'io-ts'; +import { Either } from 'fp-ts/lib/Either'; + +import { entriesMatchAny } from './entry_match_any'; +import { entriesMatch } from './entry_match'; +import { entriesExists } from './entry_exists'; + +export const nestedEntriesArray = t.array(t.union([entriesMatch, entriesMatchAny, entriesExists])); +export type NestedEntriesArray = t.TypeOf; + +/** + * Types the nonEmptyNestedEntriesArray as: + * - An array of entries of length 1 or greater + * + */ +export const nonEmptyNestedEntriesArray = new t.Type< + NestedEntriesArray, + NestedEntriesArray, + unknown +>( + 'NonEmptyNestedEntriesArray', + nestedEntriesArray.is, + (input, context): Either => { + if (Array.isArray(input) && input.length === 0) { + return t.failure(input, context); + } else { + return nestedEntriesArray.validate(input, context); + } + }, + t.identity +); + +export type NonEmptyNestedEntriesArray = t.OutputOf; +export type NonEmptyNestedEntriesArrayDecoded = t.TypeOf; diff --git a/x-pack/plugins/lists/common/schemas/types/non_empty_or_nullable_string_array.test.ts b/x-pack/plugins/lists/common/schemas/types/non_empty_or_nullable_string_array.test.ts new file mode 100644 index 000000000000..e3cc9104853e --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/non_empty_or_nullable_string_array.test.ts @@ -0,0 +1,69 @@ +/* + * 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 { pipe } from 'fp-ts/lib/pipeable'; +import { left } from 'fp-ts/lib/Either'; + +import { foldLeftRight, getPaths } from '../../siem_common_deps'; + +import { nonEmptyOrNullableStringArray } from './non_empty_or_nullable_string_array'; + +describe('nonEmptyOrNullableStringArray', () => { + test('it should NOT validate an empty array', () => { + const payload: string[] = []; + const decoded = nonEmptyOrNullableStringArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "[]" supplied to "NonEmptyOrNullableStringArray"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should NOT validate "undefined"', () => { + const payload = undefined; + const decoded = nonEmptyOrNullableStringArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "NonEmptyOrNullableStringArray"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should NOT validate "null"', () => { + const payload = null; + const decoded = nonEmptyOrNullableStringArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "null" supplied to "NonEmptyOrNullableStringArray"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should NOT validate an array of with an empty string', () => { + const payload: string[] = ['im good', '']; + const decoded = nonEmptyOrNullableStringArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "["im good",""]" supplied to "NonEmptyOrNullableStringArray"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should NOT validate an array of non strings', () => { + const payload = [1]; + const decoded = nonEmptyOrNullableStringArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "[1]" supplied to "NonEmptyOrNullableStringArray"', + ]); + expect(message.schema).toEqual({}); + }); +}); diff --git a/x-pack/plugins/lists/common/schemas/types/non_empty_or_nullable_string_array.ts b/x-pack/plugins/lists/common/schemas/types/non_empty_or_nullable_string_array.ts new file mode 100644 index 000000000000..f8ae1701e132 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/non_empty_or_nullable_string_array.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as t from 'io-ts'; +import { Either } from 'fp-ts/lib/Either'; + +/** + * Types the nonEmptyOrNullableStringArray as: + * - An array of non empty strings of length 1 or greater + * - This differs from NonEmptyStringArray in that both input and output are type array + * + */ +export const nonEmptyOrNullableStringArray = new t.Type( + 'NonEmptyOrNullableStringArray', + t.array(t.string).is, + (input, context): Either => { + const emptyValueFound = Array.isArray(input) && input.some((value) => value === ''); + const nonStringValueFound = + Array.isArray(input) && input.some((value) => typeof value !== 'string'); + + if (Array.isArray(input) && (emptyValueFound || nonStringValueFound || input.length === 0)) { + return t.failure(input, context); + } else { + return t.array(t.string).validate(input, context); + } + }, + t.identity +); + +export type NonEmptyOrNullableStringArray = t.OutputOf; +export type NonEmptyOrNullableStringArrayDecoded = t.TypeOf; diff --git a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item.json b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item.json index eede855aab19..5fbfcc10bcc3 100644 --- a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item.json +++ b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item.json @@ -8,7 +8,7 @@ "name": "Sample Endpoint Exception List", "entries": [ { - "field": "actingProcess.file.signer", + "field": "host.ip", "operator": "excluded", "type": "exists" }, diff --git a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts index 51e3a7ee8046..555b9c5e95a7 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts @@ -14,7 +14,6 @@ import { Description, DescriptionOrUndefined, EntriesArray, - EntriesArrayOrUndefined, ExceptionListItemType, ExceptionListItemTypeOrUndefined, ExceptionListType, @@ -140,7 +139,7 @@ export interface UpdateExceptionListItemOptions { _tags: _TagsOrUndefined; _version: _VersionOrUndefined; comments: UpdateCommentsArray; - entries: EntriesArrayOrUndefined; + entries: EntriesArray; id: IdOrUndefined; itemId: ItemIdOrUndefined; namespaceType: NamespaceType; @@ -155,7 +154,7 @@ export interface UpdateEndpointListItemOptions { _tags: _TagsOrUndefined; _version: _VersionOrUndefined; comments: UpdateCommentsArray; - entries: EntriesArrayOrUndefined; + entries: EntriesArray; id: IdOrUndefined; itemId: ItemIdOrUndefined; name: NameOrUndefined; diff --git a/x-pack/plugins/lists/server/services/exception_lists/update_exception_list_item.ts b/x-pack/plugins/lists/server/services/exception_lists/update_exception_list_item.ts index f26dd7e18dd5..ccb74e879670 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/update_exception_list_item.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/update_exception_list_item.ts @@ -8,7 +8,7 @@ import { SavedObjectsClientContract } from 'kibana/server'; import { DescriptionOrUndefined, - EntriesArrayOrUndefined, + EntriesArray, ExceptionListItemSchema, ExceptionListItemTypeOrUndefined, ExceptionListSoSchema, @@ -37,7 +37,7 @@ interface UpdateExceptionListItemOptions { _version: _VersionOrUndefined; name: NameOrUndefined; description: DescriptionOrUndefined; - entries: EntriesArrayOrUndefined; + entries: EntriesArray; savedObjectsClient: SavedObjectsClientContract; namespaceType: NamespaceType; itemId: ItemIdOrUndefined; diff --git a/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.test.ts b/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.test.ts index caf2dfb761ed..1c7a2a5de659 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.test.ts @@ -25,6 +25,9 @@ import { Operator, } from '../../../lists/common/schemas'; import { getExceptionListItemSchemaMock } from '../../../lists/common/schemas/response/exception_list_item_schema.mock'; +import { getEntryMatchMock } from '../../../lists/common/schemas/types/entry_match.mock'; +import { getEntryMatchAnyMock } from '../../../lists/common/schemas/types/entry_match_any.mock'; +import { getEntryExistsMock } from '../../../lists/common/schemas/types/entry_exists.mock'; describe('build_exceptions_query', () => { let exclude: boolean; @@ -295,20 +298,95 @@ describe('build_exceptions_query', () => { const item: EntryNested = { field: 'parent', type: 'nested', - entries: [makeMatchEntry({ field: 'nestedField', operator: 'included' })], + entries: [ + { + ...getEntryMatchMock(), + field: 'nestedField', + operator: 'included', + value: 'value-1', + }, + ], }; const result = buildNested({ item, language: 'kuery' }); expect(result).toEqual('parent:{ nestedField:"value-1" }'); }); + test('it returns formatted query when entry item is "exists"', () => { + const item: EntryNested = { + field: 'parent', + type: 'nested', + entries: [{ ...getEntryExistsMock(), field: 'nestedField', operator: 'included' }], + }; + const result = buildNested({ item, language: 'kuery' }); + + expect(result).toEqual('parent:{ nestedField:* }'); + }); + + test('it returns formatted query when entry item is "exists" and operator is "excluded"', () => { + const item: EntryNested = { + field: 'parent', + type: 'nested', + entries: [{ ...getEntryExistsMock(), field: 'nestedField', operator: 'excluded' }], + }; + const result = buildNested({ item, language: 'kuery' }); + + expect(result).toEqual('parent:{ not nestedField:* }'); + }); + + test('it returns formatted query when entry item is "match_any"', () => { + const item: EntryNested = { + field: 'parent', + type: 'nested', + entries: [ + { + ...getEntryMatchAnyMock(), + field: 'nestedField', + operator: 'included', + value: ['value1', 'value2'], + }, + ], + }; + const result = buildNested({ item, language: 'kuery' }); + + expect(result).toEqual('parent:{ nestedField:("value1" or "value2") }'); + }); + + test('it returns formatted query when entry item is "match_any" and operator is "excluded"', () => { + const item: EntryNested = { + field: 'parent', + type: 'nested', + entries: [ + { + ...getEntryMatchAnyMock(), + field: 'nestedField', + operator: 'excluded', + value: ['value1', 'value2'], + }, + ], + }; + const result = buildNested({ item, language: 'kuery' }); + + expect(result).toEqual('parent:{ not nestedField:("value1" or "value2") }'); + }); + test('it returns formatted query when multiple items in nested entry', () => { const item: EntryNested = { field: 'parent', type: 'nested', entries: [ - makeMatchEntry({ field: 'nestedField', operator: 'included' }), - makeMatchEntry({ field: 'nestedFieldB', operator: 'included', value: 'value-2' }), + { + ...getEntryMatchMock(), + field: 'nestedField', + operator: 'included', + value: 'value-1', + }, + { + ...getEntryMatchMock(), + field: 'nestedFieldB', + operator: 'included', + value: 'value-2', + }, ], }; const result = buildNested({ item, language: 'kuery' }); @@ -514,7 +592,7 @@ describe('build_exceptions_query', () => { entries, }); const expectedQuery = - 'b:("value-1" OR "value-2") AND parent:{ nestedField:"value-3" } AND NOT _exists_e'; + 'b:("value-1" OR "value-2") AND parent:{ NOT nestedField:"value-3" } AND NOT _exists_e'; expect(query).toEqual(expectedQuery); }); @@ -576,7 +654,7 @@ describe('build_exceptions_query', () => { language: 'kuery', entries, }); - const expectedQuery = 'b:* and parent:{ c:"value-1" and d:"value-2" } and e:*'; + const expectedQuery = 'b:* and parent:{ not c:"value-1" and d:"value-2" } and e:*'; expect(query).toEqual(expectedQuery); }); @@ -642,7 +720,8 @@ describe('build_exceptions_query', () => { language: 'kuery', entries, }); - const expectedQuery = 'b:"value" and parent:{ c:"valueC" and d:"valueD" } and e:"valueE"'; + const expectedQuery = + 'b:"value" and parent:{ not c:"valueC" and not d:"valueD" } and e:"valueE"'; expect(query).toEqual(expectedQuery); }); @@ -684,7 +763,7 @@ describe('build_exceptions_query', () => { language: 'kuery', entries, }); - const expectedQuery = 'not b:("value-1" or "value-2") and parent:{ c:"valueC" }'; + const expectedQuery = 'not b:("value-1" or "value-2") and parent:{ not c:"valueC" }'; expect(query).toEqual(expectedQuery); }); @@ -800,7 +879,7 @@ describe('build_exceptions_query', () => { exclude, }); const expectedQuery = - '(some.parentField:{ nested.field:"some value" } and some.not.nested.field:"some value") or (b:("value-1" or "value-2") and parent:{ c:"valueC" and d:"valueD" } and e:("value-1" or "value-2"))'; + '(some.parentField:{ nested.field:"some value" } and some.not.nested.field:"some value") or (b:("value-1" or "value-2") and parent:{ not c:"valueC" and not d:"valueD" } and e:("value-1" or "value-2"))'; expect(query).toEqual([{ query: expectedQuery, language: 'kuery' }]); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.ts b/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.ts index fc4fbae02b8f..ff492dcda3b6 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.ts @@ -126,7 +126,7 @@ export const buildNested = ({ }): string => { const { field, entries } = item; const and = getLanguageBooleanOperator({ language, value: 'and' }); - const values = entries.map((entry) => `${entry.field}:"${entry.value}"`); + const values = entries.map((entry) => evaluateValues({ item: entry, language })); return `${field}:{ ${values.join(` ${and} `)} }`; }; diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/field.tsx b/x-pack/plugins/security_solution/public/common/components/autocomplete/field.tsx index ed844b5130c7..fab2b1e4a746 100644 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/field.tsx +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/field.tsx @@ -3,7 +3,7 @@ * 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, { useMemo, useCallback } from 'react'; +import React, { useState, useMemo, useCallback } from 'react'; import { EuiComboBoxOptionOption, EuiComboBox } from '@elastic/eui'; import { IFieldType, IIndexPattern } from '../../../../../../../src/plugins/data/common'; @@ -19,6 +19,7 @@ interface OperatorProps { isClearable: boolean; fieldTypeFilter?: string[]; fieldInputWidth?: number; + isRequired?: boolean; onChange: (a: IFieldType[]) => void; } @@ -29,10 +30,12 @@ export const FieldComponent: React.FC = ({ isLoading = false, isDisabled = false, isClearable = false, + isRequired = false, fieldTypeFilter = [], fieldInputWidth = 190, onChange, }): JSX.Element => { + const [touched, setIsTouched] = useState(false); const getLabel = useCallback((field): string => field.name, []); const optionsMemo = useMemo((): IFieldType[] => { if (indexPattern != null) { @@ -74,6 +77,8 @@ export const FieldComponent: React.FC = ({ isLoading={isLoading} isDisabled={isDisabled} isClearable={isClearable} + isInvalid={isRequired ? touched && selectedField == null : false} + onFocus={() => setIsTouched(true)} singleSelection={{ asPlainText: true }} data-test-subj="fieldAutocompleteComboBox" style={{ width: `${fieldInputWidth}px` }} diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_lists.tsx b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_lists.tsx index a9d85452651b..cd90d6eb8562 100644 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_lists.tsx +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_lists.tsx @@ -18,6 +18,7 @@ interface AutocompleteFieldListsProps { isLoading: boolean; isDisabled: boolean; isClearable: boolean; + isRequired?: boolean; onChange: (arg: ListSchema) => void; } @@ -28,8 +29,10 @@ export const AutocompleteFieldListsComponent: React.FC { + const [touched, setIsTouched] = useState(false); const { http } = useKibana().services; const [lists, setLists] = useState([]); const { loading, result, start } = useFindLists(); @@ -97,6 +100,8 @@ export const AutocompleteFieldListsComponent: React.FC setIsTouched(true)} singleSelection={{ asPlainText: true }} sortMatchesBy="startsWith" data-test-subj="valuesAutocompleteComboBox listsComboxBox" diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match.tsx b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match.tsx index a082811920f8..992005b3be8b 100644 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match.tsx +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match.tsx @@ -3,7 +3,7 @@ * 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, { useCallback, useMemo } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { EuiComboBoxOptionOption, EuiComboBox } from '@elastic/eui'; import { uniq } from 'lodash'; @@ -22,6 +22,7 @@ interface AutocompleteFieldMatchProps { isLoading: boolean; isDisabled: boolean; isClearable: boolean; + isRequired?: boolean; fieldInputWidth?: number; onChange: (arg: string) => void; } @@ -34,9 +35,11 @@ export const AutocompleteFieldMatchComponent: React.FC { + const [touched, setIsTouched] = useState(false); const [isLoadingSuggestions, suggestions, updateSuggestions] = useFieldValueAutocomplete({ selectedField, operatorType: OperatorTypeEnum.MATCH, @@ -96,7 +99,8 @@ export const AutocompleteFieldMatchComponent: React.FC setIsTouched(true)} sortMatchesBy="startsWith" data-test-subj="valuesAutocompleteComboBox matchComboxBox" style={fieldInputWidth ? { width: `${fieldInputWidth}px` } : {}} diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match_any.tsx b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match_any.tsx index 461d49dddfde..27807a752c14 100644 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match_any.tsx +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match_any.tsx @@ -3,7 +3,7 @@ * 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, { useCallback, useMemo } from 'react'; +import React, { useState, useCallback, useMemo } from 'react'; import { EuiComboBoxOptionOption, EuiComboBox } from '@elastic/eui'; import { uniq } from 'lodash'; @@ -22,6 +22,7 @@ interface AutocompleteFieldMatchAnyProps { isLoading: boolean; isDisabled: boolean; isClearable: boolean; + isRequired?: boolean; onChange: (arg: string[]) => void; } @@ -33,8 +34,10 @@ export const AutocompleteFieldMatchAnyComponent: React.FC { + const [touched, setIsTouched] = useState(false); const [isLoadingSuggestions, suggestions, updateSuggestions] = useFieldValueAutocomplete({ selectedField, operatorType: OperatorTypeEnum.MATCH_ANY, @@ -92,7 +95,8 @@ export const AutocompleteFieldMatchAnyComponent: React.FC setIsTouched(true)} delimiter=", " data-test-subj="valuesAutocompleteComboBox matchAnyComboxBox" fullWidth diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.test.ts b/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.test.ts index cb07d9991310..b25bb245c679 100644 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.test.ts @@ -54,16 +54,16 @@ describe('helpers', () => { }); describe('#validateParams', () => { - test('returns true if value is undefined', () => { + test('returns false if value is undefined', () => { const isValid = validateParams(undefined, getField('@timestamp')); - expect(isValid).toBeTruthy(); + expect(isValid).toBeFalsy(); }); - test('returns true if value is empty string', () => { + test('returns false if value is empty string', () => { const isValid = validateParams('', getField('@timestamp')); - expect(isValid).toBeTruthy(); + expect(isValid).toBeFalsy(); }); test('returns true if type is "date" and value is valid', () => { diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.ts b/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.ts index 16659593784d..a65f1fa35d3c 100644 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.ts +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.ts @@ -36,7 +36,7 @@ export const validateParams = ( ): boolean => { // Box would show error state if empty otherwise if (params == null || params === '') { - return true; + return false; } const types = field != null && field.esTypes != null ? field.esTypes : []; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_exception_item.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_exception_item.test.tsx index 9ca7a371ce81..0f3b6ec2e94e 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_exception_item.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_exception_item.test.tsx @@ -12,10 +12,8 @@ import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import { ExceptionListItemComponent } from './builder_exception_item'; import { fields } from '../../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks.ts'; import { getExceptionListItemSchemaMock } from '../../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; -import { - getEntryMatchMock, - getEntryMatchAnyMock, -} from '../../../../../../lists/common/schemas/types/entries.mock'; +import { getEntryMatchMock } from '../../../../../../lists/common/schemas/types/entry_match.mock'; +import { getEntryMatchAnyMock } from '../../../../../../lists/common/schemas/types/entry_match_any.mock'; describe('ExceptionListItemComponent', () => { describe('and badge logic', () => { diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/entry_item.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/entry_item.tsx index 0f5000c8c0ab..7bf279168a9a 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/entry_item.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/entry_item.tsx @@ -117,6 +117,7 @@ export const EntryItemComponent: React.FC = ({ isDisabled={isLoading} onChange={handleFieldChange} data-test-subj="exceptionBuilderEntryField" + isRequired /> ); @@ -170,6 +171,7 @@ export const EntryItemComponent: React.FC = ({ isClearable={false} indexPattern={indexPattern} onChange={handleFieldMatchValueChange} + isRequired data-test-subj="exceptionBuilderEntryFieldMatch" /> ); @@ -185,6 +187,7 @@ export const EntryItemComponent: React.FC = ({ isClearable={false} indexPattern={indexPattern} onChange={handleFieldMatchAnyValueChange} + isRequired data-test-subj="exceptionBuilderEntryFieldMatchAny" /> ); @@ -199,6 +202,7 @@ export const EntryItemComponent: React.FC = ({ isDisabled={isLoading} isClearable={false} onChange={handleFieldListValueChange} + isRequired data-test-subj="exceptionBuilderEntryFieldList" /> ); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx index 7171d3c6b815..dace2eb5f067 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx @@ -38,18 +38,20 @@ import { existsOperator, doesNotExistOperator, } from '../autocomplete/operators'; -import { OperatorTypeEnum, OperatorEnum } from '../../../lists_plugin_deps'; +import { OperatorTypeEnum, OperatorEnum, EntryNested } from '../../../lists_plugin_deps'; import { getExceptionListItemSchemaMock } from '../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; -import { - getEntryExistsMock, - getEntryListMock, - getEntryMatchMock, - getEntryMatchAnyMock, - getEntriesArrayMock, -} from '../../../../../lists/common/schemas/types/entries.mock'; +import { getEntryMatchMock } from '../../../../../lists/common/schemas/types/entry_match.mock'; +import { getEntryMatchAnyMock } from '../../../../../lists/common/schemas/types/entry_match_any.mock'; +import { getEntryExistsMock } from '../../../../../lists/common/schemas/types/entry_exists.mock'; +import { getEntryListMock } from '../../../../../lists/common/schemas/types/entry_list.mock'; import { getCommentsArrayMock } from '../../../../../lists/common/schemas/types/comments.mock'; +import { getEntriesArrayMock } from '../../../../../lists/common/schemas/types/entries.mock'; import { ENTRIES } from '../../../../../lists/common/constants.mock'; -import { ExceptionListItemSchema, EntriesArray } from '../../../../../lists/common/schemas'; +import { + CreateExceptionListItemSchema, + ExceptionListItemSchema, + EntriesArray, +} from '../../../../../lists/common/schemas'; import { IIndexPattern } from 'src/plugins/data/common'; describe('Exception helpers', () => { @@ -251,8 +253,8 @@ describe('Exception helpers', () => { { fieldName: 'host.name.host.name', isNested: true, - operator: 'is', - value: 'some host name', + operator: 'is one of', + value: ['some host name'], }, ]; expect(result).toEqual(expected); @@ -482,7 +484,7 @@ describe('Exception helpers', () => { }); describe('#filterExceptionItems', () => { - test('it removes empty entry items', () => { + test('it removes entry items with "value" of "undefined"', () => { const { entries, ...rest } = getExceptionListItemSchemaMock(); const mockEmptyException: EmptyEntry = { field: 'host.name', @@ -500,6 +502,85 @@ describe('Exception helpers', () => { expect(exceptions).toEqual([getExceptionListItemSchemaMock()]); }); + test('it removes "match" entry items with "value" of empty string', () => { + const { entries, ...rest } = { ...getExceptionListItemSchemaMock() }; + const mockEmptyException: EmptyEntry = { + field: 'host.name', + type: OperatorTypeEnum.MATCH, + operator: OperatorEnum.INCLUDED, + value: '', + }; + const output: Array< + ExceptionListItemSchema | CreateExceptionListItemSchema + > = filterExceptionItems([ + { + ...rest, + entries: [...entries, mockEmptyException], + }, + ]); + + expect(output).toEqual([{ ...getExceptionListItemSchemaMock() }]); + }); + + test('it removes "match" entry items with "field" of empty string', () => { + const { entries, ...rest } = { ...getExceptionListItemSchemaMock() }; + const mockEmptyException: EmptyEntry = { + field: '', + type: OperatorTypeEnum.MATCH, + operator: OperatorEnum.INCLUDED, + value: 'some value', + }; + const output: Array< + ExceptionListItemSchema | CreateExceptionListItemSchema + > = filterExceptionItems([ + { + ...rest, + entries: [...entries, mockEmptyException], + }, + ]); + + expect(output).toEqual([{ ...getExceptionListItemSchemaMock() }]); + }); + + test('it removes "match_any" entry items with "field" of empty string', () => { + const { entries, ...rest } = { ...getExceptionListItemSchemaMock() }; + const mockEmptyException: EmptyEntry = { + field: '', + type: OperatorTypeEnum.MATCH_ANY, + operator: OperatorEnum.INCLUDED, + value: ['some value'], + }; + const output: Array< + ExceptionListItemSchema | CreateExceptionListItemSchema + > = filterExceptionItems([ + { + ...rest, + entries: [...entries, mockEmptyException], + }, + ]); + + expect(output).toEqual([{ ...getExceptionListItemSchemaMock() }]); + }); + + test('it removes "nested" entry items with "field" of empty string', () => { + const { entries, ...rest } = { ...getExceptionListItemSchemaMock() }; + const mockEmptyException: EntryNested = { + field: '', + type: OperatorTypeEnum.NESTED, + entries: [{ ...getEntryMatchMock() }], + }; + const output: Array< + ExceptionListItemSchema | CreateExceptionListItemSchema + > = filterExceptionItems([ + { + ...rest, + entries: [...entries, mockEmptyException], + }, + ]); + + expect(output).toEqual([{ ...getExceptionListItemSchemaMock() }]); + }); + test('it removes `temporaryId` from items', () => { const { meta, ...rest } = getNewExceptionItem({ listType: 'detection', @@ -509,7 +590,7 @@ describe('Exception helpers', () => { }); const exceptions = filterExceptionItems([{ ...rest, meta }]); - expect(exceptions).toEqual([{ ...rest, meta: undefined }]); + expect(exceptions).toEqual([{ ...rest, entries: [], meta: undefined }]); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx index 3d028431de8f..4d8fc5f68870 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx @@ -39,6 +39,7 @@ import { EntryNested, } from '../../../lists_plugin_deps'; import { IFieldType, IIndexPattern } from '../../../../../../../src/plugins/data/common'; +import { validate } from '../../../../common/validate'; import { TimelineNonEcsData } from '../../../graphql/types'; import { WithCopyToClipboard } from '../../lib/clipboard/with_copy_to_clipboard'; @@ -348,11 +349,22 @@ export const filterExceptionItems = ( ): Array => { return exceptions.reduce>( (acc, exception) => { - const entries = exception.entries.filter((t) => entry.is(t) || entriesNested.is(t)); + const entries = exception.entries.filter((t) => { + const [validatedEntry] = validate(t, entry); + const [validatedNestedEntry] = validate(t, entriesNested); + + if (validatedEntry != null || validatedNestedEntry != null) { + return true; + } + + return false; + }); + const item = { ...exception, entries }; + if (exceptionListItemSchema.is(item)) { return [...acc, item]; - } else if (createExceptionListItemSchema.is(item) && item.meta != null) { + } else if (createExceptionListItemSchema.is(item)) { const { meta, ...rest } = item; const itemSansMetaId: CreateExceptionListItemSchema = { ...rest, meta: undefined }; return [...acc, itemSansMetaId]; diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.test.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.test.ts index d3d073efa73c..bb8b4fb3d5ce 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.test.ts @@ -8,7 +8,7 @@ import { ExceptionListClient } from '../../../../../lists/server'; import { listMock } from '../../../../../lists/server/mocks'; import { getFoundExceptionListItemSchemaMock } from '../../../../../lists/common/schemas/response/found_exception_list_item_schema.mock'; import { getExceptionListItemSchemaMock } from '../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; -import { EntriesArray, EntryList } from '../../../../../lists/common/schemas/types/entries'; +import { EntriesArray, EntryList } from '../../../../../lists/common/schemas/types'; import { buildArtifact, getFullEndpointExceptionList } from './lists'; import { TranslatedEntry, TranslatedExceptionListItem } from '../../schemas/artifacts'; diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts index 68fa2a0511a4..5998a88527f2 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts @@ -9,7 +9,7 @@ import { deflate } from 'zlib'; import { ExceptionListItemSchema } from '../../../../../lists/common/schemas'; import { validate } from '../../../../common/validate'; -import { Entry, EntryNested } from '../../../../../lists/common/schemas/types/entries'; +import { Entry, EntryNested } from '../../../../../lists/common/schemas/types'; import { FoundExceptionListItemSchema } from '../../../../../lists/common/schemas/response/found_exception_list_item_schema'; import { ExceptionListClient } from '../../../../../lists/server'; import { ENDPOINT_LIST_ID } from '../../../../common/shared_imports'; From 1a1d7049e8ea14b60eb30cc85b7f19cc98a7777b Mon Sep 17 00:00:00 2001 From: Garrett Spong Date: Tue, 21 Jul 2020 19:21:40 -0600 Subject: [PATCH 020/142] [Security Solution] Fixes exception modal not loading content (#72770) ## Summary When using the `useFetchIndexPatterns` hook multiple times within a component (e.g. add_exception_modal & edit_exception_modal), the `apolloClient` will perform `queryDeduplication` and prevent the first query from executing. A deep compare is not performed on `indices`, so another field must be passed to circumvent this. For all the lovely details, see https://github.com/apollographql/react-apollo/issues/2202 Note: As of yesterday, [support has been added](https://github.com/apollographql/apollo-client/pull/6526) for configuring `queryDeduplicating` via `context`. This is available in `apollo-client` `2.6`, so when upgrading (currently on `2.3.8`) we can swap out this workaround to leverage this functionality. Note II: This [link](https://www.apollographql.com/docs/link/links/dedup/#context) may also be an option after upgrading to a supported version. --- .../exceptions/add_exception_modal/index.tsx | 7 +++++-- .../exceptions/edit_exception_modal/index.tsx | 7 +++++-- .../detection_engine/rules/fetch_index_patterns.tsx | 10 +++++++++- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx index e630645ef8c4..6e77cd7082d5 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx @@ -114,9 +114,12 @@ export const AddExceptionModal = memo(function AddExceptionModal({ const { loading: isSignalIndexLoading, signalIndexName } = useSignalIndex(); const [ { isLoading: isSignalIndexPatternLoading, indexPatterns: signalIndexPatterns }, - ] = useFetchIndexPatterns(signalIndexName !== null ? [signalIndexName] : []); + ] = useFetchIndexPatterns(signalIndexName !== null ? [signalIndexName] : [], 'signals'); - const [{ isLoading: isIndexPatternLoading, indexPatterns }] = useFetchIndexPatterns(ruleIndices); + const [{ isLoading: isIndexPatternLoading, indexPatterns }] = useFetchIndexPatterns( + ruleIndices, + 'rules' + ); const onError = useCallback( (error: Error) => { diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx index d07a8b5f0d2f..2d12cfbec160 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx @@ -97,9 +97,12 @@ export const EditExceptionModal = memo(function EditExceptionModal({ const { loading: isSignalIndexLoading, signalIndexName } = useSignalIndex(); const [ { isLoading: isSignalIndexPatternLoading, indexPatterns: signalIndexPatterns }, - ] = useFetchIndexPatterns(signalIndexName !== null ? [signalIndexName] : []); + ] = useFetchIndexPatterns(signalIndexName !== null ? [signalIndexName] : [], 'signals'); - const [{ isLoading: isIndexPatternLoading, indexPatterns }] = useFetchIndexPatterns(ruleIndices); + const [{ isLoading: isIndexPatternLoading, indexPatterns }] = useFetchIndexPatterns( + ruleIndices, + 'rules' + ); const onError = useCallback( (error) => { diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/fetch_index_patterns.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/fetch_index_patterns.tsx index 6257a9980e00..c0997a5e6290 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/fetch_index_patterns.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/fetch_index_patterns.tsx @@ -38,7 +38,14 @@ const DEFAULT_BROWSER_FIELDS = {}; const DEFAULT_INDEX_PATTERNS = { fields: [], title: '' }; const DEFAULT_DOC_VALUE_FIELDS: DocValueFields[] = []; -export const useFetchIndexPatterns = (defaultIndices: string[] = []): Return => { +// Fun fact: When using this hook multiple times within a component (e.g. add_exception_modal & edit_exception_modal), +// the apolloClient will perform queryDeduplication and prevent the first query from executing. A deep compare is not +// performed on `indices`, so another field must be passed to circumvent this. +// For details, see https://github.com/apollographql/react-apollo/issues/2202 +export const useFetchIndexPatterns = ( + defaultIndices: string[] = [], + queryDeduplication?: string +): Return => { const apolloClient = useApolloClient(); const [indices, setIndices] = useState(defaultIndices); @@ -74,6 +81,7 @@ export const useFetchIndexPatterns = (defaultIndices: string[] = []): Return => variables: { sourceId: 'default', defaultIndex: indices, + ...(queryDeduplication != null ? { queryDeduplication } : {}), }, context: { fetchOptions: { From 6f405289ecd7fea77d08633ccfe85ddfffe96621 Mon Sep 17 00:00:00 2001 From: Andrew Cholakian Date: Tue, 21 Jul 2020 20:36:43 -0500 Subject: [PATCH 021/142] [Uptime] Rename Whitelist to Allowlist in parse_filter_map (#71584) Fixes https://github.com/elastic/kibana/issues/71583 Co-authored-by: Elastic Machine --- .../components/overview/filter_group/parse_filter_map.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/uptime/public/components/overview/filter_group/parse_filter_map.ts b/x-pack/plugins/uptime/public/components/overview/filter_group/parse_filter_map.ts index 08766521799e..47c86543c128 100644 --- a/x-pack/plugins/uptime/public/components/overview/filter_group/parse_filter_map.ts +++ b/x-pack/plugins/uptime/public/components/overview/filter_group/parse_filter_map.ts @@ -14,7 +14,7 @@ interface FilterField { * If your code needs to support custom fields, introduce a second parameter to * `parseFiltersMap` to take a list of FilterField objects. */ -const filterWhitelist: FilterField[] = [ +const filterAllowList: FilterField[] = [ { name: 'ports', fieldName: 'url.port' }, { name: 'locations', fieldName: 'observer.geo.name' }, { name: 'tags', fieldName: 'tags' }, @@ -28,7 +28,7 @@ export const parseFiltersMap = (filterMapString: string) => { const filterSlices: { [key: string]: any } = {}; try { const map = new Map(JSON.parse(filterMapString)); - filterWhitelist.forEach(({ name, fieldName }) => { + filterAllowList.forEach(({ name, fieldName }) => { filterSlices[name] = map.get(fieldName) ?? []; }); return filterSlices; From ad65b2ce34354b59f38b44da1c7d0dd01e0aedc9 Mon Sep 17 00:00:00 2001 From: Andrew Goldstein Date: Wed, 22 Jul 2020 00:12:13 -0600 Subject: [PATCH 022/142] [Security Solution] Hide KQL bar (all pages) and alerts filters (Detections) when Resolver is full screen (#72788) ## Summary Fixes an issue where the KQL bar (on all pages) and alerts filters (on the `Detections` page) should be hidden when Resolver is in full screen mode. **To reproduce:** 1) Navigate to the `Detections` page 2) Enter `agent.type : endpoint` in the KQL bar to only show endpoint alerts 3) Click the `Full screen` button in the detections table **Expected result** * The KQL bar, inspect button, alerts filters (`Open | In progress | Closed`), and `Showing n alerts`, `Select all n alerts`, and `Additional filters` actions are visible in full screen mode 4) Click the `Analyze event` button to show Resolver **Expected result** * The KQL bar, inspect button, alerts filters (`Open | In progress | Closed`), `Showing n alerts`, `Select all n alerts`, and `Additional filters` actions are **NOT** visible in full screen mode **when Resolver is open** **Actual result** * The KQL bar, inspect button, alerts filters (`Open | In progress | Closed`), `Showing n alerts`, `Select all n alerts`, and `Additional filters` actions are (incorrectly) visible in full screen mode, per the screenshot below: ![filters-in-full-screen-mode](https://user-images.githubusercontent.com/4459398/88079205-9f565b80-cb3a-11ea-996a-fb71bf43c473.png) 5) Click the `< Back to events` button **Expected result** * The KQL bar, inspect button, alerts filters (`Open | In progress | Closed`), `Showing n alerts`, `Select all n alerts`, and `Additional filters` actions become visible again 6) Press the `Esc` (Escape) key to exit Full screen mode **Expected result** * The KQL bar, inspect button, alerts filters (`Open | In progress | Closed`), `Showing n alerts`, `Select all n alerts`, and `Additional filters` actions are (still) visible ## Screenshot (fixed) The following screenshot of the fix was taken from the `Detections` page after following the reproduction steps above: ![filters-in-full-screen-mode-fixed](https://user-images.githubusercontent.com/4459398/88125154-e882cb80-cb8b-11ea-9b45-718fd9ef0844.png) --- .../cypress/integration/events_viewer.spec.ts | 2 +- .../events_viewer/events_viewer.test.tsx | 247 ++++++++++++++++++ .../events_viewer/events_viewer.tsx | 26 +- .../filters_global/filters_global.tsx | 19 +- .../components/header_section/index.test.tsx | 24 ++ .../alerts_filter_group/index.tsx | 2 +- .../detection_engine.test.tsx | 1 + .../detection_engine/detection_engine.tsx | 13 +- .../rules/details/index.test.tsx | 1 + .../detection_engine/rules/details/index.tsx | 13 +- .../public/hosts/pages/details/index.tsx | 193 ++++++++------ .../public/hosts/pages/hosts.tsx | 29 +- .../public/network/pages/network.tsx | 24 +- .../components/timeline/helpers.test.tsx | 42 ++- .../timelines/components/timeline/helpers.tsx | 11 + 15 files changed, 537 insertions(+), 110 deletions(-) diff --git a/x-pack/plugins/security_solution/cypress/integration/events_viewer.spec.ts b/x-pack/plugins/security_solution/cypress/integration/events_viewer.spec.ts index 84ca1e20e957..cd4573817cc2 100644 --- a/x-pack/plugins/security_solution/cypress/integration/events_viewer.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/events_viewer.spec.ts @@ -153,7 +153,7 @@ describe('Events Viewer', () => { }); }); - context('Events columns', () => { + context.skip('Events columns', () => { before(() => { loginAndWaitForPage(HOSTS_URL); openEvents(); diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx index 049953e21feb..833688ae5799 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.test.tsx @@ -15,11 +15,17 @@ import { wait as waitFor } from '@testing-library/react'; import { mockEventViewerResponse } from './mock'; import { StatefulEventsViewer } from '.'; +import { EventsViewer } from './events_viewer'; import { defaultHeaders } from './default_headers'; import { useFetchIndexPatterns } from '../../../detections/containers/detection_engine/rules/fetch_index_patterns'; import { mockBrowserFields, mockDocValueFields } from '../../containers/source/mock'; import { eventsDefaultModel } from './default_model'; import { useMountAppended } from '../../utils/use_mount_appended'; +import { inputsModel } from '../../store/inputs'; +import { TimelineId } from '../../../../common/types/timeline'; +import { KqlMode } from '../../../timelines/store/timeline/model'; +import { SortDirection } from '../../../timelines/components/timeline/body/sort'; +import { AlertsTableFilterGroup } from '../../../detections/components/alerts_table/alerts_filter_group'; jest.mock('../../components/url_state/normalize_time_range.ts'); @@ -40,6 +46,39 @@ const defaultMocks = { isLoading: false, }; +const utilityBar = (refetch: inputsModel.Refetch, totalCount: number) => ( +

+); + +const eventsViewerDefaultProps = { + browserFields: {}, + columns: [], + dataProviders: [], + deletedEventIds: [], + docValueFields: [], + end: to, + filters: [], + id: TimelineId.detectionsPage, + indexPattern: mockIndexPattern, + isLive: false, + isLoadingIndexPattern: false, + itemsPerPage: 10, + itemsPerPageOptions: [], + kqlMode: 'filter' as KqlMode, + onChangeItemsPerPage: jest.fn(), + query: { + query: '', + language: 'kql', + }, + start: from, + sort: { + columnId: 'foo', + sortDirection: 'none' as SortDirection, + }, + toggleColumn: jest.fn(), + utilityBar, +}; + describe('EventsViewer', () => { const mount = useMountAppended(); @@ -213,4 +252,212 @@ describe('EventsViewer', () => { }); }); }); + + describe('headerFilterGroup', () => { + test('it renders the provided headerFilterGroup', async () => { + const wrapper = mount( + + + } + /> + + + ); + + await waitFor(() => { + wrapper.update(); + + expect(wrapper.find(`[data-test-subj="alerts-table-filter-group"]`).exists()).toBe(true); + }); + }); + + test('it has a visible HeaderFilterGroupWrapper when Resolver is NOT showing, because graphEventId is undefined', async () => { + const wrapper = mount( + + + } + /> + + + ); + + await waitFor(() => { + wrapper.update(); + + expect( + wrapper.find(`[data-test-subj="header-filter-group-wrapper"]`).first() + ).not.toHaveStyleRule('visibility', 'hidden'); + }); + }); + + test('it has a visible HeaderFilterGroupWrapper when Resolver is NOT showing, because graphEventId is an empty string', async () => { + const wrapper = mount( + + + } + /> + + + ); + + await waitFor(() => { + wrapper.update(); + + expect( + wrapper.find(`[data-test-subj="header-filter-group-wrapper"]`).first() + ).not.toHaveStyleRule('visibility', 'hidden'); + }); + }); + + test('it does NOT have a visible HeaderFilterGroupWrapper when Resolver is showing, because graphEventId is a valid id', async () => { + const wrapper = mount( + + + } + /> + + + ); + + await waitFor(() => { + wrapper.update(); + + expect( + wrapper.find(`[data-test-subj="header-filter-group-wrapper"]`).first() + ).toHaveStyleRule('visibility', 'hidden'); + }); + }); + + test('it (still) renders an invisible headerFilterGroup (to maintain state while hidden) when Resolver is showing, because graphEventId is a valid id', async () => { + const wrapper = mount( + + + } + /> + + + ); + + await waitFor(() => { + wrapper.update(); + + expect(wrapper.find(`[data-test-subj="alerts-table-filter-group"]`).exists()).toBe(true); + }); + }); + }); + + describe('utilityBar', () => { + test('it renders the provided utilityBar when Resolver is NOT showing, because graphEventId is undefined', async () => { + const wrapper = mount( + + + + + + ); + + await waitFor(() => { + wrapper.update(); + + expect(wrapper.find(`[data-test-subj="mock-utility-bar"]`).exists()).toBe(true); + }); + }); + + test('it renders the provided utilityBar when Resolver is NOT showing, because graphEventId is an empty string', async () => { + const wrapper = mount( + + + + + + ); + + await waitFor(() => { + wrapper.update(); + + expect(wrapper.find(`[data-test-subj="mock-utility-bar"]`).exists()).toBe(true); + }); + }); + + test('it does NOT render the provided utilityBar when Resolver is showing, because graphEventId is a valid id', async () => { + const wrapper = mount( + + + + + + ); + + await waitFor(() => { + wrapper.update(); + + expect(wrapper.find(`[data-test-subj="mock-utility-bar"]`).exists()).toBe(false); + }); + }); + }); + + describe('header inspect button', () => { + test('it renders the inspect button when Resolver is NOT showing, because graphEventId is undefined', async () => { + const wrapper = mount( + + + + + + ); + + await waitFor(() => { + wrapper.update(); + + expect(wrapper.find(`[data-test-subj="inspect-icon-button"]`).exists()).toBe(true); + }); + }); + + test('it renders the inspect button when Resolver is NOT showing, because graphEventId is an empty string', async () => { + const wrapper = mount( + + + + + + ); + + await waitFor(() => { + wrapper.update(); + + expect(wrapper.find(`[data-test-subj="inspect-icon-button"]`).exists()).toBe(true); + }); + }); + + test('it does NOT render the inspect button when Resolver is showing, because graphEventId is a valid id', async () => { + const wrapper = mount( + + + + + + ); + + await waitFor(() => { + wrapper.update(); + + expect(wrapper.find(`[data-test-subj="inspect-icon-button"]`).exists()).toBe(false); + }); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx index 3f474da102ca..bc036b38524b 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx @@ -22,7 +22,7 @@ import { StatefulBody } from '../../../timelines/components/timeline/body/statef import { DataProvider } from '../../../timelines/components/timeline/data_providers/data_provider'; import { OnChangeItemsPerPage } from '../../../timelines/components/timeline/events'; import { Footer, footerHeight } from '../../../timelines/components/timeline/footer'; -import { combineQueries } from '../../../timelines/components/timeline/helpers'; +import { combineQueries, resolverIsShowing } from '../../../timelines/components/timeline/helpers'; import { TimelineRefetch } from '../../../timelines/components/timeline/refetch_timeline'; import { EventDetailsWidthProvider } from './event_details_width_context'; import * as i18n from './translations'; @@ -73,6 +73,16 @@ const EventsContainerLoading = styled.div` overflow: auto; `; +/** + * Hides stateful headerFilterGroup implementations, but prevents the component + * from being unmounted, to preserve the state of the component + */ +const HeaderFilterGroupWrapper = styled.header<{ show: boolean }>` + ${({ show }) => css` + ${show ? '' : 'visibility: hidden;'}; + `} +`; + interface Props { browserFields: BrowserFields; columns: ColumnHeaderOptions[]; @@ -234,14 +244,21 @@ const EventsViewerComponent: React.FC = ({ return ( <> - {headerFilterGroup} + {headerFilterGroup && ( + + {headerFilterGroup} + + )} - {utilityBar && ( + {utilityBar && !resolverIsShowing(graphEventId) && ( {utilityBar?.(refetch, totalCountMinusDeleted)} )} @@ -307,6 +324,7 @@ export const EventsViewer = React.memo( prevProps.deletedEventIds === nextProps.deletedEventIds && prevProps.end === nextProps.end && deepEqual(prevProps.filters, nextProps.filters) && + prevProps.headerFilterGroup === nextProps.headerFilterGroup && prevProps.height === nextProps.height && prevProps.id === nextProps.id && deepEqual(prevProps.indexPattern, nextProps.indexPattern) && diff --git a/x-pack/plugins/security_solution/public/common/components/filters_global/filters_global.tsx b/x-pack/plugins/security_solution/public/common/components/filters_global/filters_global.tsx index 65901ec589da..b52438486406 100644 --- a/x-pack/plugins/security_solution/public/common/components/filters_global/filters_global.tsx +++ b/x-pack/plugins/security_solution/public/common/components/filters_global/filters_global.tsx @@ -39,16 +39,27 @@ const Wrapper = styled.aside<{ isSticky?: boolean }>` `; Wrapper.displayName = 'Wrapper'; +const FiltersGlobalContainer = styled.header<{ show: boolean }>` + ${({ show }) => css` + ${show ? '' : 'display: none;'}; + `} +`; + +FiltersGlobalContainer.displayName = 'FiltersGlobalContainer'; + export interface FiltersGlobalProps { children: React.ReactNode; + show?: boolean; } -export const FiltersGlobal = React.memo(({ children }) => ( +export const FiltersGlobal = React.memo(({ children, show = true }) => ( {({ style, isSticky }) => ( - - {children} - + + + {children} + + )} )); diff --git a/x-pack/plugins/security_solution/public/common/components/header_section/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/header_section/index.test.tsx index b33ce22651d6..32f6216be63f 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_section/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/header_section/index.test.tsx @@ -131,4 +131,28 @@ describe('HeaderSection', () => { .exists() ).toBe(true); }); + + test('it renders an inspect button when an `id` is provided', () => { + const wrapper = mount( + + +

{'Test children'}

+
+
+ ); + + expect(wrapper.find('[data-test-subj="inspect-icon-button"]').first().exists()).toBe(true); + }); + + test('it does NOT an inspect button when an `id` is NOT provided', () => { + const wrapper = mount( + + +

{'Test children'}

+
+
+ ); + + expect(wrapper.find('[data-test-subj="inspect-icon-button"]').first().exists()).toBe(false); + }); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/alerts_filter_group/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/alerts_filter_group/index.tsx index ba64868b7081..a227d2d3c3a8 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/alerts_filter_group/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/alerts_filter_group/index.tsx @@ -36,7 +36,7 @@ const AlertsTableFilterGroupComponent: React.FC = ({ onFilterGroupChanged }, [setFilterGroup, onFilterGroupChanged]); return ( - + { = ({ filters, + graphEventId, query, setAbsoluteRangeDatePicker, }) => { @@ -151,7 +156,7 @@ export const DetectionEnginePageComponent: React.FC = ({ {indicesExist ? ( - + @@ -232,13 +237,19 @@ export const DetectionEnginePageComponent: React.FC = ({ const makeMapStateToProps = () => { const getGlobalInputs = inputsSelectors.globalSelector(); + const getTimeline = timelineSelectors.getTimelineByIdSelector(); return (state: State) => { const globalInputs: InputsRange = getGlobalInputs(state); const { query, filters } = globalInputs; + const timeline: TimelineModel = + getTimeline(state, TimelineId.detectionsPage) ?? timelineDefaults; + const { graphEventId } = timeline; + return { query, filters, + graphEventId, }; }; }; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.test.tsx index a251c617e542..5e6587dab173 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.test.tsx @@ -82,6 +82,7 @@ describe('RuleDetailsPageComponent', () => { { export const RuleDetailsPageComponent: FC = ({ filters, + graphEventId, query, setAbsoluteRangeDatePicker, }) => { @@ -351,7 +356,7 @@ export const RuleDetailsPageComponent: FC = ({ {indicesExist ? ( - + @@ -541,13 +546,19 @@ RuleDetailsPageComponent.displayName = 'RuleDetailsPageComponent'; const makeMapStateToProps = () => { const getGlobalInputs = inputsSelectors.globalSelector(); + const getTimeline = timelineSelectors.getTimelineByIdSelector(); return (state: State) => { const globalInputs: InputsRange = getGlobalInputs(state); const { query, filters } = globalInputs; + const timeline: TimelineModel = + getTimeline(state, TimelineId.detectionsRulesDetailsPage) ?? timelineDefaults; + const { graphEventId } = timeline; + return { query, filters, + graphEventId, }; }; }; diff --git a/x-pack/plugins/security_solution/public/hosts/pages/details/index.tsx b/x-pack/plugins/security_solution/public/hosts/pages/details/index.tsx index 447d003625c8..781aa711ff0d 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/details/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/details/index.tsx @@ -4,7 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiHorizontalRule, EuiSpacer } from '@elastic/eui'; +import { EuiHorizontalRule, EuiSpacer, EuiWindowEvent } from '@elastic/eui'; +import { noop } from 'lodash/fp'; import React, { useEffect, useCallback, useMemo } from 'react'; import { connect, ConnectedProps } from 'react-redux'; import { StickyContainer } from 'react-sticky'; @@ -44,6 +45,13 @@ import { navTabsHostDetails } from './nav_tabs'; import { HostDetailsProps } from './types'; import { type } from './utils'; import { getHostDetailsPageFilters } from './helpers'; +import { showGlobalFilters } from '../../../timelines/components/timeline/helpers'; +import { useFullScreen } from '../../../common/containers/use_full_screen'; +import { Display } from '../display'; +import { timelineSelectors } from '../../../timelines/store/timeline'; +import { TimelineModel } from '../../../timelines/store/timeline/model'; +import { TimelineId } from '../../../../common/types/timeline'; +import { timelineDefaults } from '../../../timelines/store/timeline/defaults'; const HostOverviewManage = manageQuery(HostOverview); const KpiHostDetailsManage = manageQuery(KpiHostsComponent); @@ -51,6 +59,7 @@ const KpiHostDetailsManage = manageQuery(KpiHostsComponent); const HostDetailsComponent = React.memo( ({ filters, + graphEventId, query, setAbsoluteRangeDatePicker, setHostDetailsTablesActivePageToZero, @@ -58,6 +67,7 @@ const HostDetailsComponent = React.memo( hostDetailsPagePath, }) => { const { to, from, deleteQuery, setQuery, isInitializing } = useGlobalTime(); + const { globalFullScreen } = useFullScreen(); useEffect(() => { setHostDetailsTablesActivePageToZero(); }, [setHostDetailsTablesActivePageToZero, detailName]); @@ -93,90 +103,93 @@ const HostDetailsComponent = React.memo( <> {indicesExist ? ( - + + - - - } - title={detailName} - /> - - - {({ hostOverview, loading, id, inspect, refetch }) => ( - - {({ isLoadingAnomaliesData, anomaliesData }) => ( - { - const fromTo = scoreIntervalToDateTime(score, interval); - setAbsoluteRangeDatePicker({ - id: 'global', - from: fromTo.from, - to: fromTo.to, - }); - }} - /> - )} - - )} - - - - - - {({ kpiHostDetails, id, inspect, loading, refetch }) => ( - - )} - - - - - - - + + + + } + title={detailName} + /> + + + {({ hostOverview, loading, id, inspect, refetch }) => ( + + {({ isLoadingAnomaliesData, anomaliesData }) => ( + { + const fromTo = scoreIntervalToDateTime(score, interval); + setAbsoluteRangeDatePicker({ + id: 'global', + from: fromTo.from, + to: fromTo.to, + }); + }} + /> + )} + + )} + + + + + + {({ kpiHostDetails, id, inspect, loading, refetch }) => ( + + )} + + + + + + + + { const getGlobalQuerySelector = inputsSelectors.globalQuerySelector(); const getGlobalFiltersQuerySelector = inputsSelectors.globalFiltersQuerySelector(); - return (state: State) => ({ - query: getGlobalQuerySelector(state), - filters: getGlobalFiltersQuerySelector(state), - }); + const getTimeline = timelineSelectors.getTimelineByIdSelector(); + return (state: State) => { + const timeline: TimelineModel = + getTimeline(state, TimelineId.hostsPageEvents) ?? timelineDefaults; + const { graphEventId } = timeline; + + return { + query: getGlobalQuerySelector(state), + filters: getGlobalFiltersQuerySelector(state), + graphEventId, + }; + }; }; const mapDispatchToProps = { diff --git a/x-pack/plugins/security_solution/public/hosts/pages/hosts.tsx b/x-pack/plugins/security_solution/public/hosts/pages/hosts.tsx index a3885eac5377..1219effa5ff6 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/hosts.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/hosts.tsx @@ -26,6 +26,7 @@ import { KpiHostsQuery } from '../containers/kpi_hosts'; import { useFullScreen } from '../../common/containers/use_full_screen'; import { useGlobalTime } from '../../common/containers/use_global_time'; import { useWithSource } from '../../common/containers/source'; +import { TimelineId } from '../../../common/types/timeline'; import { LastEventIndexKey } from '../../graphql/types'; import { useKibana } from '../../common/lib/kibana'; import { convertToBuildEsQuery } from '../../common/lib/keury'; @@ -44,11 +45,15 @@ import { HostsComponentProps } from './types'; import { filterHostData } from './navigation'; import { hostsModel } from '../store'; import { HostsTableType } from '../store/model'; +import { showGlobalFilters } from '../../timelines/components/timeline/helpers'; +import { timelineSelectors } from '../../timelines/store/timeline'; +import { timelineDefaults } from '../../timelines/store/timeline/defaults'; +import { TimelineModel } from '../../timelines/store/timeline/model'; const KpiHostsComponentManage = manageQuery(KpiHostsComponent); export const HostsComponent = React.memo( - ({ filters, query, setAbsoluteRangeDatePicker, hostsPagePath }) => { + ({ filters, graphEventId, query, setAbsoluteRangeDatePicker, hostsPagePath }) => { const { to, from, deleteQuery, setQuery, isInitializing } = useGlobalTime(); const { globalFullScreen } = useFullScreen(); const capabilities = useMlCapabilities(); @@ -93,7 +98,7 @@ export const HostsComponent = React.memo( {indicesExist ? ( - + @@ -167,10 +172,22 @@ HostsComponent.displayName = 'HostsComponent'; const makeMapStateToProps = () => { const getGlobalQuerySelector = inputsSelectors.globalQuerySelector(); const getGlobalFiltersQuerySelector = inputsSelectors.globalFiltersQuerySelector(); - const mapStateToProps = (state: State) => ({ - query: getGlobalQuerySelector(state), - filters: getGlobalFiltersQuerySelector(state), - }); + const getTimeline = timelineSelectors.getTimelineByIdSelector(); + const mapStateToProps = (state: State) => { + const hostsPageEventsTimeline: TimelineModel = + getTimeline(state, TimelineId.hostsPageEvents) ?? timelineDefaults; + const { graphEventId: hostsPageEventsGraphEventId } = hostsPageEventsTimeline; + + const hostsPageExternalAlertsTimeline: TimelineModel = + getTimeline(state, TimelineId.hostsPageExternalAlerts) ?? timelineDefaults; + const { graphEventId: hostsPageExternalAlertsGraphEventId } = hostsPageExternalAlertsTimeline; + + return { + query: getGlobalQuerySelector(state), + filters: getGlobalFiltersQuerySelector(state), + graphEventId: hostsPageEventsGraphEventId ?? hostsPageExternalAlertsGraphEventId, + }; + }; return mapStateToProps; }; diff --git a/x-pack/plugins/security_solution/public/network/pages/network.tsx b/x-pack/plugins/security_solution/public/network/pages/network.tsx index 0def110c45a1..ca8da4eb711e 100644 --- a/x-pack/plugins/security_solution/public/network/pages/network.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/network.tsx @@ -41,6 +41,11 @@ import { OverviewEmpty } from '../../overview/components/overview_empty'; import * as i18n from './translations'; import { NetworkComponentProps } from './types'; import { NetworkRouteType } from './navigation/types'; +import { showGlobalFilters } from '../../timelines/components/timeline/helpers'; +import { timelineSelectors } from '../../timelines/store/timeline'; +import { TimelineId } from '../../../common/types/timeline'; +import { timelineDefaults } from '../../timelines/store/timeline/defaults'; +import { TimelineModel } from '../../timelines/store/timeline/model'; const KpiNetworkComponentManage = manageQuery(KpiNetworkComponent); const sourceId = 'default'; @@ -48,6 +53,7 @@ const sourceId = 'default'; const NetworkComponent = React.memo( ({ filters, + graphEventId, query, setAbsoluteRangeDatePicker, networkPagePath, @@ -100,7 +106,7 @@ const NetworkComponent = React.memo( {indicesExist ? ( - + @@ -189,10 +195,18 @@ NetworkComponent.displayName = 'NetworkComponent'; const makeMapStateToProps = () => { const getGlobalQuerySelector = inputsSelectors.globalQuerySelector(); const getGlobalFiltersQuerySelector = inputsSelectors.globalFiltersQuerySelector(); - const mapStateToProps = (state: State) => ({ - query: getGlobalQuerySelector(state), - filters: getGlobalFiltersQuerySelector(state), - }); + const getTimeline = timelineSelectors.getTimelineByIdSelector(); + const mapStateToProps = (state: State) => { + const timeline: TimelineModel = + getTimeline(state, TimelineId.networkPageExternalAlerts) ?? timelineDefaults; + const { graphEventId } = timeline; + + return { + query: getGlobalQuerySelector(state), + filters: getGlobalFiltersQuerySelector(state), + graphEventId, + }; + }; return mapStateToProps; }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/helpers.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/helpers.test.tsx index c371d1862be7..21b96dfe4118 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/helpers.test.tsx @@ -9,7 +9,7 @@ import { mockIndexPattern } from '../../../common/mock'; import { DataProviderType } from './data_providers/data_provider'; import { mockDataProviders } from './data_providers/mock/mock_data_providers'; -import { buildGlobalQuery, combineQueries } from './helpers'; +import { buildGlobalQuery, combineQueries, resolverIsShowing, showGlobalFilters } from './helpers'; import { mockBrowserFields } from '../../../common/containers/source/mock'; import { EsQueryConfig, Filter, esFilters } from '../../../../../../../src/plugins/data/public'; @@ -500,4 +500,44 @@ describe('Combined Queries', () => { '{"bool":{"must":[],"filter":[{"bool":{"filter":[{"bool":{"should":[{"bool":{"filter":[{"bool":{"should":[{"match_phrase":{"name":"Provider 1"}}],"minimum_should_match":1}},{"bool":{"filter":[{"bool":{"should":[{"match_phrase":{"name":"Provider 3"}}],"minimum_should_match":1}},{"bool":{"should":[{"match_phrase":{"name":"Provider 4"}}],"minimum_should_match":1}}]}}]}},{"bool":{"filter":[{"bool":{"should":[{"match_phrase":{"name":"Provider 2"}}],"minimum_should_match":1}},{"bool":{"should":[{"match_phrase":{"name":"Provider 5"}}],"minimum_should_match":1}}]}}],"minimum_should_match":1}},{"bool":{"should":[{"match_phrase":{"host.name":"host-1"}}],"minimum_should_match":1}}]}}],"should":[],"must_not":[]}}' ); }); + + describe('resolverIsShowing', () => { + test('it returns true when graphEventId is NOT an empty string', () => { + expect(resolverIsShowing('a valid id')).toBe(true); + }); + + test('it returns false when graphEventId is undefined', () => { + expect(resolverIsShowing(undefined)).toBe(false); + }); + + test('it returns false when graphEventId is an empty string', () => { + expect(resolverIsShowing('')).toBe(false); + }); + }); + + describe('showGlobalFilters', () => { + test('it returns false when `globalFullScreen` is true and `graphEventId` is NOT an empty string, because Resolver IS showing', () => { + expect(showGlobalFilters({ globalFullScreen: true, graphEventId: 'a valid id' })).toBe(false); + }); + + test('it returns true when `globalFullScreen` is true and `graphEventId` is undefined, because Resolver is NOT showing', () => { + expect(showGlobalFilters({ globalFullScreen: true, graphEventId: undefined })).toBe(true); + }); + + test('it returns true when `globalFullScreen` is true and `graphEventId` is an empty string, because Resolver is NOT showing', () => { + expect(showGlobalFilters({ globalFullScreen: true, graphEventId: '' })).toBe(true); + }); + + test('it returns true when `globalFullScreen` is false and `graphEventId` is NOT an empty string, because Resolver IS showing', () => { + expect(showGlobalFilters({ globalFullScreen: false, graphEventId: 'a valid id' })).toBe(true); + }); + + test('it returns true when `globalFullScreen` is false and `graphEventId` is undefined, because Resolver is NOT showing', () => { + expect(showGlobalFilters({ globalFullScreen: false, graphEventId: undefined })).toBe(true); + }); + + test('it returns true when `globalFullScreen` is false and `graphEventId` is an empty string, because Resolver is NOT showing', () => { + expect(showGlobalFilters({ globalFullScreen: false, graphEventId: '' })).toBe(true); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/helpers.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/helpers.tsx index b21ea3e4f86e..84387720b5b1 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/helpers.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/helpers.tsx @@ -158,3 +158,14 @@ export const combineQueries = ({ export const STATEFUL_EVENT_CSS_CLASS_NAME = 'event-column-view'; export const DEFAULT_ICON_BUTTON_WIDTH = 24; + +export const resolverIsShowing = (graphEventId: string | undefined): boolean => + graphEventId != null && graphEventId !== ''; + +export const showGlobalFilters = ({ + globalFullScreen, + graphEventId, +}: { + globalFullScreen: boolean; + graphEventId: string | undefined; +}): boolean => (globalFullScreen && resolverIsShowing(graphEventId) ? false : true); From 47eaf604bba91de195dbae06f79da1c7bb5ae105 Mon Sep 17 00:00:00 2001 From: Pierre Gayvallet Date: Wed, 22 Jul 2020 09:25:53 +0200 Subject: [PATCH 023/142] fix preAuth/preRouting mocks (#72663) --- src/core/server/http/http_server.mocks.ts | 7 ++++++- src/core/server/http/http_service.mock.ts | 10 ++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/core/server/http/http_server.mocks.ts b/src/core/server/http/http_server.mocks.ts index 7d37af833d4c..ba6662db3655 100644 --- a/src/core/server/http/http_server.mocks.ts +++ b/src/core/server/http/http_server.mocks.ts @@ -89,7 +89,12 @@ function createKibanaRequestMock

({ settings: { tags: routeTags, auth: routeAuthRequired, app: kibanaRouteState }, }, raw: { - req: { socket }, + req: { + socket, + // these are needed to avoid an error when consuming KibanaRequest.events + on: jest.fn(), + off: jest.fn(), + }, }, }), { diff --git a/src/core/server/http/http_service.mock.ts b/src/core/server/http/http_service.mock.ts index 51f11b15f2e0..676cee1954c5 100644 --- a/src/core/server/http/http_service.mock.ts +++ b/src/core/server/http/http_service.mock.ts @@ -33,6 +33,7 @@ import { OnPreRoutingToolkit } from './lifecycle/on_pre_routing'; import { AuthToolkit } from './lifecycle/auth'; import { sessionStorageMock } from './cookie_session_storage.mocks'; import { OnPostAuthToolkit } from './lifecycle/on_post_auth'; +import { OnPreAuthToolkit } from './lifecycle/on_pre_auth'; import { OnPreResponseToolkit } from './lifecycle/on_pre_response'; type BasePathMocked = jest.Mocked; @@ -175,15 +176,19 @@ const createHttpServiceMock = () => { return mocked; }; -const createOnPreAuthToolkitMock = (): jest.Mocked => ({ +const createOnPreAuthToolkitMock = (): jest.Mocked => ({ next: jest.fn(), - rewriteUrl: jest.fn(), }); const createOnPostAuthToolkitMock = (): jest.Mocked => ({ next: jest.fn(), }); +const createOnPreRoutingToolkitMock = (): jest.Mocked => ({ + next: jest.fn(), + rewriteUrl: jest.fn(), +}); + const createAuthToolkitMock = (): jest.Mocked => ({ authenticated: jest.fn(), notHandled: jest.fn(), @@ -205,6 +210,7 @@ export const httpServiceMock = { createOnPreAuthToolkit: createOnPreAuthToolkitMock, createOnPostAuthToolkit: createOnPostAuthToolkitMock, createOnPreResponseToolkit: createOnPreResponseToolkitMock, + createOnPreRoutingToolkit: createOnPreRoutingToolkitMock, createAuthToolkit: createAuthToolkitMock, createRouter: mockRouter.create, }; From a93c327e9deb35d13dcb56361d3ad3d2c55c65c3 Mon Sep 17 00:00:00 2001 From: Pete Harverson Date: Wed, 22 Jul 2020 09:30:13 +0100 Subject: [PATCH 024/142] [ML] Fix layout of anomaly chart tooltip for long field values (#72689) --- .../components/chart_tooltip/_chart_tooltip.scss | 5 ----- .../components/chart_tooltip/chart_tooltip.tsx | 14 ++++++++++++-- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/x-pack/plugins/ml/public/application/components/chart_tooltip/_chart_tooltip.scss b/x-pack/plugins/ml/public/application/components/chart_tooltip/_chart_tooltip.scss index 25bf3597c346..46e5d91e1cc8 100644 --- a/x-pack/plugins/ml/public/application/components/chart_tooltip/_chart_tooltip.scss +++ b/x-pack/plugins/ml/public/application/components/chart_tooltip/_chart_tooltip.scss @@ -25,15 +25,10 @@ } &__label { - overflow-wrap: break-word; - word-wrap: break-word; min-width: 1px; - flex: 1 1 auto; } &__value { - overflow-wrap: break-word; - word-wrap: break-word; font-weight: $euiFontWeightBold; text-align: right; font-feature-settings: 'tnum'; diff --git a/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip.tsx b/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip.tsx index 7897ef5cad0d..3bd4ae174831 100644 --- a/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip.tsx +++ b/x-pack/plugins/ml/public/application/components/chart_tooltip/chart_tooltip.tsx @@ -7,6 +7,7 @@ import React, { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import classNames from 'classnames'; import TooltipTrigger from 'react-popper-tooltip'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { TooltipValueFormatter } from '@elastic/charts'; import './_index.scss'; @@ -79,8 +80,17 @@ const Tooltip: FC<{ service: ChartTooltipService }> = React.memo(({ service }) = borderLeftColor: color, }} > - {label} - {value} + + + {label} + + + {value} + +

); })} From 1810dd1fe78777652985361814228a27c00d3a5c Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Wed, 22 Jul 2020 11:25:41 +0200 Subject: [PATCH 025/142] Unskip vislib tests (#71452) --- .../value_axis_options.test.tsx.snap | 2 +- .../metrics_axes/value_axis_options.tsx | 2 +- test/functional/apps/visualize/_area_chart.js | 62 +- test/functional/apps/visualize/_line_chart.js | 62 +- .../apps/visualize/_vertical_bar_chart.js | 933 +++++++++--------- .../_vertical_bar_chart_nontimeindex.js | 66 +- .../page_objects/visualize_chart_page.ts | 4 + 7 files changed, 493 insertions(+), 638 deletions(-) diff --git a/src/plugins/vis_type_vislib/public/components/options/metrics_axes/__snapshots__/value_axis_options.test.tsx.snap b/src/plugins/vis_type_vislib/public/components/options/metrics_axes/__snapshots__/value_axis_options.test.tsx.snap index b89d193c7c75..36f5480e8540 100644 --- a/src/plugins/vis_type_vislib/public/components/options/metrics_axes/__snapshots__/value_axis_options.test.tsx.snap +++ b/src/plugins/vis_type_vislib/public/components/options/metrics_axes/__snapshots__/value_axis_options.test.tsx.snap @@ -102,7 +102,7 @@ exports[`ValueAxisOptions component should init with the default set of props 1` value="" /> diff --git a/test/functional/apps/visualize/_area_chart.js b/test/functional/apps/visualize/_area_chart.js index 41e56986f677..4321f0df8925 100644 --- a/test/functional/apps/visualize/_area_chart.js +++ b/test/functional/apps/visualize/_area_chart.js @@ -298,7 +298,7 @@ export default function ({ getService, getPageObjects }) { }); }); - describe.skip('switch between Y axis scale types', () => { + describe('switch between Y axis scale types', () => { before(initAreaChart); const axisId = 'ValueAxis-1'; @@ -308,57 +308,25 @@ export default function ({ getService, getPageObjects }) { await PageObjects.visEditor.selectYAxisScaleType(axisId, 'log'); await PageObjects.visEditor.changeYAxisFilterLabelsCheckbox(axisId, false); await PageObjects.visEditor.clickGo(); - const labels = await PageObjects.visChart.getYAxisLabels(); - const expectedLabels = [ - '2', - '3', - '5', - '7', - '10', - '20', - '30', - '50', - '70', - '100', - '200', - '300', - '500', - '700', - '1,000', - '2,000', - '3,000', - '5,000', - '7,000', - ]; - expect(labels).to.eql(expectedLabels); + const labels = await PageObjects.visChart.getYAxisLabelsAsNumbers(); + const minLabel = 2; + const maxLabel = 5000; + const numberOfLabels = 10; + expect(labels.length).to.be.greaterThan(numberOfLabels); + expect(labels[0]).to.eql(minLabel); + expect(labels[labels.length - 1]).to.be.greaterThan(maxLabel); }); it('should show filtered ticks on selecting log scale', async () => { await PageObjects.visEditor.changeYAxisFilterLabelsCheckbox(axisId, true); await PageObjects.visEditor.clickGo(); - const labels = await PageObjects.visChart.getYAxisLabels(); - const expectedLabels = [ - '2', - '3', - '5', - '7', - '10', - '20', - '30', - '50', - '70', - '100', - '200', - '300', - '500', - '700', - '1,000', - '2,000', - '3,000', - '5,000', - '7,000', - ]; - expect(labels).to.eql(expectedLabels); + const labels = await PageObjects.visChart.getYAxisLabelsAsNumbers(); + const minLabel = 2; + const maxLabel = 5000; + const numberOfLabels = 10; + expect(labels.length).to.be.greaterThan(numberOfLabels); + expect(labels[0]).to.eql(minLabel); + expect(labels[labels.length - 1]).to.be.greaterThan(maxLabel); }); it('should show ticks on selecting square root scale', async () => { diff --git a/test/functional/apps/visualize/_line_chart.js b/test/functional/apps/visualize/_line_chart.js index a492f3858b52..24e4ef4a7fe2 100644 --- a/test/functional/apps/visualize/_line_chart.js +++ b/test/functional/apps/visualize/_line_chart.js @@ -181,7 +181,7 @@ export default function ({ getService, getPageObjects }) { await PageObjects.visChart.waitForVisualization(); }); - describe.skip('switch between Y axis scale types', () => { + describe('switch between Y axis scale types', () => { before(initLineChart); const axisId = 'ValueAxis-1'; @@ -191,57 +191,25 @@ export default function ({ getService, getPageObjects }) { await PageObjects.visEditor.selectYAxisScaleType(axisId, 'log'); await PageObjects.visEditor.changeYAxisFilterLabelsCheckbox(axisId, false); await PageObjects.visEditor.clickGo(); - const labels = await PageObjects.visChart.getYAxisLabels(); - const expectedLabels = [ - '2', - '3', - '5', - '7', - '10', - '20', - '30', - '50', - '70', - '100', - '200', - '300', - '500', - '700', - '1,000', - '2,000', - '3,000', - '5,000', - '7,000', - ]; - expect(labels).to.eql(expectedLabels); + const labels = await PageObjects.visChart.getYAxisLabelsAsNumbers(); + const minLabel = 2; + const maxLabel = 5000; + const numberOfLabels = 10; + expect(labels.length).to.be.greaterThan(numberOfLabels); + expect(labels[0]).to.eql(minLabel); + expect(labels[labels.length - 1]).to.be.greaterThan(maxLabel); }); it('should show filtered ticks on selecting log scale', async () => { await PageObjects.visEditor.changeYAxisFilterLabelsCheckbox(axisId, true); await PageObjects.visEditor.clickGo(); - const labels = await PageObjects.visChart.getYAxisLabels(); - const expectedLabels = [ - '2', - '3', - '5', - '7', - '10', - '20', - '30', - '50', - '70', - '100', - '200', - '300', - '500', - '700', - '1,000', - '2,000', - '3,000', - '5,000', - '7,000', - ]; - expect(labels).to.eql(expectedLabels); + const labels = await PageObjects.visChart.getYAxisLabelsAsNumbers(); + const minLabel = 2; + const maxLabel = 5000; + const numberOfLabels = 10; + expect(labels.length).to.be.greaterThan(numberOfLabels); + expect(labels[0]).to.eql(minLabel); + expect(labels[labels.length - 1]).to.be.greaterThan(maxLabel); }); it('should show ticks on selecting square root scale', async () => { diff --git a/test/functional/apps/visualize/_vertical_bar_chart.js b/test/functional/apps/visualize/_vertical_bar_chart.js index f1d83908b9b6..ff0423eb531d 100644 --- a/test/functional/apps/visualize/_vertical_bar_chart.js +++ b/test/functional/apps/visualize/_vertical_bar_chart.js @@ -28,19 +28,28 @@ export default function ({ getService, getPageObjects }) { const PageObjects = getPageObjects(['visualize', 'visEditor', 'visChart', 'timePicker']); describe('vertical bar chart', function () { + const vizName1 = 'Visualization VerticalBarChart'; + + const initBarChart = async () => { + log.debug('navigateToApp visualize'); + await PageObjects.visualize.navigateToNewVisualization(); + log.debug('clickVerticalBarChart'); + await PageObjects.visualize.clickVerticalBarChart(); + await PageObjects.visualize.clickNewSearch(); + await PageObjects.timePicker.setDefaultAbsoluteRange(); + log.debug('Bucket = X-Axis'); + await PageObjects.visEditor.clickBucket('X-axis'); + log.debug('Aggregation = Date Histogram'); + await PageObjects.visEditor.selectAggregation('Date Histogram'); + log.debug('Field = @timestamp'); + await PageObjects.visEditor.selectField('@timestamp'); + // leaving Interval set to Auto + await PageObjects.visEditor.clickGo(); + }; + describe('bar charts x axis tick labels', () => { it('should show tick labels also after rotation of the chart', async function () { - await PageObjects.visualize.navigateToNewVisualization(); - await PageObjects.visualize.clickVerticalBarChart(); - await PageObjects.visualize.clickNewSearch(); - await PageObjects.timePicker.setDefaultAbsoluteRange(); - await PageObjects.visEditor.clickBucket('X-axis'); - log.debug('Aggregation = Date Histogram'); - await PageObjects.visEditor.selectAggregation('Date Histogram'); - log.debug('Field = @timestamp'); - await PageObjects.visEditor.selectField('@timestamp'); - // leaving Interval set to Auto - await PageObjects.visEditor.clickGo(); + await initBarChart(); const bottomLabels = await PageObjects.visChart.getXAxisLabels(); log.debug(`${bottomLabels.length} tick labels on bottom x axis`); @@ -62,6 +71,7 @@ export default function ({ getService, getPageObjects }) { await PageObjects.visEditor.clickBucket('X-axis'); await PageObjects.visEditor.selectAggregation('Date Range'); await PageObjects.visEditor.selectField('@timestamp'); + await PageObjects.visEditor.clickGo(); const bottomLabels = await PageObjects.visChart.getXAxisLabels(); expect(bottomLabels.length).to.be(1); @@ -95,519 +105,456 @@ export default function ({ getService, getPageObjects }) { }); }); - // FLAKY: https://github.com/elastic/kibana/issues/22322 - describe.skip('vertical bar chart flaky part', function () { - const vizName1 = 'Visualization VerticalBarChart'; + it('should save and load', async function () { + await initBarChart(); + await PageObjects.visualize.saveVisualizationExpectSuccessAndBreadcrumb(vizName1); - const initBarChart = async () => { - log.debug('navigateToApp visualize'); - await PageObjects.visualize.navigateToNewVisualization(); - log.debug('clickVerticalBarChart'); - await PageObjects.visualize.clickVerticalBarChart(); - await PageObjects.visualize.clickNewSearch(); - await PageObjects.timePicker.setDefaultAbsoluteRange(); - log.debug('Bucket = X-Axis'); - await PageObjects.visEditor.clickBucket('X-axis'); - log.debug('Aggregation = Date Histogram'); - await PageObjects.visEditor.selectAggregation('Date Histogram'); - log.debug('Field = @timestamp'); - await PageObjects.visEditor.selectField('@timestamp'); - // leaving Interval set to Auto - await PageObjects.visEditor.clickGo(); - }; + await PageObjects.visualize.loadSavedVisualization(vizName1); + await PageObjects.visChart.waitForVisualization(); + }); - before(initBarChart); + it('should have inspector enabled', async function () { + await inspector.expectIsEnabled(); + }); - it('should save and load', async function () { - await PageObjects.visualize.saveVisualizationExpectSuccessAndBreadcrumb(vizName1); + it('should show correct chart', async function () { + const expectedChartValues = [ + 37, + 202, + 740, + 1437, + 1371, + 751, + 188, + 31, + 42, + 202, + 683, + 1361, + 1415, + 707, + 177, + 27, + 32, + 175, + 707, + 1408, + 1355, + 726, + 201, + 29, + ]; + + // Most recent failure on Jenkins usually indicates the bar chart is still being drawn? + // return arguments[0].getAttribute(arguments[1]);","args":[{"ELEMENT":"592"},"fill"]}] arguments[0].getAttribute is not a function + // try sleeping a bit before getting that data + await retry.try(async () => { + const data = await PageObjects.visChart.getBarChartData(); + log.debug('data=' + data); + log.debug('data.length=' + data.length); + expect(data).to.eql(expectedChartValues); + }); + }); - await PageObjects.visualize.loadSavedVisualization(vizName1); - await PageObjects.visChart.waitForVisualization(); + it('should show correct data', async function () { + // this is only the first page of the tabular data. + const expectedChartData = [ + ['2015-09-20 00:00', '37'], + ['2015-09-20 03:00', '202'], + ['2015-09-20 06:00', '740'], + ['2015-09-20 09:00', '1,437'], + ['2015-09-20 12:00', '1,371'], + ['2015-09-20 15:00', '751'], + ['2015-09-20 18:00', '188'], + ['2015-09-20 21:00', '31'], + ['2015-09-21 00:00', '42'], + ['2015-09-21 03:00', '202'], + ['2015-09-21 06:00', '683'], + ['2015-09-21 09:00', '1,361'], + ['2015-09-21 12:00', '1,415'], + ['2015-09-21 15:00', '707'], + ['2015-09-21 18:00', '177'], + ['2015-09-21 21:00', '27'], + ['2015-09-22 00:00', '32'], + ['2015-09-22 03:00', '175'], + ['2015-09-22 06:00', '707'], + ['2015-09-22 09:00', '1,408'], + ]; + + await inspector.open(); + await inspector.expectTableData(expectedChartData); + await inspector.close(); + }); + + it('should have `drop partial buckets` option', async () => { + const fromTime = 'Sep 20, 2015 @ 06:31:44.000'; + const toTime = 'Sep 22, 2015 @ 18:31:44.000'; + + await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); + + let expectedChartValues = [ + 82, + 218, + 341, + 440, + 480, + 517, + 522, + 446, + 403, + 321, + 258, + 172, + 95, + 55, + 38, + 24, + 3, + 4, + 11, + 14, + 17, + 38, + 49, + 115, + 152, + 216, + 315, + 402, + 446, + 513, + 520, + 474, + 421, + 307, + 230, + 170, + 99, + 48, + 30, + 15, + 10, + 2, + 8, + 7, + 17, + 34, + 37, + 104, + 153, + 241, + 313, + 404, + 492, + 512, + 503, + 473, + 379, + 293, + 277, + 156, + 56, + ]; + + // Most recent failure on Jenkins usually indicates the bar chart is still being drawn? + // return arguments[0].getAttribute(arguments[1]);","args":[{"ELEMENT":"592"},"fill"]}] arguments[0].getAttribute is not a function + // try sleeping a bit before getting that data + await retry.try(async () => { + const data = await PageObjects.visChart.getBarChartData(); + log.debug('data=' + data); + log.debug('data.length=' + data.length); + expect(data).to.eql(expectedChartValues); }); - it('should have inspector enabled', async function () { - await inspector.expectIsEnabled(); + await PageObjects.visEditor.toggleOpenEditor(2); + await PageObjects.visEditor.clickDropPartialBuckets(); + await PageObjects.visEditor.clickGo(); + + expectedChartValues = [ + 218, + 341, + 440, + 480, + 517, + 522, + 446, + 403, + 321, + 258, + 172, + 95, + 55, + 38, + 24, + 3, + 4, + 11, + 14, + 17, + 38, + 49, + 115, + 152, + 216, + 315, + 402, + 446, + 513, + 520, + 474, + 421, + 307, + 230, + 170, + 99, + 48, + 30, + 15, + 10, + 2, + 8, + 7, + 17, + 34, + 37, + 104, + 153, + 241, + 313, + 404, + 492, + 512, + 503, + 473, + 379, + 293, + 277, + 156, + ]; + + // Most recent failure on Jenkins usually indicates the bar chart is still being drawn? + // return arguments[0].getAttribute(arguments[1]);","args":[{"ELEMENT":"592"},"fill"]}] arguments[0].getAttribute is not a function + // try sleeping a bit before getting that data + await retry.try(async () => { + const data = await PageObjects.visChart.getBarChartData(); + log.debug('data=' + data); + log.debug('data.length=' + data.length); + expect(data).to.eql(expectedChartValues); }); + }); - it('should show correct chart', async function () { - const expectedChartValues = [ - 37, - 202, - 740, - 1437, - 1371, - 751, - 188, - 31, - 42, - 202, - 683, - 1361, - 1415, - 707, - 177, - 27, - 32, - 175, - 707, - 1408, - 1355, - 726, - 201, - 29, - ]; + describe('switch between Y axis scale types', () => { + before(initBarChart); + const axisId = 'ValueAxis-1'; - // Most recent failure on Jenkins usually indicates the bar chart is still being drawn? - // return arguments[0].getAttribute(arguments[1]);","args":[{"ELEMENT":"592"},"fill"]}] arguments[0].getAttribute is not a function - // try sleeping a bit before getting that data - await retry.try(async () => { - const data = await PageObjects.visChart.getBarChartData(); - log.debug('data=' + data); - log.debug('data.length=' + data.length); - expect(data).to.eql(expectedChartValues); - }); + it('should show ticks on selecting log scale', async () => { + await PageObjects.visEditor.clickMetricsAndAxes(); + await PageObjects.visEditor.clickYAxisOptions(axisId); + await PageObjects.visEditor.selectYAxisScaleType(axisId, 'log'); + await PageObjects.visEditor.changeYAxisFilterLabelsCheckbox(axisId, false); + await PageObjects.visEditor.clickGo(); + const labels = await PageObjects.visChart.getYAxisLabelsAsNumbers(); + const minLabel = 2; + const maxLabel = 5000; + const numberOfLabels = 10; + expect(labels.length).to.be.greaterThan(numberOfLabels); + expect(labels[0]).to.eql(minLabel); + expect(labels[labels.length - 1]).to.be.greaterThan(maxLabel); }); - it('should show correct data', async function () { - // this is only the first page of the tabular data. - const expectedChartData = [ - ['2015-09-20 00:00', '37'], - ['2015-09-20 03:00', '202'], - ['2015-09-20 06:00', '740'], - ['2015-09-20 09:00', '1,437'], - ['2015-09-20 12:00', '1,371'], - ['2015-09-20 15:00', '751'], - ['2015-09-20 18:00', '188'], - ['2015-09-20 21:00', '31'], - ['2015-09-21 00:00', '42'], - ['2015-09-21 03:00', '202'], - ['2015-09-21 06:00', '683'], - ['2015-09-21 09:00', '1,361'], - ['2015-09-21 12:00', '1,415'], - ['2015-09-21 15:00', '707'], - ['2015-09-21 18:00', '177'], - ['2015-09-21 21:00', '27'], - ['2015-09-22 00:00', '32'], - ['2015-09-22 03:00', '175'], - ['2015-09-22 06:00', '707'], - ['2015-09-22 09:00', '1,408'], - ]; - - await inspector.open(); - await inspector.expectTableData(expectedChartData); - await inspector.close(); + it('should show filtered ticks on selecting log scale', async () => { + await PageObjects.visEditor.changeYAxisFilterLabelsCheckbox(axisId, true); + await PageObjects.visEditor.clickGo(); + const labels = await PageObjects.visChart.getYAxisLabelsAsNumbers(); + const minLabel = 2; + const maxLabel = 5000; + const numberOfLabels = 10; + expect(labels.length).to.be.greaterThan(numberOfLabels); + expect(labels[0]).to.eql(minLabel); + expect(labels[labels.length - 1]).to.be.greaterThan(maxLabel); }); - it('should have `drop partial buckets` option', async () => { - const fromTime = 'Sep 20, 2015 @ 06:31:44.000'; - const toTime = 'Sep 22, 2015 @ 18:31:44.000'; - - await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); - - let expectedChartValues = [ - 82, - 218, - 341, - 440, - 480, - 517, - 522, - 446, - 403, - 321, - 258, - 172, - 95, - 55, - 38, - 24, - 3, - 4, - 11, - 14, - 17, - 38, - 49, - 115, - 152, - 216, - 315, - 402, - 446, - 513, - 520, - 474, - 421, - 307, - 230, - 170, - 99, - 48, - 30, - 15, - 10, - 2, - 8, - 7, - 17, - 34, - 37, - 104, - 153, - 241, - 313, - 404, - 492, - 512, - 503, - 473, - 379, - 293, - 277, - 156, - 56, + it('should show ticks on selecting square root scale', async () => { + await PageObjects.visEditor.selectYAxisScaleType(axisId, 'square root'); + await PageObjects.visEditor.changeYAxisFilterLabelsCheckbox(axisId, false); + await PageObjects.visEditor.clickGo(); + const labels = await PageObjects.visChart.getYAxisLabels(); + const expectedLabels = [ + '0', + '200', + '400', + '600', + '800', + '1,000', + '1,200', + '1,400', + '1,600', ]; + expect(labels).to.eql(expectedLabels); + }); - // Most recent failure on Jenkins usually indicates the bar chart is still being drawn? - // return arguments[0].getAttribute(arguments[1]);","args":[{"ELEMENT":"592"},"fill"]}] arguments[0].getAttribute is not a function - // try sleeping a bit before getting that data - await retry.try(async () => { - const data = await PageObjects.visChart.getBarChartData(); - log.debug('data=' + data); - log.debug('data.length=' + data.length); - expect(data).to.eql(expectedChartValues); - }); - - await PageObjects.visEditor.toggleOpenEditor(2); - await PageObjects.visEditor.clickDropPartialBuckets(); + it('should show filtered ticks on selecting square root scale', async () => { + await PageObjects.visEditor.changeYAxisFilterLabelsCheckbox(axisId, true); await PageObjects.visEditor.clickGo(); + const labels = await PageObjects.visChart.getYAxisLabels(); + const expectedLabels = ['200', '400', '600', '800', '1,000', '1,200', '1,400']; + expect(labels).to.eql(expectedLabels); + }); - expectedChartValues = [ - 218, - 341, - 440, - 480, - 517, - 522, - 446, - 403, - 321, - 258, - 172, - 95, - 55, - 38, - 24, - 3, - 4, - 11, - 14, - 17, - 38, - 49, - 115, - 152, - 216, - 315, - 402, - 446, - 513, - 520, - 474, - 421, - 307, - 230, - 170, - 99, - 48, - 30, - 15, - 10, - 2, - 8, - 7, - 17, - 34, - 37, - 104, - 153, - 241, - 313, - 404, - 492, - 512, - 503, - 473, - 379, - 293, - 277, - 156, + it('should show ticks on selecting linear scale', async () => { + await PageObjects.visEditor.selectYAxisScaleType(axisId, 'linear'); + await PageObjects.visEditor.changeYAxisFilterLabelsCheckbox(axisId, false); + await PageObjects.visEditor.clickGo(); + const labels = await PageObjects.visChart.getYAxisLabels(); + log.debug(labels); + const expectedLabels = [ + '0', + '200', + '400', + '600', + '800', + '1,000', + '1,200', + '1,400', + '1,600', ]; + expect(labels).to.eql(expectedLabels); + }); - // Most recent failure on Jenkins usually indicates the bar chart is still being drawn? - // return arguments[0].getAttribute(arguments[1]);","args":[{"ELEMENT":"592"},"fill"]}] arguments[0].getAttribute is not a function - // try sleeping a bit before getting that data - await retry.try(async () => { - const data = await PageObjects.visChart.getBarChartData(); - log.debug('data=' + data); - log.debug('data.length=' + data.length); - expect(data).to.eql(expectedChartValues); - }); + it('should show filtered ticks on selecting linear scale', async () => { + await PageObjects.visEditor.changeYAxisFilterLabelsCheckbox(axisId, true); + await PageObjects.visEditor.clickGo(); + const labels = await PageObjects.visChart.getYAxisLabels(); + const expectedLabels = ['200', '400', '600', '800', '1,000', '1,200', '1,400']; + expect(labels).to.eql(expectedLabels); }); + }); - describe('switch between Y axis scale types', () => { - before(initBarChart); + describe('vertical bar in percent mode', async () => { + it('should show ticks with percentage values', async function () { const axisId = 'ValueAxis-1'; + await PageObjects.visEditor.clickMetricsAndAxes(); + await PageObjects.visEditor.clickYAxisOptions(axisId); + await PageObjects.visEditor.selectYAxisMode('percentage'); + await PageObjects.visEditor.changeYAxisShowCheckbox(axisId, true); + await PageObjects.visEditor.changeYAxisFilterLabelsCheckbox(axisId, false); + await PageObjects.visEditor.clickGo(); + const labels = await PageObjects.visChart.getYAxisLabels(); + expect(labels[0]).to.eql('0%'); + expect(labels[labels.length - 1]).to.eql('100%'); + }); + }); - it('should show ticks on selecting log scale', async () => { - await PageObjects.visEditor.clickMetricsAndAxes(); - await PageObjects.visEditor.clickYAxisOptions(axisId); - await PageObjects.visEditor.selectYAxisScaleType(axisId, 'log'); - await PageObjects.visEditor.changeYAxisFilterLabelsCheckbox(axisId, false); - await PageObjects.visEditor.clickGo(); - const labels = await PageObjects.visChart.getYAxisLabels(); - const expectedLabels = [ - '2', - '3', - '4', - '5', - '7', - '9', - '20', - '30', - '40', - '50', - '70', - '90', - '200', - '300', - '400', - '500', - '700', - '900', - '2,000', - '3,000', - '4,000', - '5,000', - '7,000', - ]; - expect(labels).to.eql(expectedLabels); - }); - - it('should show filtered ticks on selecting log scale', async () => { - await PageObjects.visEditor.changeYAxisFilterLabelsCheckbox(axisId, true); - await PageObjects.visEditor.clickGo(); - const labels = await PageObjects.visChart.getYAxisLabels(); - const expectedLabels = [ - '2', - '3', - '4', - '5', - '7', - '9', - '20', - '30', - '40', - '50', - '70', - '90', - '200', - '300', - '400', - '500', - '700', - '900', - '2,000', - '3,000', - '4,000', - '5,000', - '7,000', - ]; - expect(labels).to.eql(expectedLabels); - }); - - it('should show ticks on selecting square root scale', async () => { - await PageObjects.visEditor.selectYAxisScaleType(axisId, 'square root'); - await PageObjects.visEditor.changeYAxisFilterLabelsCheckbox(axisId, false); - await PageObjects.visEditor.clickGo(); - const labels = await PageObjects.visChart.getYAxisLabels(); - const expectedLabels = [ - '0', - '200', - '400', - '600', - '800', - '1,000', - '1,200', - '1,400', - '1,600', - ]; - expect(labels).to.eql(expectedLabels); - }); - - it('should show filtered ticks on selecting square root scale', async () => { - await PageObjects.visEditor.changeYAxisFilterLabelsCheckbox(axisId, true); - await PageObjects.visEditor.clickGo(); - const labels = await PageObjects.visChart.getYAxisLabels(); - const expectedLabels = ['200', '400', '600', '800', '1,000', '1,200', '1,400']; - expect(labels).to.eql(expectedLabels); - }); - - it('should show ticks on selecting linear scale', async () => { - await PageObjects.visEditor.selectYAxisScaleType(axisId, 'linear'); - await PageObjects.visEditor.changeYAxisFilterLabelsCheckbox(axisId, false); - await PageObjects.visEditor.clickGo(); - const labels = await PageObjects.visChart.getYAxisLabels(); - log.debug(labels); - const expectedLabels = [ - '0', - '200', - '400', - '600', - '800', - '1,000', - '1,200', - '1,400', - '1,600', - ]; - expect(labels).to.eql(expectedLabels); - }); - - it('should show filtered ticks on selecting linear scale', async () => { - await PageObjects.visEditor.changeYAxisFilterLabelsCheckbox(axisId, true); - await PageObjects.visEditor.clickGo(); - const labels = await PageObjects.visChart.getYAxisLabels(); - const expectedLabels = ['200', '400', '600', '800', '1,000', '1,200', '1,400']; - expect(labels).to.eql(expectedLabels); - }); + describe('vertical bar with Split series', function () { + before(initBarChart); + + it('should show correct series', async function () { + await PageObjects.visEditor.toggleOpenEditor(2, 'false'); + await PageObjects.visEditor.clickBucket('Split series'); + await PageObjects.visEditor.selectAggregation('Terms'); + await PageObjects.visEditor.selectField('response.raw'); + await PageObjects.visChart.waitForVisualizationRenderingStabilized(); + await PageObjects.visEditor.clickGo(); + + const expectedEntries = ['200', '404', '503']; + const legendEntries = await PageObjects.visChart.getLegendEntries(); + expect(legendEntries).to.eql(expectedEntries); }); - describe('vertical bar in percent mode', async () => { - it('should show ticks with percentage values', async function () { - const axisId = 'ValueAxis-1'; - await PageObjects.visEditor.clickMetricsAndAxes(); - await PageObjects.visEditor.clickYAxisOptions(axisId); - await PageObjects.visEditor.selectYAxisMode('percentage'); - await PageObjects.visEditor.changeYAxisShowCheckbox(axisId, true); - await PageObjects.visEditor.changeYAxisFilterLabelsCheckbox(axisId, false); - await PageObjects.visEditor.clickGo(); - const labels = await PageObjects.visChart.getYAxisLabels(); - expect(labels[0]).to.eql('0%'); - expect(labels[labels.length - 1]).to.eql('100%'); - }); + it('should allow custom sorting of series', async () => { + await PageObjects.visEditor.toggleOpenEditor(1, 'false'); + await PageObjects.visEditor.selectCustomSortMetric(3, 'Min', 'bytes'); + await PageObjects.visEditor.clickGo(); + + const expectedEntries = ['404', '200', '503']; + const legendEntries = await PageObjects.visChart.getLegendEntries(); + expect(legendEntries).to.eql(expectedEntries); }); - describe('vertical bar with Split series', function () { - before(initBarChart); - - it('should show correct series', async function () { - await PageObjects.visEditor.toggleOpenEditor(2, 'false'); - await PageObjects.visEditor.clickBucket('Split series'); - await PageObjects.visEditor.selectAggregation('Terms'); - await PageObjects.visEditor.selectField('response.raw'); - await PageObjects.visChart.waitForVisualizationRenderingStabilized(); - await PageObjects.visEditor.clickGo(); - - const expectedEntries = ['200', '404', '503']; - const legendEntries = await PageObjects.visChart.getLegendEntries(); - expect(legendEntries).to.eql(expectedEntries); - }); - - it('should allow custom sorting of series', async () => { - await PageObjects.visEditor.toggleOpenEditor(1, 'false'); - await PageObjects.visEditor.selectCustomSortMetric(3, 'Min', 'bytes'); - await PageObjects.visEditor.clickGo(); - - const expectedEntries = ['404', '200', '503']; - const legendEntries = await PageObjects.visChart.getLegendEntries(); - expect(legendEntries).to.eql(expectedEntries); - }); - - it('should correctly filter by legend', async () => { - await PageObjects.visChart.filterLegend('200'); - await PageObjects.visChart.waitForVisualization(); - const legendEntries = await PageObjects.visChart.getLegendEntries(); - const expectedEntries = ['200']; - expect(legendEntries).to.eql(expectedEntries); - await filterBar.removeFilter('response.raw'); - await PageObjects.visChart.waitForVisualization(); - }); + it('should correctly filter by legend', async () => { + await PageObjects.visChart.filterLegend('200'); + await PageObjects.visChart.waitForVisualization(); + const legendEntries = await PageObjects.visChart.getLegendEntries(); + const expectedEntries = ['200']; + expect(legendEntries).to.eql(expectedEntries); + await filterBar.removeFilter('response.raw'); + await PageObjects.visChart.waitForVisualization(); }); + }); - describe('vertical bar with multiple splits', function () { - before(initBarChart); - - it('should show correct series', async function () { - await PageObjects.visEditor.toggleOpenEditor(2, 'false'); - await PageObjects.visEditor.clickBucket('Split series'); - await PageObjects.visEditor.selectAggregation('Terms'); - await PageObjects.visEditor.selectField('response.raw'); - await PageObjects.visChart.waitForVisualizationRenderingStabilized(); - - await PageObjects.visEditor.toggleOpenEditor(3, 'false'); - await PageObjects.visEditor.clickBucket('Split series'); - await PageObjects.visEditor.selectAggregation('Terms'); - await PageObjects.visEditor.selectField('machine.os'); - await PageObjects.visChart.waitForVisualizationRenderingStabilized(); - await PageObjects.visEditor.clickGo(); - - const expectedEntries = [ - '200 - win 8', - '200 - win xp', - '200 - ios', - '200 - osx', - '200 - win 7', - '404 - ios', - '503 - ios', - '503 - osx', - '503 - win 7', - '503 - win 8', - '503 - win xp', - '404 - osx', - '404 - win 7', - '404 - win 8', - '404 - win xp', - ]; - const legendEntries = await PageObjects.visChart.getLegendEntries(); - expect(legendEntries).to.eql(expectedEntries); - }); - - it('should show correct series when disabling first agg', async function () { - // this will avoid issues with the play tooltip covering the disable agg button - await testSubjects.scrollIntoView('metricsAggGroup'); - await PageObjects.visEditor.toggleDisabledAgg(3); - await PageObjects.visEditor.clickGo(); - - const expectedEntries = ['win 8', 'win xp', 'ios', 'osx', 'win 7']; - const legendEntries = await PageObjects.visChart.getLegendEntries(); - expect(legendEntries).to.eql(expectedEntries); - }); + describe('vertical bar with multiple splits', function () { + before(initBarChart); + + it('should show correct series', async function () { + await PageObjects.visEditor.toggleOpenEditor(2, 'false'); + await PageObjects.visEditor.clickBucket('Split series'); + await PageObjects.visEditor.selectAggregation('Terms'); + await PageObjects.visEditor.selectField('response.raw'); + await PageObjects.visChart.waitForVisualizationRenderingStabilized(); + + await PageObjects.visEditor.toggleOpenEditor(3, 'false'); + await PageObjects.visEditor.clickBucket('Split series'); + await PageObjects.visEditor.selectAggregation('Terms'); + await PageObjects.visEditor.selectField('machine.os'); + await PageObjects.visChart.waitForVisualizationRenderingStabilized(); + await PageObjects.visEditor.clickGo(); + + const expectedEntries = [ + '200 - win 8', + '200 - win xp', + '200 - ios', + '200 - osx', + '200 - win 7', + '404 - ios', + '503 - ios', + '503 - osx', + '503 - win 7', + '503 - win 8', + '503 - win xp', + '404 - osx', + '404 - win 7', + '404 - win 8', + '404 - win xp', + ]; + const legendEntries = await PageObjects.visChart.getLegendEntries(); + expect(legendEntries).to.eql(expectedEntries); + }); + + it('should show correct series when disabling first agg', async function () { + // this will avoid issues with the play tooltip covering the disable agg button + await testSubjects.scrollIntoView('metricsAggGroup'); + await PageObjects.visEditor.toggleDisabledAgg(3); + await PageObjects.visEditor.clickGo(); + + const expectedEntries = ['win 8', 'win xp', 'ios', 'osx', 'win 7']; + const legendEntries = await PageObjects.visChart.getLegendEntries(); + expect(legendEntries).to.eql(expectedEntries); }); + }); + + describe('vertical bar with derivative', function () { + before(initBarChart); + + it('should show correct series', async function () { + await PageObjects.visEditor.toggleOpenEditor(2, 'false'); + await PageObjects.visEditor.toggleOpenEditor(1); + await PageObjects.visEditor.selectAggregation('Derivative', 'metrics'); + await PageObjects.visChart.waitForVisualizationRenderingStabilized(); + await PageObjects.visEditor.clickGo(); + + const expectedEntries = ['Derivative of Count']; + const legendEntries = await PageObjects.visChart.getLegendEntries(); + expect(legendEntries).to.eql(expectedEntries); + }); + + it('should show an error if last bucket aggregation is terms', async () => { + await PageObjects.visEditor.toggleOpenEditor(2, 'false'); + await PageObjects.visEditor.clickBucket('Split series'); + await PageObjects.visEditor.selectAggregation('Terms'); + await PageObjects.visEditor.selectField('response.raw'); - describe('vertical bar with derivative', function () { - before(initBarChart); - - it('should show correct series', async function () { - await PageObjects.visEditor.toggleOpenEditor(2, 'false'); - await PageObjects.visEditor.toggleOpenEditor(1); - await PageObjects.visEditor.selectAggregation('Derivative', 'metrics'); - await PageObjects.visChart.waitForVisualizationRenderingStabilized(); - await PageObjects.visEditor.clickGo(); - - const expectedEntries = ['Derivative of Count']; - const legendEntries = await PageObjects.visChart.getLegendEntries(); - expect(legendEntries).to.eql(expectedEntries); - }); - - it('should show an error if last bucket aggregation is terms', async () => { - await PageObjects.visEditor.toggleOpenEditor(2, 'false'); - await PageObjects.visEditor.clickBucket('Split series'); - await PageObjects.visEditor.selectAggregation('Terms'); - await PageObjects.visEditor.selectField('response.raw'); - - const errorMessage = await PageObjects.visEditor.getBucketErrorMessage(); - expect(errorMessage).to.contain('Last bucket aggregation must be "Date Histogram"'); - }); + const errorMessage = await PageObjects.visEditor.getBucketErrorMessage(); + expect(errorMessage).to.contain('Last bucket aggregation must be "Date Histogram"'); }); }); }); diff --git a/test/functional/apps/visualize/_vertical_bar_chart_nontimeindex.js b/test/functional/apps/visualize/_vertical_bar_chart_nontimeindex.js index 42dfd791321a..f95781c9bbb3 100644 --- a/test/functional/apps/visualize/_vertical_bar_chart_nontimeindex.js +++ b/test/functional/apps/visualize/_vertical_bar_chart_nontimeindex.js @@ -25,8 +25,7 @@ export default function ({ getService, getPageObjects }) { const inspector = getService('inspector'); const PageObjects = getPageObjects(['common', 'visualize', 'header', 'visEditor', 'visChart']); - // FLAKY: https://github.com/elastic/kibana/issues/22322 - describe.skip('vertical bar chart with index without time filter', function () { + describe('vertical bar chart with index without time filter', function () { const vizName1 = 'Visualization VerticalBarChart without time filter'; const initBarChart = async () => { @@ -46,6 +45,7 @@ export default function ({ getService, getPageObjects }) { await PageObjects.visEditor.selectField('@timestamp'); await PageObjects.visEditor.setInterval('3h', { type: 'custom' }); await PageObjects.visChart.waitForVisualizationRenderingStabilized(); + await PageObjects.visEditor.clickGo(); }; before(initBarChart); @@ -129,7 +129,7 @@ export default function ({ getService, getPageObjects }) { await inspector.expectTableData(expectedChartData); }); - describe.skip('switch between Y axis scale types', () => { + describe('switch between Y axis scale types', () => { before(initBarChart); const axisId = 'ValueAxis-1'; @@ -139,57 +139,25 @@ export default function ({ getService, getPageObjects }) { await PageObjects.visEditor.selectYAxisScaleType(axisId, 'log'); await PageObjects.visEditor.changeYAxisFilterLabelsCheckbox(axisId, false); await PageObjects.visEditor.clickGo(); - const labels = await PageObjects.visChart.getYAxisLabels(); - const expectedLabels = [ - '2', - '3', - '5', - '7', - '10', - '20', - '30', - '50', - '70', - '100', - '200', - '300', - '500', - '700', - '1,000', - '2,000', - '3,000', - '5,000', - '7,000', - ]; - expect(labels).to.eql(expectedLabels); + const labels = await PageObjects.visChart.getYAxisLabelsAsNumbers(); + const minLabel = 2; + const maxLabel = 5000; + const numberOfLabels = 10; + expect(labels.length).to.be.greaterThan(numberOfLabels); + expect(labels[0]).to.eql(minLabel); + expect(labels[labels.length - 1]).to.be.greaterThan(maxLabel); }); it('should show filtered ticks on selecting log scale', async () => { await PageObjects.visEditor.changeYAxisFilterLabelsCheckbox(axisId, true); await PageObjects.visEditor.clickGo(); - const labels = await PageObjects.visChart.getYAxisLabels(); - const expectedLabels = [ - '2', - '3', - '5', - '7', - '10', - '20', - '30', - '50', - '70', - '100', - '200', - '300', - '500', - '700', - '1,000', - '2,000', - '3,000', - '5,000', - '7,000', - ]; - expect(labels).to.eql(expectedLabels); + const labels = await PageObjects.visChart.getYAxisLabelsAsNumbers(); + const minLabel = 2; + const maxLabel = 5000; + const numberOfLabels = 10; + expect(labels.length).to.be.greaterThan(numberOfLabels); + expect(labels[0]).to.eql(minLabel); + expect(labels[labels.length - 1]).to.be.greaterThan(maxLabel); }); it('should show ticks on selecting square root scale', async () => { diff --git a/test/functional/page_objects/visualize_chart_page.ts b/test/functional/page_objects/visualize_chart_page.ts index 590631ad48b0..ade78cbb810d 100644 --- a/test/functional/page_objects/visualize_chart_page.ts +++ b/test/functional/page_objects/visualize_chart_page.ts @@ -51,6 +51,10 @@ export function VisualizeChartPageProvider({ getService, getPageObjects }: FtrPr .map((tick) => $(tick).text().trim()); } + public async getYAxisLabelsAsNumbers() { + return (await this.getYAxisLabels()).map((label) => Number(label.replace(',', ''))); + } + /** * Gets the chart data and scales it based on chart height and label. * @param dataLabel data-label value From edaea1e341ea7304ff283fa0f15a473e256745f2 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Wed, 22 Jul 2020 11:26:04 +0200 Subject: [PATCH 026/142] Stabilize filter bar test (#72032) --- test/functional/apps/dashboard/dashboard_filter_bar.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/functional/apps/dashboard/dashboard_filter_bar.js b/test/functional/apps/dashboard/dashboard_filter_bar.js index f3241568bbb3..dd0318ea5c0d 100644 --- a/test/functional/apps/dashboard/dashboard_filter_bar.js +++ b/test/functional/apps/dashboard/dashboard_filter_bar.js @@ -30,8 +30,7 @@ export default function ({ getService, getPageObjects }) { const browser = getService('browser'); const PageObjects = getPageObjects(['common', 'dashboard', 'header', 'visualize', 'timePicker']); - // FLAKY: https://github.com/elastic/kibana/issues/71987 - describe.skip('dashboard filter bar', () => { + describe('dashboard filter bar', () => { before(async () => { await esArchiver.load('dashboard/current/kibana'); await kibanaServer.uiSettings.replace({ @@ -69,6 +68,7 @@ export default function ({ getService, getPageObjects }) { it('uses default index pattern on an empty dashboard', async () => { await testSubjects.click('addFilter'); await dashboardExpect.fieldSuggestions(['bytes']); + await filterBar.ensureFieldEditorModalIsClosed(); }); it('shows index pattern of vis when one is added', async () => { @@ -77,6 +77,7 @@ export default function ({ getService, getPageObjects }) { await filterBar.ensureFieldEditorModalIsClosed(); await testSubjects.click('addFilter'); await dashboardExpect.fieldSuggestions(['animal']); + await filterBar.ensureFieldEditorModalIsClosed(); }); it('works when a vis with no index pattern is added', async () => { From b9aede40d6a94720a1432612d7866ddb4864e22b Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Wed, 22 Jul 2020 11:26:37 +0200 Subject: [PATCH 027/142] stabilize failing test (#72086) --- test/functional/page_objects/dashboard_page.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/functional/page_objects/dashboard_page.ts b/test/functional/page_objects/dashboard_page.ts index 7c325ba6d4ae..b662fd62e4b0 100644 --- a/test/functional/page_objects/dashboard_page.ts +++ b/test/functional/page_objects/dashboard_page.ts @@ -294,6 +294,7 @@ export function DashboardPageProvider({ getService, getPageObjects }: FtrProvide await this.enterDashboardTitleAndClickSave(dashboardName, saveOptions); if (saveOptions.needsConfirm) { + await this.ensureDuplicateTitleCallout(); await this.clickSave(); } From 78ea171a8011cc319563276033beade44a74c30d Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Wed, 22 Jul 2020 11:28:23 +0200 Subject: [PATCH 028/142] Stabilize closing toast (#72097) * stabilize closing toast * unskip test Co-authored-by: Elastic Machine --- test/functional/apps/dashboard/dashboard_snapshots.js | 3 +-- test/functional/page_objects/common_page.ts | 6 +++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/test/functional/apps/dashboard/dashboard_snapshots.js b/test/functional/apps/dashboard/dashboard_snapshots.js index 20bc30c889d6..787e839aa08a 100644 --- a/test/functional/apps/dashboard/dashboard_snapshots.js +++ b/test/functional/apps/dashboard/dashboard_snapshots.js @@ -28,8 +28,7 @@ export default function ({ getService, getPageObjects, updateBaselines }) { const dashboardPanelActions = getService('dashboardPanelActions'); const dashboardAddPanel = getService('dashboardAddPanel'); - // FLAKY: https://github.com/elastic/kibana/issues/52854 - describe.skip('dashboard snapshots', function describeIndexTests() { + describe('dashboard snapshots', function describeIndexTests() { before(async function () { await esArchiver.load('dashboard/current/kibana'); await kibanaServer.uiSettings.replace({ diff --git a/test/functional/page_objects/common_page.ts b/test/functional/page_objects/common_page.ts index 8c5a99204bab..d6a96eb651d0 100644 --- a/test/functional/page_objects/common_page.ts +++ b/test/functional/page_objects/common_page.ts @@ -407,7 +407,11 @@ export function CommonPageProvider({ getService, getPageObjects }: FtrProviderCo async closeToastIfExists() { const toastShown = await find.existsByCssSelector('.euiToast'); if (toastShown) { - await find.clickByCssSelector('.euiToast__closeButton'); + try { + await find.clickByCssSelector('.euiToast__closeButton'); + } catch (err) { + // ignore errors, toast clear themselves after timeout + } } } From 3709de64d614533571da8e190e751c51f3484073 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Wed, 22 Jul 2020 12:14:59 +0200 Subject: [PATCH 029/142] [Lens] Legend config (#70619) --- .../workspace_panel_wrapper.tsx | 2 +- .../pie_visualization/pie_visualization.tsx | 6 +- .../pie_visualization/register_expression.tsx | 6 + .../render_function.test.tsx | 7 + .../pie_visualization/render_function.tsx | 3 + .../pie_visualization/settings_widget.scss | 3 - .../pie_visualization/settings_widget.tsx | 230 -------------- .../public/pie_visualization/to_expression.ts | 1 + .../public/pie_visualization/toolbar.scss | 3 + .../lens/public/pie_visualization/toolbar.tsx | 281 ++++++++++++++++++ .../lens/public/pie_visualization/types.ts | 1 + .../__snapshots__/to_expression.test.ts.snap | 1 + .../public/xy_visualization/to_expression.ts | 3 + .../lens/public/xy_visualization/types.ts | 16 + .../xy_visualization/xy_config_panel.tsx | 94 ++++++ .../xy_visualization/xy_expression.test.tsx | 67 +++++ .../public/xy_visualization/xy_expression.tsx | 6 +- .../translations/translations/ja-JP.json | 6 +- .../translations/translations/zh-CN.json | 6 +- 19 files changed, 498 insertions(+), 244 deletions(-) delete mode 100644 x-pack/plugins/lens/public/pie_visualization/settings_widget.scss delete mode 100644 x-pack/plugins/lens/public/pie_visualization/settings_widget.tsx create mode 100644 x-pack/plugins/lens/public/pie_visualization/toolbar.scss create mode 100644 x-pack/plugins/lens/public/pie_visualization/toolbar.tsx diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.tsx index f6e15002ca66..411488abce77 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel_wrapper.tsx @@ -63,7 +63,7 @@ export function WorkspacePanelWrapper({ clearStagedPreview: false, }); }, - [dispatch] + [dispatch, activeVisualization] ); return ( <> diff --git a/x-pack/plugins/lens/public/pie_visualization/pie_visualization.tsx b/x-pack/plugins/lens/public/pie_visualization/pie_visualization.tsx index 4f0c081d8be0..369ab28293fb 100644 --- a/x-pack/plugins/lens/public/pie_visualization/pie_visualization.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/pie_visualization.tsx @@ -13,7 +13,7 @@ import { toExpression, toPreviewExpression } from './to_expression'; import { LayerState, PieVisualizationState } from './types'; import { suggestions } from './suggestions'; import { CHART_NAMES, MAX_PIE_BUCKETS, MAX_TREEMAP_BUCKETS } from './constants'; -import { SettingsWidget } from './settings_widget'; +import { PieToolbar } from './toolbar'; function newLayerState(layerId: string): LayerState { return { @@ -204,10 +204,10 @@ export const pieVisualization: Visualization - + , domElement ); diff --git a/x-pack/plugins/lens/public/pie_visualization/register_expression.tsx b/x-pack/plugins/lens/public/pie_visualization/register_expression.tsx index cea84db8b279..89d93ab79233 100644 --- a/x-pack/plugins/lens/public/pie_visualization/register_expression.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/register_expression.tsx @@ -7,6 +7,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { i18n } from '@kbn/i18n'; +import { Position } from '@elastic/charts'; import { I18nProvider } from '@kbn/i18n/react'; import { IInterpreterRenderHandlers, @@ -73,6 +74,11 @@ export const pie: ExpressionFunctionDefinition< types: ['boolean'], help: '', }, + legendPosition: { + types: ['string'], + options: [Position.Top, Position.Right, Position.Bottom, Position.Left], + help: '', + }, percentDecimals: { types: ['number'], help: '', diff --git a/x-pack/plugins/lens/public/pie_visualization/render_function.test.tsx b/x-pack/plugins/lens/public/pie_visualization/render_function.test.tsx index cfbeb27efb3d..38ef44a2fef1 100644 --- a/x-pack/plugins/lens/public/pie_visualization/render_function.test.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/render_function.test.tsx @@ -65,6 +65,13 @@ describe('PieVisualization component', () => { }; } + test('it shows legend on correct side', () => { + const component = shallow( + + ); + expect(component.find(Settings).prop('legendPosition')).toEqual('top'); + }); + test('it shows legend for 2 groups using default legendDisplay', () => { const component = shallow(); expect(component.find(Settings).prop('showLegend')).toEqual(true); diff --git a/x-pack/plugins/lens/public/pie_visualization/render_function.tsx b/x-pack/plugins/lens/public/pie_visualization/render_function.tsx index f349cc4dfd64..79986986b217 100644 --- a/x-pack/plugins/lens/public/pie_visualization/render_function.tsx +++ b/x-pack/plugins/lens/public/pie_visualization/render_function.tsx @@ -22,6 +22,7 @@ import { PartitionFillLabel, RecursivePartial, LayerValue, + Position, } from '@elastic/charts'; import { FormatFactory, LensFilterEvent } from '../types'; import { VisualizationContainer } from '../visualization_container'; @@ -55,6 +56,7 @@ export function PieComponent( numberDisplay, categoryDisplay, legendDisplay, + legendPosition, nestedLegend, percentDecimals, hideLabels, @@ -237,6 +239,7 @@ export function PieComponent( (legendDisplay === 'show' || (legendDisplay === 'default' && columnGroups.length > 1 && shape !== 'treemap')) } + legendPosition={legendPosition || Position.Right} legendMaxDepth={nestedLegend ? undefined : 1 /* Color is based only on first layer */} onElementClick={(args) => { const context = getFilterContext( diff --git a/x-pack/plugins/lens/public/pie_visualization/settings_widget.scss b/x-pack/plugins/lens/public/pie_visualization/settings_widget.scss deleted file mode 100644 index 4fa328d8a904..000000000000 --- a/x-pack/plugins/lens/public/pie_visualization/settings_widget.scss +++ /dev/null @@ -1,3 +0,0 @@ -.lnsPieSettingsWidget { - min-width: $euiSizeXL * 10; -} diff --git a/x-pack/plugins/lens/public/pie_visualization/settings_widget.tsx b/x-pack/plugins/lens/public/pie_visualization/settings_widget.tsx deleted file mode 100644 index e5fde12f74b4..000000000000 --- a/x-pack/plugins/lens/public/pie_visualization/settings_widget.tsx +++ /dev/null @@ -1,230 +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 React from 'react'; -import { i18n } from '@kbn/i18n'; -import { - EuiForm, - EuiFormRow, - EuiSuperSelect, - EuiRange, - EuiSwitch, - EuiHorizontalRule, - EuiSpacer, - EuiButtonGroup, -} from '@elastic/eui'; -import { DEFAULT_PERCENT_DECIMALS } from './constants'; -import { PieVisualizationState, SharedLayerState } from './types'; -import { VisualizationLayerWidgetProps } from '../types'; -import './settings_widget.scss'; - -const numberOptions: Array<{ value: SharedLayerState['numberDisplay']; inputDisplay: string }> = [ - { - value: 'hidden', - inputDisplay: i18n.translate('xpack.lens.pieChart.hiddenNumbersLabel', { - defaultMessage: 'Hide from chart', - }), - }, - { - value: 'percent', - inputDisplay: i18n.translate('xpack.lens.pieChart.showPercentValuesLabel', { - defaultMessage: 'Show percent', - }), - }, - { - value: 'value', - inputDisplay: i18n.translate('xpack.lens.pieChart.showFormatterValuesLabel', { - defaultMessage: 'Show value', - }), - }, -]; - -const categoryOptions: Array<{ - value: SharedLayerState['categoryDisplay']; - inputDisplay: string; -}> = [ - { - value: 'default', - inputDisplay: i18n.translate('xpack.lens.pieChart.showCategoriesLabel', { - defaultMessage: 'Inside or outside', - }), - }, - { - value: 'inside', - inputDisplay: i18n.translate('xpack.lens.pieChart.fitInsideOnlyLabel', { - defaultMessage: 'Inside only', - }), - }, - { - value: 'hide', - inputDisplay: i18n.translate('xpack.lens.pieChart.categoriesInLegendLabel', { - defaultMessage: 'Hide labels', - }), - }, -]; - -const categoryOptionsTreemap: Array<{ - value: SharedLayerState['categoryDisplay']; - inputDisplay: string; -}> = [ - { - value: 'default', - inputDisplay: i18n.translate('xpack.lens.pieChart.showTreemapCategoriesLabel', { - defaultMessage: 'Show labels', - }), - }, - { - value: 'hide', - inputDisplay: i18n.translate('xpack.lens.pieChart.categoriesInLegendLabel', { - defaultMessage: 'Hide labels', - }), - }, -]; - -const legendOptions: Array<{ - value: SharedLayerState['legendDisplay']; - label: string; - id: string; -}> = [ - { - id: 'pieLegendDisplay-default', - value: 'default', - label: i18n.translate('xpack.lens.pieChart.defaultLegendLabel', { - defaultMessage: 'auto', - }), - }, - { - id: 'pieLegendDisplay-show', - value: 'show', - label: i18n.translate('xpack.lens.pieChart.alwaysShowLegendLabel', { - defaultMessage: 'show', - }), - }, - { - id: 'pieLegendDisplay-hide', - value: 'hide', - label: i18n.translate('xpack.lens.pieChart.hideLegendLabel', { - defaultMessage: 'hide', - }), - }, -]; - -export function SettingsWidget(props: VisualizationLayerWidgetProps) { - const { state, setState } = props; - const layer = state.layers[0]; - if (!layer) { - return null; - } - - return ( - - - { - setState({ - ...state, - layers: [{ ...layer, categoryDisplay: option }], - }); - }} - /> - - - { - setState({ - ...state, - layers: [{ ...layer, numberDisplay: option }], - }); - }} - /> - - - - { - setState({ - ...state, - layers: [{ ...layer, percentDecimals: Number(e.currentTarget.value) }], - }); - }} - /> - - - -
- value === layer.legendDisplay)!.id} - onChange={(optionId) => { - setState({ - ...state, - layers: [ - { - ...layer, - legendDisplay: legendOptions.find(({ id }) => id === optionId)!.value, - }, - ], - }); - }} - buttonSize="compressed" - isFullWidth - /> - - - { - setState({ ...state, layers: [{ ...layer, nestedLegend: !layer.nestedLegend }] }); - }} - /> -
-
-
- ); -} diff --git a/x-pack/plugins/lens/public/pie_visualization/to_expression.ts b/x-pack/plugins/lens/public/pie_visualization/to_expression.ts index cf9d311dfd50..fbc47e8bfb00 100644 --- a/x-pack/plugins/lens/public/pie_visualization/to_expression.ts +++ b/x-pack/plugins/lens/public/pie_visualization/to_expression.ts @@ -41,6 +41,7 @@ function expressionHelper( numberDisplay: [layer.numberDisplay], categoryDisplay: [layer.categoryDisplay], legendDisplay: [layer.legendDisplay], + legendPosition: [layer.legendPosition || 'right'], percentDecimals: [layer.percentDecimals ?? DEFAULT_PERCENT_DECIMALS], nestedLegend: [!!layer.nestedLegend], }, diff --git a/x-pack/plugins/lens/public/pie_visualization/toolbar.scss b/x-pack/plugins/lens/public/pie_visualization/toolbar.scss new file mode 100644 index 000000000000..3cfbe6480c61 --- /dev/null +++ b/x-pack/plugins/lens/public/pie_visualization/toolbar.scss @@ -0,0 +1,3 @@ +.lnsPieToolbar__popover { + width: $euiFormMaxWidth; +} diff --git a/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx b/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx new file mode 100644 index 000000000000..9c3d0d0f3481 --- /dev/null +++ b/x-pack/plugins/lens/public/pie_visualization/toolbar.tsx @@ -0,0 +1,281 @@ +/* + * 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 './toolbar.scss'; +import React, { useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiPopover, + EuiSelect, + EuiFormRow, + EuiSuperSelect, + EuiRange, + EuiSwitch, + EuiHorizontalRule, + EuiSpacer, + EuiButtonGroup, +} from '@elastic/eui'; +import { Position } from '@elastic/charts'; +import { DEFAULT_PERCENT_DECIMALS } from './constants'; +import { PieVisualizationState, SharedLayerState } from './types'; +import { VisualizationToolbarProps } from '../types'; +import { ToolbarButton } from '../toolbar_button'; + +const numberOptions: Array<{ value: SharedLayerState['numberDisplay']; inputDisplay: string }> = [ + { + value: 'hidden', + inputDisplay: i18n.translate('xpack.lens.pieChart.hiddenNumbersLabel', { + defaultMessage: 'Hide from chart', + }), + }, + { + value: 'percent', + inputDisplay: i18n.translate('xpack.lens.pieChart.showPercentValuesLabel', { + defaultMessage: 'Show percent', + }), + }, + { + value: 'value', + inputDisplay: i18n.translate('xpack.lens.pieChart.showFormatterValuesLabel', { + defaultMessage: 'Show value', + }), + }, +]; + +const categoryOptions: Array<{ + value: SharedLayerState['categoryDisplay']; + inputDisplay: string; +}> = [ + { + value: 'default', + inputDisplay: i18n.translate('xpack.lens.pieChart.showCategoriesLabel', { + defaultMessage: 'Inside or outside', + }), + }, + { + value: 'inside', + inputDisplay: i18n.translate('xpack.lens.pieChart.fitInsideOnlyLabel', { + defaultMessage: 'Inside only', + }), + }, + { + value: 'hide', + inputDisplay: i18n.translate('xpack.lens.pieChart.categoriesInLegendLabel', { + defaultMessage: 'Hide labels', + }), + }, +]; + +const categoryOptionsTreemap: Array<{ + value: SharedLayerState['categoryDisplay']; + inputDisplay: string; +}> = [ + { + value: 'default', + inputDisplay: i18n.translate('xpack.lens.pieChart.showTreemapCategoriesLabel', { + defaultMessage: 'Show labels', + }), + }, + { + value: 'hide', + inputDisplay: i18n.translate('xpack.lens.pieChart.categoriesInLegendLabel', { + defaultMessage: 'Hide labels', + }), + }, +]; + +const legendOptions: Array<{ + value: SharedLayerState['legendDisplay']; + label: string; + id: string; +}> = [ + { + id: 'pieLegendDisplay-default', + value: 'default', + label: i18n.translate('xpack.lens.pieChart.legendVisibility.auto', { + defaultMessage: 'auto', + }), + }, + { + id: 'pieLegendDisplay-show', + value: 'show', + label: i18n.translate('xpack.lens.pieChart.legendVisibility.show', { + defaultMessage: 'show', + }), + }, + { + id: 'pieLegendDisplay-hide', + value: 'hide', + label: i18n.translate('xpack.lens.pieChart.legendVisibility.hide', { + defaultMessage: 'hide', + }), + }, +]; + +export function PieToolbar(props: VisualizationToolbarProps) { + const [open, setOpen] = useState(false); + const { state, setState } = props; + const layer = state.layers[0]; + if (!layer) { + return null; + } + return ( + + + { + setOpen(!open); + }} + > + {i18n.translate('xpack.lens.pieChart.settingsLabel', { defaultMessage: 'Settings' })} + + } + isOpen={open} + closePopover={() => { + setOpen(false); + }} + anchorPosition="downRight" + > + + { + setState({ + ...state, + layers: [{ ...layer, categoryDisplay: option }], + }); + }} + /> + + + { + setState({ + ...state, + layers: [{ ...layer, numberDisplay: option }], + }); + }} + /> + + + + { + setState({ + ...state, + layers: [{ ...layer, percentDecimals: Number(e.currentTarget.value) }], + }); + }} + /> + + + +
+ value === layer.legendDisplay)!.id} + onChange={(optionId) => { + setState({ + ...state, + layers: [ + { + ...layer, + legendDisplay: legendOptions.find(({ id }) => id === optionId)!.value, + }, + ], + }); + }} + buttonSize="compressed" + isFullWidth + /> + + + { + setState({ ...state, layers: [{ ...layer, nestedLegend: !layer.nestedLegend }] }); + }} + /> +
+
+ + { + setState({ + ...state, + layers: [{ ...layer, legendPosition: e.target.value as Position }], + }); + }} + /> + +
+
+
+ ); +} diff --git a/x-pack/plugins/lens/public/pie_visualization/types.ts b/x-pack/plugins/lens/public/pie_visualization/types.ts index 60b656424864..74156bce2ea7 100644 --- a/x-pack/plugins/lens/public/pie_visualization/types.ts +++ b/x-pack/plugins/lens/public/pie_visualization/types.ts @@ -13,6 +13,7 @@ export interface SharedLayerState { numberDisplay: 'hidden' | 'percent' | 'value'; categoryDisplay: 'default' | 'inside' | 'hide'; legendDisplay: 'default' | 'show' | 'hide'; + legendPosition?: 'left' | 'right' | 'top' | 'bottom'; nestedLegend?: boolean; percentDecimals?: number; } diff --git a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap index d7d76bdd1f44..b5783803b803 100644 --- a/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap +++ b/x-pack/plugins/lens/public/xy_visualization/__snapshots__/to_expression.test.ts.snap @@ -64,6 +64,7 @@ Object { "position": Array [ "bottom", ], + "showSingleSeries": Array [], }, "function": "lens_xy_legendConfig", "type": "function", diff --git a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts index 3b9406cedd49..b17704b38cde 100644 --- a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts +++ b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts @@ -127,6 +127,9 @@ export const buildExpression = ( function: 'lens_xy_legendConfig', arguments: { isVisible: [state.legend.isVisible], + showSingleSeries: state.legend.showSingleSeries + ? [state.legend.showSingleSeries] + : [], position: [state.legend.position], }, }, diff --git a/x-pack/plugins/lens/public/xy_visualization/types.ts b/x-pack/plugins/lens/public/xy_visualization/types.ts index 08f29c65b26d..605119535d1f 100644 --- a/x-pack/plugins/lens/public/xy_visualization/types.ts +++ b/x-pack/plugins/lens/public/xy_visualization/types.ts @@ -19,8 +19,18 @@ import { VisualizationType } from '../index'; import { FittingFunction } from './fitting_functions'; export interface LegendConfig { + /** + * Flag whether the legend should be shown. If there is just a single series, it will be hidden + */ isVisible: boolean; + /** + * Position of the legend relative to the chart + */ position: Position; + /** + * Flag whether the legend should be shown even with just a single series + */ + showSingleSeries?: boolean; } type LegendConfigResult = LegendConfig & { type: 'lens_xy_legendConfig' }; @@ -50,6 +60,12 @@ export const legendConfig: ExpressionFunctionDefinition< defaultMessage: 'Specifies the legend position.', }), }, + showSingleSeries: { + types: ['boolean'], + help: i18n.translate('xpack.lens.xyChart.showSingleSeries.help', { + defaultMessage: 'Specifies whether a legend with just a single entry should be shown', + }), + }, }, fn: function fn(input: unknown, args: LegendConfig) { return { diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx index d22b3ec0a44a..59c4b393df46 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx @@ -7,6 +7,7 @@ import './xy_config_panel.scss'; import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; +import { Position } from '@elastic/charts'; import { debounce } from 'lodash'; import { EuiButtonGroup, @@ -16,12 +17,14 @@ import { EuiFormRow, EuiPopover, EuiText, + EuiSelect, htmlIdGenerator, EuiForm, EuiColorPicker, EuiColorPickerProps, EuiToolTip, EuiIcon, + EuiHorizontalRule, } from '@elastic/eui'; import { VisualizationLayerWidgetProps, @@ -46,6 +49,30 @@ function updateLayer(state: State, layer: UnwrapArray, index: n }; } +const legendOptions: Array<{ id: string; value: 'auto' | 'show' | 'hide'; label: string }> = [ + { + id: `xy_legend_auto`, + value: 'auto', + label: i18n.translate('xpack.lens.xyChart.legendVisibility.auto', { + defaultMessage: 'auto', + }), + }, + { + id: `xy_legend_show`, + value: 'show', + label: i18n.translate('xpack.lens.xyChart.legendVisibility.show', { + defaultMessage: 'show', + }), + }, + { + id: `xy_legend_hide`, + value: 'hide', + label: i18n.translate('xpack.lens.xyChart.legendVisibility.hide', { + defaultMessage: 'hide', + }), + }, +]; + export function LayerContextMenu(props: VisualizationLayerWidgetProps) { const { state, layerId } = props; const horizontalOnly = isHorizontalChart(state.layers); @@ -95,6 +122,12 @@ export function XyToolbar(props: VisualizationToolbarProps) { const hasNonBarSeries = props.state?.layers.some( (layer) => layer.seriesType === 'line' || layer.seriesType === 'area' ); + const legendMode = + props.state?.legend.isVisible && !props.state?.legend.showSingleSeries + ? 'auto' + : !props.state?.legend.isVisible + ? 'hide' + : 'show'; return ( @@ -157,6 +190,67 @@ export function XyToolbar(props: VisualizationToolbarProps) { /> + + + value === legendMode)!.id} + onChange={(optionId) => { + const newMode = legendOptions.find(({ id }) => id === optionId)!.value; + if (newMode === 'auto') { + props.setState({ + ...props.state, + legend: { ...props.state.legend, isVisible: true, showSingleSeries: false }, + }); + } else if (newMode === 'show') { + props.setState({ + ...props.state, + legend: { ...props.state.legend, isVisible: true, showSingleSeries: true }, + }); + } else if (newMode === 'hide') { + props.setState({ + ...props.state, + legend: { ...props.state.legend, isVisible: false, showSingleSeries: false }, + }); + } + }} + /> + + + { + props.setState({ + ...props.state, + legend: { ...props.state.legend, position: e.target.value as Position }, + }); + }} + /> + diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_expression.test.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_expression.test.tsx index b7a50b3af640..c880cbb641e5 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_expression.test.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_expression.test.tsx @@ -1556,6 +1556,73 @@ describe('xy_expression', () => { expect(component.find(Settings).prop('showLegend')).toEqual(true); }); + test('it should always show legend if showSingleSeries is set', () => { + const { data, args } = sampleArgs(); + + const component = shallow( + + ); + + expect(component.find(Settings).prop('showLegend')).toEqual(true); + }); + + test('it not show legend if isVisible is set to false', () => { + const { data, args } = sampleArgs(); + + const component = shallow( + + ); + + expect(component.find(Settings).prop('showLegend')).toEqual(false); + }); + + test('it should show legend on right side', () => { + const { data, args } = sampleArgs(); + + const component = shallow( + + ); + + expect(component.find(Settings).prop('legendPosition')).toEqual('top'); + }); + test('it should apply the fitting function to all non-bar series', () => { const data: LensMultiTable = { type: 'lens_multitable', diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_expression.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_expression.tsx index 3ab12aa0879b..871b626d6256 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_expression.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_expression.tsx @@ -282,7 +282,11 @@ export function XYChart({ return ( Date: Wed, 22 Jul 2020 12:15:39 +0200 Subject: [PATCH 030/142] do not pass title as part of tsvb request (#72619) --- src/plugins/visualizations/public/legacy/build_pipeline.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/plugins/visualizations/public/legacy/build_pipeline.ts b/src/plugins/visualizations/public/legacy/build_pipeline.ts index e74a83d91fab..d3fe814f3b01 100644 --- a/src/plugins/visualizations/public/legacy/build_pipeline.ts +++ b/src/plugins/visualizations/public/legacy/build_pipeline.ts @@ -255,7 +255,7 @@ export const buildPipelineVisFunction: BuildPipelineVisFunction = { input_control_vis: (params) => { return `input_control_vis ${prepareJson('visConfig', params)}`; }, - metrics: (params, schemas, uiState = {}) => { + metrics: ({ title, ...params }, schemas, uiState = {}) => { const paramsJson = prepareJson('params', params); const uiStateJson = prepareJson('uiState', uiState); From cb0405eeaecbc85986a52462605b54671bd343ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Marcondes?= <55978943+cauemarcondes@users.noreply.github.com> Date: Wed, 22 Jul 2020 13:30:52 +0100 Subject: [PATCH 031/142] [Observability] filter "hasData" api by processor event (#72810) * filtering hasdata by processor event * adding api test --- .../lib/observability_overview/has_data.ts | 21 +- .../observability_overview/data.json.gz | Bin 0 -> 377 bytes .../observability_overview/mappings.json | 4229 +++++++++++++++++ .../apm_api_integration/basic/tests/index.ts | 5 + .../tests/observability_overview/has_data.ts | 41 + .../observability_overview.ts | 47 + 6 files changed, 4342 insertions(+), 1 deletion(-) create mode 100644 x-pack/test/apm_api_integration/basic/fixtures/es_archiver/observability_overview/data.json.gz create mode 100644 x-pack/test/apm_api_integration/basic/fixtures/es_archiver/observability_overview/mappings.json create mode 100644 x-pack/test/apm_api_integration/basic/tests/observability_overview/has_data.ts create mode 100644 x-pack/test/apm_api_integration/basic/tests/observability_overview/observability_overview.ts diff --git a/x-pack/plugins/apm/server/lib/observability_overview/has_data.ts b/x-pack/plugins/apm/server/lib/observability_overview/has_data.ts index 73cc2d273ec6..fc7445ab4a22 100644 --- a/x-pack/plugins/apm/server/lib/observability_overview/has_data.ts +++ b/x-pack/plugins/apm/server/lib/observability_overview/has_data.ts @@ -3,6 +3,8 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +import { PROCESSOR_EVENT } from '../../../common/elasticsearch_fieldnames'; +import { ProcessorEvent } from '../../../common/processor_event'; import { Setup } from '../helpers/setup_request'; export async function hasData({ setup }: { setup: Setup }) { @@ -15,7 +17,24 @@ export async function hasData({ setup }: { setup: Setup }) { indices['apm_oss.metricsIndices'], ], terminateAfter: 1, - size: 0, + body: { + size: 0, + query: { + bool: { + filter: [ + { + terms: { + [PROCESSOR_EVENT]: [ + ProcessorEvent.error, + ProcessorEvent.metric, + ProcessorEvent.transaction, + ], + }, + }, + ], + }, + }, + }, }; const response = await client.search(params); diff --git a/x-pack/test/apm_api_integration/basic/fixtures/es_archiver/observability_overview/data.json.gz b/x-pack/test/apm_api_integration/basic/fixtures/es_archiver/observability_overview/data.json.gz new file mode 100644 index 0000000000000000000000000000000000000000..23602666f3b43ddab67671239a717cd706c6a0f2 GIT binary patch literal 377 zcmV-<0fzn`iwFoj4j5km17u-zVJ>QOZ*Bl>Qn7BrFcjSR3X~Z~wiBB;Q&p)0U04_@ zstUc>rld;jC=RF<;@@jSQbJ*|Ab{Oun!-Y>O7n>*rXJxj6$9VdeJigW zJo40)wRRoUO|S_HggK&Og?XN`Jmqmp*t*wyzLstz4{z43E3FA?60;abed%anUS z{g}p&8^rH<{*h-C<1u6S1Yv{yY_o^yp4a=JwyELEhCs5rw3^mR?VSA|SHF { + describe('when data is not loaded', () => { + it('returns false when there is no data', async () => { + const response = await supertest.get('/api/apm/observability_overview/has_data'); + expect(response.status).to.be(200); + expect(response.body).to.eql(false); + }); + }); + describe('when only onboarding data is loaded', () => { + before(() => esArchiver.load('observability_overview')); + after(() => esArchiver.unload('observability_overview')); + it('returns false when there is only onboarding data', async () => { + const response = await supertest.get('/api/apm/observability_overview/has_data'); + expect(response.status).to.be(200); + expect(response.body).to.eql(false); + }); + }); + describe('when data is loaded', () => { + before(() => esArchiver.load('8.0.0')); + after(() => esArchiver.unload('8.0.0')); + + it('returns true when there is at least one document on transaction, error or metrics indices', async () => { + const response = await supertest.get('/api/apm/observability_overview/has_data'); + expect(response.status).to.be(200); + expect(response.body).to.eql(true); + }); + }); + }); +} diff --git a/x-pack/test/apm_api_integration/basic/tests/observability_overview/observability_overview.ts b/x-pack/test/apm_api_integration/basic/tests/observability_overview/observability_overview.ts new file mode 100644 index 000000000000..bd8b0c6126fa --- /dev/null +++ b/x-pack/test/apm_api_integration/basic/tests/observability_overview/observability_overview.ts @@ -0,0 +1,47 @@ +/* + * 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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; + +export default function ApiTest({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + // url parameters + const start = encodeURIComponent('2020-06-29T06:00:00.000Z'); + const end = encodeURIComponent('2020-06-29T10:00:00.000Z'); + const bucketSize = '60s'; + + describe('Observability overview', () => { + describe('when data is not loaded', () => { + it('handles the empty state', async () => { + const response = await supertest.get( + `/api/apm/observability_overview?start=${start}&end=${end}&bucketSize=${bucketSize}` + ); + expect(response.status).to.be(200); + expect(response.body).to.eql({ serviceCount: 0, transactionCoordinates: [] }); + }); + }); + describe('when data is loaded', () => { + before(() => esArchiver.load('8.0.0')); + after(() => esArchiver.unload('8.0.0')); + + it('returns the service count and transaction coordinates', async () => { + const response = await supertest.get( + `/api/apm/observability_overview?start=${start}&end=${end}&bucketSize=${bucketSize}` + ); + expect(response.status).to.be(200); + expect(response.body).to.eql({ + serviceCount: 3, + transactionCoordinates: [ + { x: 1593413220000, y: 0.016666666666666666 }, + { x: 1593413280000, y: 1.0458333333333334 }, + ], + }); + }); + }); + }); +} From a41633d8c5a3581df83f2e95dfcf33f76793fab6 Mon Sep 17 00:00:00 2001 From: Gidi Meir Morris Date: Wed, 22 Jul 2020 13:39:33 +0100 Subject: [PATCH 032/142] [Task Manager] Addresses flaky test introduced by buffered store (#72815) Removed unused functionality which we weren't using anyway and was causing some flaky behaviour. --- .../server/lib/bulk_operation_buffer.test.ts | 44 +------------------ .../server/lib/bulk_operation_buffer.ts | 11 +---- 2 files changed, 3 insertions(+), 52 deletions(-) diff --git a/x-pack/plugins/task_manager/server/lib/bulk_operation_buffer.test.ts b/x-pack/plugins/task_manager/server/lib/bulk_operation_buffer.test.ts index 1994e5f74937..2c6d2b64f5d4 100644 --- a/x-pack/plugins/task_manager/server/lib/bulk_operation_buffer.test.ts +++ b/x-pack/plugins/task_manager/server/lib/bulk_operation_buffer.test.ts @@ -33,7 +33,7 @@ function errorAttempts(task: TaskInstance): Err { +describe('Bulk Operation Buffer', () => { describe('createBuffer()', () => { test('batches up multiple Operation calls', async () => { const bulkUpdate: jest.Mocked> = jest.fn( @@ -54,48 +54,6 @@ describe.skip('Bulk Operation Buffer', () => { expect(bulkUpdate).toHaveBeenCalledWith([task1, task2]); }); - test('batch updates are executed at most by the next Event Loop tick by default', async () => { - const bulkUpdate: jest.Mocked> = jest.fn((tasks) => { - return Promise.resolve(tasks.map(incrementAttempts)); - }); - - const bufferedUpdate = createBuffer(bulkUpdate); - - const task1 = createTask(); - const task2 = createTask(); - const task3 = createTask(); - const task4 = createTask(); - const task5 = createTask(); - const task6 = createTask(); - - return new Promise((resolve) => { - Promise.all([bufferedUpdate(task1), bufferedUpdate(task2)]).then((_) => { - expect(bulkUpdate).toHaveBeenCalledTimes(1); - expect(bulkUpdate).toHaveBeenCalledWith([task1, task2]); - expect(bulkUpdate).not.toHaveBeenCalledWith([task3, task4]); - }); - - setTimeout(() => { - // on next tick - setTimeout(() => { - // on next tick - expect(bulkUpdate).toHaveBeenCalledTimes(2); - Promise.all([bufferedUpdate(task5), bufferedUpdate(task6)]).then((_) => { - expect(bulkUpdate).toHaveBeenCalledTimes(3); - expect(bulkUpdate).toHaveBeenCalledWith([task5, task6]); - resolve(); - }); - }, 0); - - expect(bulkUpdate).toHaveBeenCalledTimes(1); - Promise.all([bufferedUpdate(task3), bufferedUpdate(task4)]).then((_) => { - expect(bulkUpdate).toHaveBeenCalledTimes(2); - expect(bulkUpdate).toHaveBeenCalledWith([task3, task4]); - }); - }, 0); - }); - }); - test('batch updates can be customised to execute after a certain period', async () => { const bulkUpdate: jest.Mocked> = jest.fn((tasks) => { return Promise.resolve(tasks.map(incrementAttempts)); diff --git a/x-pack/plugins/task_manager/server/lib/bulk_operation_buffer.ts b/x-pack/plugins/task_manager/server/lib/bulk_operation_buffer.ts index fca7ce02e0cd..c8e5b837fa36 100644 --- a/x-pack/plugins/task_manager/server/lib/bulk_operation_buffer.ts +++ b/x-pack/plugins/task_manager/server/lib/bulk_operation_buffer.ts @@ -93,11 +93,8 @@ export function createBuffer { setTimeout(resolve, ms); From 4dcf719edbf74b944b12180030d5540de1f74b47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Marcondes?= <55978943+cauemarcondes@users.noreply.github.com> Date: Wed, 22 Jul 2020 14:01:29 +0100 Subject: [PATCH 033/142] Adding api test for transaction_groups /breakdown and /avg_duration_by_browser (#72623) * adding api test for transaction_groups /breakdown and /avg_duration_by_browser * adding filter by transaction name * adding filter by transaction name * addressing pr comments * fixing TS issue Co-authored-by: Elastic Machine --- .../avg_duration_by_browser/fetcher.ts | 8 +- .../avg_duration_by_browser/index.ts | 1 + .../apm/server/routes/transaction_groups.ts | 3 +- .../apm_api_integration/basic/tests/index.ts | 2 + .../avg_duration_by_browser.ts | 53 ++ .../tests/transaction_groups/breakdown.ts | 67 ++ .../expectation/avg_duration_by_browser.json | 735 ++++++++++++++++++ ..._duration_by_browser_transaction_name.json | 731 +++++++++++++++++ .../expectation/breakdown.json | 202 +++++ .../breakdown_transaction_name.json | 55 ++ 10 files changed, 1855 insertions(+), 2 deletions(-) create mode 100644 x-pack/test/apm_api_integration/basic/tests/transaction_groups/avg_duration_by_browser.ts create mode 100644 x-pack/test/apm_api_integration/basic/tests/transaction_groups/breakdown.ts create mode 100644 x-pack/test/apm_api_integration/basic/tests/transaction_groups/expectation/avg_duration_by_browser.json create mode 100644 x-pack/test/apm_api_integration/basic/tests/transaction_groups/expectation/avg_duration_by_browser_transaction_name.json create mode 100644 x-pack/test/apm_api_integration/basic/tests/transaction_groups/expectation/breakdown.json create mode 100644 x-pack/test/apm_api_integration/basic/tests/transaction_groups/expectation/breakdown_transaction_name.json diff --git a/x-pack/plugins/apm/server/lib/transactions/avg_duration_by_browser/fetcher.ts b/x-pack/plugins/apm/server/lib/transactions/avg_duration_by_browser/fetcher.ts index e3d688b69438..b4d98ec41fc2 100644 --- a/x-pack/plugins/apm/server/lib/transactions/avg_duration_by_browser/fetcher.ts +++ b/x-pack/plugins/apm/server/lib/transactions/avg_duration_by_browser/fetcher.ts @@ -12,6 +12,7 @@ import { TRANSACTION_TYPE, USER_AGENT_NAME, TRANSACTION_DURATION, + TRANSACTION_NAME, } from '../../../../common/elasticsearch_fieldnames'; import { rangeFilter } from '../../../../common/utils/range_filter'; import { getBucketSize } from '../../helpers/get_bucket_size'; @@ -23,15 +24,20 @@ export type ESResponse = PromiseReturnType; export function fetcher(options: Options) { const { end, client, indices, start, uiFiltersES } = options.setup; - const { serviceName } = options; + const { serviceName, transactionName } = options; const { intervalString } = getBucketSize(start, end, 'auto'); + const transactionNameFilter = transactionName + ? [{ term: { [TRANSACTION_NAME]: transactionName } }] + : []; + const filter: ESFilter[] = [ { term: { [PROCESSOR_EVENT]: ProcessorEvent.transaction } }, { term: { [SERVICE_NAME]: serviceName } }, { term: { [TRANSACTION_TYPE]: TRANSACTION_PAGE_LOAD } }, { range: rangeFilter(start, end) }, ...uiFiltersES, + ...transactionNameFilter, ]; const params = { diff --git a/x-pack/plugins/apm/server/lib/transactions/avg_duration_by_browser/index.ts b/x-pack/plugins/apm/server/lib/transactions/avg_duration_by_browser/index.ts index 000890f52ebe..e3a0d9e26142 100644 --- a/x-pack/plugins/apm/server/lib/transactions/avg_duration_by_browser/index.ts +++ b/x-pack/plugins/apm/server/lib/transactions/avg_duration_by_browser/index.ts @@ -16,6 +16,7 @@ import { transformer } from './transformer'; export interface Options { serviceName: string; setup: Setup & SetupTimeRange & SetupUIFilters; + transactionName?: string; } export type AvgDurationByBrowserAPIResponse = Array<{ diff --git a/x-pack/plugins/apm/server/routes/transaction_groups.ts b/x-pack/plugins/apm/server/routes/transaction_groups.ts index 813d757c7c33..c667ce4f07e9 100644 --- a/x-pack/plugins/apm/server/routes/transaction_groups.ts +++ b/x-pack/plugins/apm/server/routes/transaction_groups.ts @@ -164,7 +164,6 @@ export const transactionGroupsAvgDurationByBrowser = createRoute(() => ({ }), query: t.intersection([ t.partial({ - transactionType: t.string, transactionName: t.string, }), uiFiltersRt, @@ -174,10 +173,12 @@ export const transactionGroupsAvgDurationByBrowser = createRoute(() => ({ handler: async ({ context, request }) => { const setup = await setupRequest(context, request); const { serviceName } = context.params.path; + const { transactionName } = context.params.query; return getTransactionAvgDurationByBrowser({ serviceName, setup, + transactionName, }); }, })); diff --git a/x-pack/test/apm_api_integration/basic/tests/index.ts b/x-pack/test/apm_api_integration/basic/tests/index.ts index a1950f1c0947..b6a32ace5db5 100644 --- a/x-pack/test/apm_api_integration/basic/tests/index.ts +++ b/x-pack/test/apm_api_integration/basic/tests/index.ts @@ -35,6 +35,8 @@ export default function apmApiIntegrationTests({ loadTestFile }: FtrProviderCont loadTestFile(require.resolve('./transaction_groups/top_transaction_groups')); loadTestFile(require.resolve('./transaction_groups/transaction_charts')); loadTestFile(require.resolve('./transaction_groups/error_rate')); + loadTestFile(require.resolve('./transaction_groups/breakdown')); + loadTestFile(require.resolve('./transaction_groups/avg_duration_by_browser')); }); describe('Observability overview', function () { diff --git a/x-pack/test/apm_api_integration/basic/tests/transaction_groups/avg_duration_by_browser.ts b/x-pack/test/apm_api_integration/basic/tests/transaction_groups/avg_duration_by_browser.ts new file mode 100644 index 000000000000..690935ddc7f6 --- /dev/null +++ b/x-pack/test/apm_api_integration/basic/tests/transaction_groups/avg_duration_by_browser.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import expectedAvgDurationByBrowser from './expectation/avg_duration_by_browser.json'; +import expectedAvgDurationByBrowserWithTransactionName from './expectation/avg_duration_by_browser_transaction_name.json'; + +export default function ApiTest({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + const start = encodeURIComponent('2020-06-29T06:45:00.000Z'); + const end = encodeURIComponent('2020-06-29T06:49:00.000Z'); + const transactionName = '/products'; + const uiFilters = encodeURIComponent(JSON.stringify({})); + + describe('Average duration by browser', () => { + describe('when data is not loaded', () => { + it('handles the empty state', async () => { + const response = await supertest.get( + `/api/apm/services/client/transaction_groups/avg_duration_by_browser?start=${start}&end=${end}&uiFilters=${uiFilters}` + ); + expect(response.status).to.be(200); + expect(response.body).to.eql([]); + }); + }); + + describe('when data is loaded', () => { + before(() => esArchiver.load('8.0.0')); + after(() => esArchiver.unload('8.0.0')); + + it('returns the average duration by browser', async () => { + const response = await supertest.get( + `/api/apm/services/client/transaction_groups/avg_duration_by_browser?start=${start}&end=${end}&uiFilters=${uiFilters}` + ); + + expect(response.status).to.be(200); + expect(response.body).to.eql(expectedAvgDurationByBrowser); + }); + it('returns the average duration by browser filtering by transaction name', async () => { + const response = await supertest.get( + `/api/apm/services/client/transaction_groups/avg_duration_by_browser?start=${start}&end=${end}&uiFilters=${uiFilters}&transactionName=${transactionName}` + ); + + expect(response.status).to.be(200); + expect(response.body).to.eql(expectedAvgDurationByBrowserWithTransactionName); + }); + }); + }); +} diff --git a/x-pack/test/apm_api_integration/basic/tests/transaction_groups/breakdown.ts b/x-pack/test/apm_api_integration/basic/tests/transaction_groups/breakdown.ts new file mode 100644 index 000000000000..5b61112a374c --- /dev/null +++ b/x-pack/test/apm_api_integration/basic/tests/transaction_groups/breakdown.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import expectedBreakdown from './expectation/breakdown.json'; +import expectedBreakdownWithTransactionName from './expectation/breakdown_transaction_name.json'; + +export default function ApiTest({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + const start = encodeURIComponent('2020-06-29T06:45:00.000Z'); + const end = encodeURIComponent('2020-06-29T06:49:00.000Z'); + const transactionType = 'request'; + const transactionName = 'GET /api'; + const uiFilters = encodeURIComponent(JSON.stringify({})); + + describe('Breakdown', () => { + describe('when data is not loaded', () => { + it('handles the empty state', async () => { + const response = await supertest.get( + `/api/apm/services/opbeans-node/transaction_groups/breakdown?start=${start}&end=${end}&uiFilters=${uiFilters}&transactionType=${transactionType}` + ); + expect(response.status).to.be(200); + expect(response.body).to.eql({ kpis: [], timeseries: [] }); + }); + }); + + describe('when data is loaded', () => { + before(() => esArchiver.load('8.0.0')); + after(() => esArchiver.unload('8.0.0')); + + it('returns the transaction breakdown for a service', async () => { + const response = await supertest.get( + `/api/apm/services/opbeans-node/transaction_groups/breakdown?start=${start}&end=${end}&uiFilters=${uiFilters}&transactionType=${transactionType}` + ); + + expect(response.status).to.be(200); + expect(response.body).to.eql(expectedBreakdown); + }); + it('returns the transaction breakdown for a transaction group', async () => { + const response = await supertest.get( + `/api/apm/services/opbeans-node/transaction_groups/breakdown?start=${start}&end=${end}&uiFilters=${uiFilters}&transactionType=${transactionType}&transactionName=${transactionName}` + ); + + expect(response.status).to.be(200); + expect(response.body).to.eql(expectedBreakdownWithTransactionName); + }); + it('returns the top 4 by percentage and sorts them by name', async () => { + const response = await supertest.get( + `/api/apm/services/opbeans-node/transaction_groups/breakdown?start=${start}&end=${end}&uiFilters=${uiFilters}&transactionType=${transactionType}` + ); + + expect(response.status).to.be(200); + expect(response.body.kpis.map((kpi: { name: string }) => kpi.name)).to.eql([ + 'app', + 'http', + 'postgresql', + 'redis', + ]); + }); + }); + }); +} diff --git a/x-pack/test/apm_api_integration/basic/tests/transaction_groups/expectation/avg_duration_by_browser.json b/x-pack/test/apm_api_integration/basic/tests/transaction_groups/expectation/avg_duration_by_browser.json new file mode 100644 index 000000000000..cd53af3bf708 --- /dev/null +++ b/x-pack/test/apm_api_integration/basic/tests/transaction_groups/expectation/avg_duration_by_browser.json @@ -0,0 +1,735 @@ +[ + { + "title":"HeadlessChrome", + "data":[ + { + "x":1593413100000 + }, + { + "x":1593413101000 + }, + { + "x":1593413102000 + }, + { + "x":1593413103000 + }, + { + "x":1593413104000 + }, + { + "x":1593413105000 + }, + { + "x":1593413106000 + }, + { + "x":1593413107000 + }, + { + "x":1593413108000 + }, + { + "x":1593413109000 + }, + { + "x":1593413110000 + }, + { + "x":1593413111000 + }, + { + "x":1593413112000 + }, + { + "x":1593413113000 + }, + { + "x":1593413114000 + }, + { + "x":1593413115000 + }, + { + "x":1593413116000 + }, + { + "x":1593413117000 + }, + { + "x":1593413118000 + }, + { + "x":1593413119000 + }, + { + "x":1593413120000 + }, + { + "x":1593413121000 + }, + { + "x":1593413122000 + }, + { + "x":1593413123000 + }, + { + "x":1593413124000 + }, + { + "x":1593413125000 + }, + { + "x":1593413126000 + }, + { + "x":1593413127000 + }, + { + "x":1593413128000 + }, + { + "x":1593413129000 + }, + { + "x":1593413130000 + }, + { + "x":1593413131000 + }, + { + "x":1593413132000 + }, + { + "x":1593413133000 + }, + { + "x":1593413134000 + }, + { + "x":1593413135000 + }, + { + "x":1593413136000 + }, + { + "x":1593413137000 + }, + { + "x":1593413138000 + }, + { + "x":1593413139000 + }, + { + "x":1593413140000 + }, + { + "x":1593413141000 + }, + { + "x":1593413142000 + }, + { + "x":1593413143000 + }, + { + "x":1593413144000 + }, + { + "x":1593413145000 + }, + { + "x":1593413146000 + }, + { + "x":1593413147000 + }, + { + "x":1593413148000 + }, + { + "x":1593413149000 + }, + { + "x":1593413150000 + }, + { + "x":1593413151000 + }, + { + "x":1593413152000 + }, + { + "x":1593413153000 + }, + { + "x":1593413154000 + }, + { + "x":1593413155000 + }, + { + "x":1593413156000 + }, + { + "x":1593413157000 + }, + { + "x":1593413158000 + }, + { + "x":1593413159000 + }, + { + "x":1593413160000 + }, + { + "x":1593413161000 + }, + { + "x":1593413162000 + }, + { + "x":1593413163000 + }, + { + "x":1593413164000 + }, + { + "x":1593413165000 + }, + { + "x":1593413166000 + }, + { + "x":1593413167000 + }, + { + "x":1593413168000 + }, + { + "x":1593413169000 + }, + { + "x":1593413170000 + }, + { + "x":1593413171000 + }, + { + "x":1593413172000 + }, + { + "x":1593413173000 + }, + { + "x":1593413174000 + }, + { + "x":1593413175000 + }, + { + "x":1593413176000 + }, + { + "x":1593413177000 + }, + { + "x":1593413178000 + }, + { + "x":1593413179000 + }, + { + "x":1593413180000 + }, + { + "x":1593413181000 + }, + { + "x":1593413182000 + }, + { + "x":1593413183000 + }, + { + "x":1593413184000 + }, + { + "x":1593413185000 + }, + { + "x":1593413186000 + }, + { + "x":1593413187000 + }, + { + "x":1593413188000 + }, + { + "x":1593413189000 + }, + { + "x":1593413190000 + }, + { + "x":1593413191000 + }, + { + "x":1593413192000 + }, + { + "x":1593413193000 + }, + { + "x":1593413194000 + }, + { + "x":1593413195000 + }, + { + "x":1593413196000 + }, + { + "x":1593413197000 + }, + { + "x":1593413198000 + }, + { + "x":1593413199000 + }, + { + "x":1593413200000 + }, + { + "x":1593413201000 + }, + { + "x":1593413202000 + }, + { + "x":1593413203000 + }, + { + "x":1593413204000 + }, + { + "x":1593413205000 + }, + { + "x":1593413206000 + }, + { + "x":1593413207000 + }, + { + "x":1593413208000 + }, + { + "x":1593413209000 + }, + { + "x":1593413210000 + }, + { + "x":1593413211000 + }, + { + "x":1593413212000 + }, + { + "x":1593413213000 + }, + { + "x":1593413214000 + }, + { + "x":1593413215000 + }, + { + "x":1593413216000 + }, + { + "x":1593413217000 + }, + { + "x":1593413218000 + }, + { + "x":1593413219000 + }, + { + "x":1593413220000 + }, + { + "x":1593413221000 + }, + { + "x":1593413222000 + }, + { + "x":1593413223000 + }, + { + "x":1593413224000 + }, + { + "x":1593413225000 + }, + { + "x":1593413226000 + }, + { + "x":1593413227000 + }, + { + "x":1593413228000 + }, + { + "x":1593413229000 + }, + { + "x":1593413230000 + }, + { + "x":1593413231000 + }, + { + "x":1593413232000 + }, + { + "x":1593413233000 + }, + { + "x":1593413234000 + }, + { + "x":1593413235000 + }, + { + "x":1593413236000 + }, + { + "x":1593413237000 + }, + { + "x":1593413238000 + }, + { + "x":1593413239000 + }, + { + "x":1593413240000 + }, + { + "x":1593413241000 + }, + { + "x":1593413242000 + }, + { + "x":1593413243000 + }, + { + "x":1593413244000 + }, + { + "x":1593413245000 + }, + { + "x":1593413246000 + }, + { + "x":1593413247000 + }, + { + "x":1593413248000 + }, + { + "x":1593413249000 + }, + { + "x":1593413250000 + }, + { + "x":1593413251000 + }, + { + "x":1593413252000 + }, + { + "x":1593413253000 + }, + { + "x":1593413254000 + }, + { + "x":1593413255000 + }, + { + "x":1593413256000 + }, + { + "x":1593413257000 + }, + { + "x":1593413258000 + }, + { + "x":1593413259000 + }, + { + "x":1593413260000 + }, + { + "x":1593413261000 + }, + { + "x":1593413262000 + }, + { + "x":1593413263000 + }, + { + "x":1593413264000 + }, + { + "x":1593413265000 + }, + { + "x":1593413266000 + }, + { + "x":1593413267000 + }, + { + "x":1593413268000 + }, + { + "x":1593413269000 + }, + { + "x":1593413270000 + }, + { + "x":1593413271000 + }, + { + "x":1593413272000 + }, + { + "x":1593413273000 + }, + { + "x":1593413274000 + }, + { + "x":1593413275000 + }, + { + "x":1593413276000 + }, + { + "x":1593413277000 + }, + { + "x":1593413278000 + }, + { + "x":1593413279000 + }, + { + "x":1593413280000 + }, + { + "x":1593413281000 + }, + { + "x":1593413282000 + }, + { + "x":1593413283000 + }, + { + "x":1593413284000 + }, + { + "x":1593413285000 + }, + { + "x":1593413286000 + }, + { + "x":1593413287000, + "y":342000 + }, + { + "x":1593413288000 + }, + { + "x":1593413289000 + }, + { + "x":1593413290000 + }, + { + "x":1593413291000 + }, + { + "x":1593413292000 + }, + { + "x":1593413293000 + }, + { + "x":1593413294000 + }, + { + "x":1593413295000 + }, + { + "x":1593413296000 + }, + { + "x":1593413297000 + }, + { + "x":1593413298000, + "y":173000 + }, + { + "x":1593413299000 + }, + { + "x":1593413300000 + }, + { + "x":1593413301000, + "y":109000 + }, + { + "x":1593413302000 + }, + { + "x":1593413303000 + }, + { + "x":1593413304000 + }, + { + "x":1593413305000 + }, + { + "x":1593413306000 + }, + { + "x":1593413307000 + }, + { + "x":1593413308000 + }, + { + "x":1593413309000 + }, + { + "x":1593413310000 + }, + { + "x":1593413311000 + }, + { + "x":1593413312000 + }, + { + "x":1593413313000 + }, + { + "x":1593413314000 + }, + { + "x":1593413315000 + }, + { + "x":1593413316000 + }, + { + "x":1593413317000 + }, + { + "x":1593413318000, + "y":140000 + }, + { + "x":1593413319000 + }, + { + "x":1593413320000 + }, + { + "x":1593413321000 + }, + { + "x":1593413322000 + }, + { + "x":1593413323000 + }, + { + "x":1593413324000 + }, + { + "x":1593413325000 + }, + { + "x":1593413326000 + }, + { + "x":1593413327000 + }, + { + "x":1593413328000, + "y":77000 + }, + { + "x":1593413329000 + }, + { + "x":1593413330000 + }, + { + "x":1593413331000 + }, + { + "x":1593413332000 + }, + { + "x":1593413333000 + }, + { + "x":1593413334000 + }, + { + "x":1593413335000 + }, + { + "x":1593413336000 + }, + { + "x":1593413337000 + }, + { + "x":1593413338000 + }, + { + "x":1593413339000 + }, + { + "x":1593413340000 + } + ] + } +] \ No newline at end of file diff --git a/x-pack/test/apm_api_integration/basic/tests/transaction_groups/expectation/avg_duration_by_browser_transaction_name.json b/x-pack/test/apm_api_integration/basic/tests/transaction_groups/expectation/avg_duration_by_browser_transaction_name.json new file mode 100644 index 000000000000..107302831d55 --- /dev/null +++ b/x-pack/test/apm_api_integration/basic/tests/transaction_groups/expectation/avg_duration_by_browser_transaction_name.json @@ -0,0 +1,731 @@ +[ + { + "title":"HeadlessChrome", + "data":[ + { + "x":1593413100000 + }, + { + "x":1593413101000 + }, + { + "x":1593413102000 + }, + { + "x":1593413103000 + }, + { + "x":1593413104000 + }, + { + "x":1593413105000 + }, + { + "x":1593413106000 + }, + { + "x":1593413107000 + }, + { + "x":1593413108000 + }, + { + "x":1593413109000 + }, + { + "x":1593413110000 + }, + { + "x":1593413111000 + }, + { + "x":1593413112000 + }, + { + "x":1593413113000 + }, + { + "x":1593413114000 + }, + { + "x":1593413115000 + }, + { + "x":1593413116000 + }, + { + "x":1593413117000 + }, + { + "x":1593413118000 + }, + { + "x":1593413119000 + }, + { + "x":1593413120000 + }, + { + "x":1593413121000 + }, + { + "x":1593413122000 + }, + { + "x":1593413123000 + }, + { + "x":1593413124000 + }, + { + "x":1593413125000 + }, + { + "x":1593413126000 + }, + { + "x":1593413127000 + }, + { + "x":1593413128000 + }, + { + "x":1593413129000 + }, + { + "x":1593413130000 + }, + { + "x":1593413131000 + }, + { + "x":1593413132000 + }, + { + "x":1593413133000 + }, + { + "x":1593413134000 + }, + { + "x":1593413135000 + }, + { + "x":1593413136000 + }, + { + "x":1593413137000 + }, + { + "x":1593413138000 + }, + { + "x":1593413139000 + }, + { + "x":1593413140000 + }, + { + "x":1593413141000 + }, + { + "x":1593413142000 + }, + { + "x":1593413143000 + }, + { + "x":1593413144000 + }, + { + "x":1593413145000 + }, + { + "x":1593413146000 + }, + { + "x":1593413147000 + }, + { + "x":1593413148000 + }, + { + "x":1593413149000 + }, + { + "x":1593413150000 + }, + { + "x":1593413151000 + }, + { + "x":1593413152000 + }, + { + "x":1593413153000 + }, + { + "x":1593413154000 + }, + { + "x":1593413155000 + }, + { + "x":1593413156000 + }, + { + "x":1593413157000 + }, + { + "x":1593413158000 + }, + { + "x":1593413159000 + }, + { + "x":1593413160000 + }, + { + "x":1593413161000 + }, + { + "x":1593413162000 + }, + { + "x":1593413163000 + }, + { + "x":1593413164000 + }, + { + "x":1593413165000 + }, + { + "x":1593413166000 + }, + { + "x":1593413167000 + }, + { + "x":1593413168000 + }, + { + "x":1593413169000 + }, + { + "x":1593413170000 + }, + { + "x":1593413171000 + }, + { + "x":1593413172000 + }, + { + "x":1593413173000 + }, + { + "x":1593413174000 + }, + { + "x":1593413175000 + }, + { + "x":1593413176000 + }, + { + "x":1593413177000 + }, + { + "x":1593413178000 + }, + { + "x":1593413179000 + }, + { + "x":1593413180000 + }, + { + "x":1593413181000 + }, + { + "x":1593413182000 + }, + { + "x":1593413183000 + }, + { + "x":1593413184000 + }, + { + "x":1593413185000 + }, + { + "x":1593413186000 + }, + { + "x":1593413187000 + }, + { + "x":1593413188000 + }, + { + "x":1593413189000 + }, + { + "x":1593413190000 + }, + { + "x":1593413191000 + }, + { + "x":1593413192000 + }, + { + "x":1593413193000 + }, + { + "x":1593413194000 + }, + { + "x":1593413195000 + }, + { + "x":1593413196000 + }, + { + "x":1593413197000 + }, + { + "x":1593413198000 + }, + { + "x":1593413199000 + }, + { + "x":1593413200000 + }, + { + "x":1593413201000 + }, + { + "x":1593413202000 + }, + { + "x":1593413203000 + }, + { + "x":1593413204000 + }, + { + "x":1593413205000 + }, + { + "x":1593413206000 + }, + { + "x":1593413207000 + }, + { + "x":1593413208000 + }, + { + "x":1593413209000 + }, + { + "x":1593413210000 + }, + { + "x":1593413211000 + }, + { + "x":1593413212000 + }, + { + "x":1593413213000 + }, + { + "x":1593413214000 + }, + { + "x":1593413215000 + }, + { + "x":1593413216000 + }, + { + "x":1593413217000 + }, + { + "x":1593413218000 + }, + { + "x":1593413219000 + }, + { + "x":1593413220000 + }, + { + "x":1593413221000 + }, + { + "x":1593413222000 + }, + { + "x":1593413223000 + }, + { + "x":1593413224000 + }, + { + "x":1593413225000 + }, + { + "x":1593413226000 + }, + { + "x":1593413227000 + }, + { + "x":1593413228000 + }, + { + "x":1593413229000 + }, + { + "x":1593413230000 + }, + { + "x":1593413231000 + }, + { + "x":1593413232000 + }, + { + "x":1593413233000 + }, + { + "x":1593413234000 + }, + { + "x":1593413235000 + }, + { + "x":1593413236000 + }, + { + "x":1593413237000 + }, + { + "x":1593413238000 + }, + { + "x":1593413239000 + }, + { + "x":1593413240000 + }, + { + "x":1593413241000 + }, + { + "x":1593413242000 + }, + { + "x":1593413243000 + }, + { + "x":1593413244000 + }, + { + "x":1593413245000 + }, + { + "x":1593413246000 + }, + { + "x":1593413247000 + }, + { + "x":1593413248000 + }, + { + "x":1593413249000 + }, + { + "x":1593413250000 + }, + { + "x":1593413251000 + }, + { + "x":1593413252000 + }, + { + "x":1593413253000 + }, + { + "x":1593413254000 + }, + { + "x":1593413255000 + }, + { + "x":1593413256000 + }, + { + "x":1593413257000 + }, + { + "x":1593413258000 + }, + { + "x":1593413259000 + }, + { + "x":1593413260000 + }, + { + "x":1593413261000 + }, + { + "x":1593413262000 + }, + { + "x":1593413263000 + }, + { + "x":1593413264000 + }, + { + "x":1593413265000 + }, + { + "x":1593413266000 + }, + { + "x":1593413267000 + }, + { + "x":1593413268000 + }, + { + "x":1593413269000 + }, + { + "x":1593413270000 + }, + { + "x":1593413271000 + }, + { + "x":1593413272000 + }, + { + "x":1593413273000 + }, + { + "x":1593413274000 + }, + { + "x":1593413275000 + }, + { + "x":1593413276000 + }, + { + "x":1593413277000 + }, + { + "x":1593413278000 + }, + { + "x":1593413279000 + }, + { + "x":1593413280000 + }, + { + "x":1593413281000 + }, + { + "x":1593413282000 + }, + { + "x":1593413283000 + }, + { + "x":1593413284000 + }, + { + "x":1593413285000 + }, + { + "x":1593413286000 + }, + { + "x":1593413287000 + }, + { + "x":1593413288000 + }, + { + "x":1593413289000 + }, + { + "x":1593413290000 + }, + { + "x":1593413291000 + }, + { + "x":1593413292000 + }, + { + "x":1593413293000 + }, + { + "x":1593413294000 + }, + { + "x":1593413295000 + }, + { + "x":1593413296000 + }, + { + "x":1593413297000 + }, + { + "x":1593413298000 + }, + { + "x":1593413299000 + }, + { + "x":1593413300000 + }, + { + "x":1593413301000 + }, + { + "x":1593413302000 + }, + { + "x":1593413303000 + }, + { + "x":1593413304000 + }, + { + "x":1593413305000 + }, + { + "x":1593413306000 + }, + { + "x":1593413307000 + }, + { + "x":1593413308000 + }, + { + "x":1593413309000 + }, + { + "x":1593413310000 + }, + { + "x":1593413311000 + }, + { + "x":1593413312000 + }, + { + "x":1593413313000 + }, + { + "x":1593413314000 + }, + { + "x":1593413315000 + }, + { + "x":1593413316000 + }, + { + "x":1593413317000 + }, + { + "x":1593413318000 + }, + { + "x":1593413319000 + }, + { + "x":1593413320000 + }, + { + "x":1593413321000 + }, + { + "x":1593413322000 + }, + { + "x":1593413323000 + }, + { + "x":1593413324000 + }, + { + "x":1593413325000 + }, + { + "x":1593413326000 + }, + { + "x":1593413327000 + }, + { + "x":1593413328000, + "y":77000 + }, + { + "x":1593413329000 + }, + { + "x":1593413330000 + }, + { + "x":1593413331000 + }, + { + "x":1593413332000 + }, + { + "x":1593413333000 + }, + { + "x":1593413334000 + }, + { + "x":1593413335000 + }, + { + "x":1593413336000 + }, + { + "x":1593413337000 + }, + { + "x":1593413338000 + }, + { + "x":1593413339000 + }, + { + "x":1593413340000 + } + ] + } +] \ No newline at end of file diff --git a/x-pack/test/apm_api_integration/basic/tests/transaction_groups/expectation/breakdown.json b/x-pack/test/apm_api_integration/basic/tests/transaction_groups/expectation/breakdown.json new file mode 100644 index 000000000000..3b884a9eb790 --- /dev/null +++ b/x-pack/test/apm_api_integration/basic/tests/transaction_groups/expectation/breakdown.json @@ -0,0 +1,202 @@ +{ + "kpis":[ + { + "name":"app", + "percentage":0.16700861715223636, + "color":"#54b399" + }, + { + "name":"http", + "percentage":0.7702092736971686, + "color":"#6092c0" + }, + { + "name":"postgresql", + "percentage":0.0508822322527698, + "color":"#d36086" + }, + { + "name":"redis", + "percentage":0.011899876897825195, + "color":"#9170b8" + } + ], + "timeseries":[ + { + "title":"app", + "color":"#54b399", + "type":"areaStacked", + "data":[ + { + "x":1593413100000, + "y":null + }, + { + "x":1593413130000, + "y":null + }, + { + "x":1593413160000, + "y":null + }, + { + "x":1593413190000, + "y":null + }, + { + "x":1593413220000, + "y":null + }, + { + "x":1593413250000, + "y":null + }, + { + "x":1593413280000, + "y":null + }, + { + "x":1593413310000, + "y":0.16700861715223636 + }, + { + "x":1593413340000, + "y":null + } + ], + "hideLegend":true + }, + { + "title":"http", + "color":"#6092c0", + "type":"areaStacked", + "data":[ + { + "x":1593413100000, + "y":null + }, + { + "x":1593413130000, + "y":null + }, + { + "x":1593413160000, + "y":null + }, + { + "x":1593413190000, + "y":null + }, + { + "x":1593413220000, + "y":null + }, + { + "x":1593413250000, + "y":null + }, + { + "x":1593413280000, + "y":null + }, + { + "x":1593413310000, + "y":0.7702092736971686 + }, + { + "x":1593413340000, + "y":null + } + ], + "hideLegend":true + }, + { + "title":"postgresql", + "color":"#d36086", + "type":"areaStacked", + "data":[ + { + "x":1593413100000, + "y":null + }, + { + "x":1593413130000, + "y":null + }, + { + "x":1593413160000, + "y":null + }, + { + "x":1593413190000, + "y":null + }, + { + "x":1593413220000, + "y":null + }, + { + "x":1593413250000, + "y":null + }, + { + "x":1593413280000, + "y":null + }, + { + "x":1593413310000, + "y":0.0508822322527698 + }, + { + "x":1593413340000, + "y":null + } + ], + "hideLegend":true + }, + { + "title":"redis", + "color":"#9170b8", + "type":"areaStacked", + "data":[ + { + "x":1593413100000, + "y":null + }, + { + "x":1593413130000, + "y":null + }, + { + "x":1593413160000, + "y":null + }, + { + "x":1593413190000, + "y":null + }, + { + "x":1593413220000, + "y":null + }, + { + "x":1593413250000, + "y":null + }, + { + "x":1593413280000, + "y":null + }, + { + "x":1593413310000, + "y":0.011899876897825195 + }, + { + "x":1593413340000, + "y":null + } + ], + "hideLegend":true + } + ] +} \ No newline at end of file diff --git a/x-pack/test/apm_api_integration/basic/tests/transaction_groups/expectation/breakdown_transaction_name.json b/x-pack/test/apm_api_integration/basic/tests/transaction_groups/expectation/breakdown_transaction_name.json new file mode 100644 index 000000000000..b4f8e376d360 --- /dev/null +++ b/x-pack/test/apm_api_integration/basic/tests/transaction_groups/expectation/breakdown_transaction_name.json @@ -0,0 +1,55 @@ +{ + "kpis":[ + { + "name":"app", + "percentage":1, + "color":"#54b399" + } + ], + "timeseries":[ + { + "title":"app", + "color":"#54b399", + "type":"areaStacked", + "data":[ + { + "x":1593413100000, + "y":null + }, + { + "x":1593413130000, + "y":null + }, + { + "x":1593413160000, + "y":null + }, + { + "x":1593413190000, + "y":null + }, + { + "x":1593413220000, + "y":null + }, + { + "x":1593413250000, + "y":null + }, + { + "x":1593413280000, + "y":null + }, + { + "x":1593413310000, + "y":1 + }, + { + "x":1593413340000, + "y":null + } + ], + "hideLegend":true + } + ] +} \ No newline at end of file From 82dd173b2a0cd0f69d793001afe0ee045724f3d1 Mon Sep 17 00:00:00 2001 From: Poff Poffenberger Date: Wed, 22 Jul 2020 08:05:53 -0500 Subject: [PATCH 034/142] Use server basepath when creating reporting jobs (#72722) Co-authored-by: Elastic Machine --- .../reporting/server/export_types/png/create_job/index.ts | 3 +-- .../server/export_types/printable_pdf/create_job/index.ts | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/reporting/server/export_types/png/create_job/index.ts b/x-pack/plugins/reporting/server/export_types/png/create_job/index.ts index b63f2a09041b..9227354520b6 100644 --- a/x-pack/plugins/reporting/server/export_types/png/create_job/index.ts +++ b/x-pack/plugins/reporting/server/export_types/png/create_job/index.ts @@ -13,7 +13,6 @@ export const scheduleTaskFnFactory: ScheduleTaskFnFactory> = function createJobFactoryFn(reporting) { const config = reporting.getConfig(); - const setupDeps = reporting.getPluginSetupDeps(); const crypto = cryptoFactory(config.get('encryptionKey')); return async function scheduleTask( @@ -32,7 +31,7 @@ export const scheduleTaskFnFactory: ScheduleTaskFnFactory> = function createJobFactoryFn(reporting) { const config = reporting.getConfig(); - const setupDeps = reporting.getPluginSetupDeps(); const crypto = cryptoFactory(config.get('encryptionKey')); return async function scheduleTaskFn( @@ -26,7 +25,7 @@ export const scheduleTaskFnFactory: ScheduleTaskFnFactory Date: Wed, 22 Jul 2020 09:24:14 -0400 Subject: [PATCH 035/142] [Monitoring] Revert direct shipping code (#72505) * Backout these changes * Fix test --- .../plugins/monitoring/server/config.test.ts | 35 -------- x-pack/plugins/monitoring/server/config.ts | 2 - .../__tests__/bulk_uploader.js | 89 ------------------- .../server/kibana_monitoring/bulk_uploader.js | 25 +----- .../lib/send_bulk_payload.js | 56 +----------- 5 files changed, 5 insertions(+), 202 deletions(-) diff --git a/x-pack/plugins/monitoring/server/config.test.ts b/x-pack/plugins/monitoring/server/config.test.ts index 16d52f684109..32b8691bd604 100644 --- a/x-pack/plugins/monitoring/server/config.test.ts +++ b/x-pack/plugins/monitoring/server/config.test.ts @@ -27,33 +27,6 @@ describe('config schema', () => { }, "enabled": true, }, - "elasticsearch": Object { - "apiVersion": "master", - "customHeaders": Object {}, - "healthCheck": Object { - "delay": "PT2.5S", - }, - "ignoreVersionMismatch": false, - "logFetchCount": 10, - "logQueries": false, - "pingTimeout": "PT30S", - "preserveHost": true, - "requestHeadersWhitelist": Array [ - "authorization", - ], - "requestTimeout": "PT30S", - "shardTimeout": "PT30S", - "sniffInterval": false, - "sniffOnConnectionFault": false, - "sniffOnStart": false, - "ssl": Object { - "alwaysPresentCertificate": false, - "keystore": Object {}, - "truststore": Object {}, - "verificationMode": "full", - }, - "startupTimeout": "PT5S", - }, "enabled": true, "kibana": Object { "collection": Object { @@ -125,9 +98,6 @@ describe('createConfig()', () => { it('should wrap in Elasticsearch config', async () => { const config = createConfig( configSchema.validate({ - elasticsearch: { - hosts: 'http://localhost:9200', - }, ui: { elasticsearch: { hosts: 'http://localhost:9200', @@ -135,7 +105,6 @@ describe('createConfig()', () => { }, }) ); - expect(config.elasticsearch.hosts).toEqual(['http://localhost:9200']); expect(config.ui.elasticsearch.hosts).toEqual(['http://localhost:9200']); }); @@ -147,9 +116,6 @@ describe('createConfig()', () => { }; const config = createConfig( configSchema.validate({ - elasticsearch: { - ssl, - }, ui: { elasticsearch: { ssl, @@ -162,7 +128,6 @@ describe('createConfig()', () => { key: 'contents-of-packages/kbn-dev-utils/certs/elasticsearch.key', certificateAuthorities: ['contents-of-packages/kbn-dev-utils/certs/ca.crt'], }); - expect(config.elasticsearch.ssl).toEqual(expected); expect(config.ui.elasticsearch.ssl).toEqual(expected); }); }); diff --git a/x-pack/plugins/monitoring/server/config.ts b/x-pack/plugins/monitoring/server/config.ts index a430be8da6a5..789211c43db3 100644 --- a/x-pack/plugins/monitoring/server/config.ts +++ b/x-pack/plugins/monitoring/server/config.ts @@ -21,7 +21,6 @@ export const monitoringElasticsearchConfigSchema = elasticsearchConfigSchema.ext export const configSchema = schema.object({ enabled: schema.boolean({ defaultValue: true }), - elasticsearch: monitoringElasticsearchConfigSchema, ui: schema.object({ enabled: schema.boolean({ defaultValue: true }), ccs: schema.object({ @@ -86,7 +85,6 @@ export type MonitoringConfig = ReturnType; export function createConfig(config: TypeOf) { return { ...config, - elasticsearch: new ElasticsearchConfig(config.elasticsearch as ElasticsearchConfigType), ui: { ...config.ui, elasticsearch: new MonitoringElasticsearchConfig(config.ui.elasticsearch), diff --git a/x-pack/plugins/monitoring/server/kibana_monitoring/__tests__/bulk_uploader.js b/x-pack/plugins/monitoring/server/kibana_monitoring/__tests__/bulk_uploader.js index 3421f5d3830d..da12bde96609 100644 --- a/x-pack/plugins/monitoring/server/kibana_monitoring/__tests__/bulk_uploader.js +++ b/x-pack/plugins/monitoring/server/kibana_monitoring/__tests__/bulk_uploader.js @@ -6,10 +6,8 @@ import { noop } from 'lodash'; import sinon from 'sinon'; -import moment from 'moment'; import expect from '@kbn/expect'; import { BulkUploader } from '../bulk_uploader'; -import { MONITORING_SYSTEM_API_VERSION } from '../../../common/constants'; const FETCH_INTERVAL = 300; const CHECK_DELAY = 500; @@ -314,92 +312,5 @@ describe('BulkUploader', () => { done(); }, CHECK_DELAY); }); - - it('uses a direct connection to the monitoring cluster, when configured', (done) => { - const dateInIndex = '2020.02.10'; - const oldNow = moment.now; - moment.now = () => 1581310800000; - const prodClusterUuid = '1sdfd5'; - const prodCluster = { - callWithInternalUser: sinon - .stub() - .withArgs('monitoring.bulk') - .callsFake((arg) => { - let resolution = null; - if (arg === 'info') { - resolution = { cluster_uuid: prodClusterUuid }; - } - return new Promise((resolve) => resolve(resolution)); - }), - }; - const monitoringCluster = { - callWithInternalUser: sinon - .stub() - .withArgs('bulk') - .callsFake(() => { - return new Promise((resolve) => setTimeout(resolve, CHECK_DELAY + 1)); - }), - }; - - const collectorFetch = sinon.stub().returns({ - type: 'kibana_stats', - result: { type: 'kibana_stats', payload: { testData: 12345 } }, - }); - - const collectors = new MockCollectorSet(server, [ - { - fetch: collectorFetch, - isReady: () => true, - formatForBulkUpload: (result) => result, - isUsageCollector: false, - }, - ]); - const customServer = { - ...server, - elasticsearchPlugin: { - createCluster: () => monitoringCluster, - getCluster: (name) => { - if (name === 'admin' || name === 'data') { - return prodCluster; - } - return monitoringCluster; - }, - }, - config: { - get: (key) => { - if (key === 'monitoring.elasticsearch') { - return { - hosts: ['http://localhost:9200'], - username: 'tester', - password: 'testing', - ssl: {}, - }; - } - return null; - }, - }, - }; - const kbnServerStatus = { toJSON: () => ({ overall: { state: 'green' } }) }; - const kbnServerVersion = 'master'; - const uploader = new BulkUploader({ - ...customServer, - interval: FETCH_INTERVAL, - kbnServerStatus, - kbnServerVersion, - }); - uploader.start(collectors); - setTimeout(() => { - uploader.stop(); - const firstCallArgs = monitoringCluster.callWithInternalUser.firstCall.args; - expect(firstCallArgs[0]).to.be('bulk'); - expect(firstCallArgs[1].body[0].index._index).to.be( - `.monitoring-kibana-${MONITORING_SYSTEM_API_VERSION}-${dateInIndex}` - ); - expect(firstCallArgs[1].body[1].type).to.be('kibana_stats'); - expect(firstCallArgs[1].body[1].cluster_uuid).to.be(prodClusterUuid); - moment.now = oldNow; - done(); - }, CHECK_DELAY); - }); }); }); diff --git a/x-pack/plugins/monitoring/server/kibana_monitoring/bulk_uploader.js b/x-pack/plugins/monitoring/server/kibana_monitoring/bulk_uploader.js index 6035837bac85..b23b4fc88812 100644 --- a/x-pack/plugins/monitoring/server/kibana_monitoring/bulk_uploader.js +++ b/x-pack/plugins/monitoring/server/kibana_monitoring/bulk_uploader.js @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { defaultsDeep, uniq, compact, get } from 'lodash'; +import { defaultsDeep, uniq, compact } from 'lodash'; import { TELEMETRY_COLLECTION_INTERVAL, @@ -12,7 +12,6 @@ import { } from '../../common/constants'; import { sendBulkPayload, monitoringBulk } from './lib'; -import { hasMonitoringCluster } from '../es_client/instantiate_client'; /* * Handles internal Kibana stats collection and uploading data to Monitoring @@ -31,13 +30,11 @@ import { hasMonitoringCluster } from '../es_client/instantiate_client'; * @param {Object} xpackInfo server.plugins.xpack_main.info object */ export class BulkUploader { - constructor({ config, log, interval, elasticsearch, kibanaStats }) { + constructor({ log, interval, elasticsearch, kibanaStats }) { if (typeof interval !== 'number') { throw new Error('interval number of milliseconds is required'); } - this._hasDirectConnectionToMonitoringCluster = false; - this._productionClusterUuid = null; this._timer = null; // Hold sending and fetching usage until monitoring.bulk is successful. This means that we // send usage data on the second tick. But would save a lot of bandwidth fetching usage on @@ -54,15 +51,6 @@ export class BulkUploader { plugins: [monitoringBulk], }); - if (hasMonitoringCluster(config.elasticsearch)) { - this._log.info(`Detected direct connection to monitoring cluster`); - this._hasDirectConnectionToMonitoringCluster = true; - this._cluster = elasticsearch.legacy.createClient('monitoring-direct', config.elasticsearch); - elasticsearch.legacy.client.callAsInternalUser('info').then((data) => { - this._productionClusterUuid = get(data, 'cluster_uuid'); - }); - } - this.kibanaStats = kibanaStats; this.kibanaStatusGetter = null; } @@ -181,14 +169,7 @@ export class BulkUploader { } async _onPayload(payload) { - return await sendBulkPayload( - this._cluster, - this._interval, - payload, - this._log, - this._hasDirectConnectionToMonitoringCluster, - this._productionClusterUuid - ); + return await sendBulkPayload(this._cluster, this._interval, payload, this._log); } getKibanaStats(type) { diff --git a/x-pack/plugins/monitoring/server/kibana_monitoring/lib/send_bulk_payload.js b/x-pack/plugins/monitoring/server/kibana_monitoring/lib/send_bulk_payload.js index 9607b45d7e40..66799e4aa651 100644 --- a/x-pack/plugins/monitoring/server/kibana_monitoring/lib/send_bulk_payload.js +++ b/x-pack/plugins/monitoring/server/kibana_monitoring/lib/send_bulk_payload.js @@ -3,64 +3,12 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import moment from 'moment'; -import { chunk, get } from 'lodash'; -import { - MONITORING_SYSTEM_API_VERSION, - KIBANA_SYSTEM_ID, - KIBANA_STATS_TYPE_MONITORING, - KIBANA_SETTINGS_TYPE, -} from '../../../common/constants'; - -const SUPPORTED_TYPES = [KIBANA_STATS_TYPE_MONITORING, KIBANA_SETTINGS_TYPE]; -export function formatForNormalBulkEndpoint(payload, productionClusterUuid) { - const dateSuffix = moment.utc().format('YYYY.MM.DD'); - return chunk(payload, 2).reduce((accum, chunk) => { - const type = get(chunk[0], 'index._type'); - if (!type || !SUPPORTED_TYPES.includes(type)) { - return accum; - } - - const { timestamp } = chunk[1]; - - accum.push({ - index: { - _index: `.monitoring-kibana-${MONITORING_SYSTEM_API_VERSION}-${dateSuffix}`, - }, - }); - accum.push({ - [type]: chunk[1], - type, - timestamp, - cluster_uuid: productionClusterUuid, - }); - return accum; - }, []); -} +import { MONITORING_SYSTEM_API_VERSION, KIBANA_SYSTEM_ID } from '../../../common/constants'; /* * Send the Kibana usage data to the ES Monitoring Bulk endpoint */ -export async function sendBulkPayload( - cluster, - interval, - payload, - log, - hasDirectConnectionToMonitoringCluster = false, - productionClusterUuid = null -) { - if (hasDirectConnectionToMonitoringCluster) { - if (productionClusterUuid === null) { - log.warn( - `Unable to determine production cluster uuid to use for shipping monitoring data. Kibana monitoring data will appear in a standalone cluster in the Stack Monitoring UI.` - ); - } - const formattedPayload = formatForNormalBulkEndpoint(payload, productionClusterUuid); - return await cluster.callAsInternalUser('bulk', { - body: formattedPayload, - }); - } - +export async function sendBulkPayload(cluster, interval, payload) { return cluster.callAsInternalUser('monitoring.bulk', { system_id: KIBANA_SYSTEM_ID, system_api_version: MONITORING_SYSTEM_API_VERSION, From 4abe864f106b4f5fe623e546a4ffdf1277239f58 Mon Sep 17 00:00:00 2001 From: Gidi Meir Morris Date: Wed, 22 Jul 2020 14:45:57 +0100 Subject: [PATCH 036/142] Adds Role Based Access-Control to the Alerting & Action plugins based on Kibana Feature Controls (#67157) This PR adds _Role Based Access-Control_ to the Alerting framework & Actions feature using Kibana Feature Controls, addressing most of the Meta issue: https://github.com/elastic/kibana/issues/43994 This also closes https://github.com/elastic/kibana/issues/62438 This PR includes the following: 1. Adds `alerting` specific Security Actions (not to be confused with Alerting Actions) to the `security` plugin which allows us to assign alerting specific privileges to users of other plugins using the `features` plugin. 2. Removes the security wrapper from the savedObjectsClient in AlertsClient and instead plugs in the new AlertsAuthorization which performs the privilege checks on each api call made to the AlertsClient. 3. Adds privileges in each plugin that is already using the Alerting Framework which mirror (as closely as possible) the existing api-level tag-based privileges and plugs them into the AlertsClient. 4. Adds feature granted privileges arounds Actions (by relying on Saved Object privileges under the hood) and plugs them into the ActionsClient 5. Removes the legacy api-level tag-based privilege system from both the Alerts and Action HTTP APIs --- examples/alerting_example/kibana.json | 2 +- examples/alerting_example/server/plugin.ts | 38 +- x-pack/plugins/actions/kibana.json | 4 +- .../actions/server/actions_client.mock.ts | 1 + .../actions/server/actions_client.test.ts | 541 ++++++- .../plugins/actions/server/actions_client.ts | 53 +- .../actions_authorization.mock.ts | 22 + .../actions_authorization.test.ts | 191 +++ .../authorization/actions_authorization.ts | 59 + .../server/authorization/audit_logger.mock.ts | 22 + .../server/authorization/audit_logger.test.ts | 75 + .../server/authorization/audit_logger.ts | 66 + .../actions/server/create_execute_function.ts | 14 +- x-pack/plugins/actions/server/feature.ts | 41 + x-pack/plugins/actions/server/index.ts | 2 + .../server/lib/action_executor.test.ts | 64 +- .../actions/server/lib/action_executor.ts | 17 +- .../server/lib/task_runner_factory.test.ts | 3 +- .../actions/server/lib/task_runner_factory.ts | 7 +- x-pack/plugins/actions/server/mocks.ts | 5 + x-pack/plugins/actions/server/plugin.test.ts | 3 + x-pack/plugins/actions/server/plugin.ts | 97 +- .../actions/server/routes/create.test.ts | 7 - .../plugins/actions/server/routes/create.ts | 3 - .../actions/server/routes/delete.test.ts | 7 - .../plugins/actions/server/routes/delete.ts | 3 - .../actions/server/routes/execute.test.ts | 7 - .../plugins/actions/server/routes/execute.ts | 3 - .../plugins/actions/server/routes/get.test.ts | 7 - x-pack/plugins/actions/server/routes/get.ts | 3 - .../actions/server/routes/get_all.test.ts | 21 - .../plugins/actions/server/routes/get_all.ts | 3 - .../server/routes/list_action_types.test.ts | 38 +- .../server/routes/list_action_types.ts | 6 +- .../actions/server/routes/update.test.ts | 7 - .../plugins/actions/server/routes/update.ts | 3 - .../actions/server/saved_objects/index.ts | 11 +- .../plugins/alerting_builtins/common/index.ts | 7 + x-pack/plugins/alerting_builtins/kibana.json | 2 +- .../alert_types/index_threshold/alert_type.ts | 6 +- .../alerting_builtins/server/feature.ts | 49 + .../plugins/alerting_builtins/server/index.ts | 1 + .../alerting_builtins/server/plugin.test.ts | 12 +- .../alerting_builtins/server/plugin.ts | 8 +- .../plugins/alerting_builtins/server/types.ts | 2 + x-pack/plugins/alerts/README.md | 109 +- x-pack/plugins/alerts/common/index.ts | 1 + x-pack/plugins/alerts/kibana.json | 2 +- .../plugins/alerts/public/alert_api.test.ts | 8 +- .../alert_navigation_registry.test.ts | 2 +- .../alerts/server/alert_type_registry.test.ts | 80 +- .../alerts/server/alert_type_registry.ts | 57 +- .../alerts/server/alerts_client.mock.ts | 1 + .../alerts/server/alerts_client.test.ts | 1414 ++++++++++++++--- x-pack/plugins/alerts/server/alerts_client.ts | 291 +++- .../server/alerts_client_factory.test.ts | 119 +- .../alerts/server/alerts_client_factory.ts | 38 +- .../alerts_authorization.mock.ts | 25 + .../alerts_authorization.test.ts | 1256 +++++++++++++++ .../authorization/alerts_authorization.ts | 457 ++++++ .../server/authorization/audit_logger.mock.ts | 24 + .../server/authorization/audit_logger.test.ts | 311 ++++ .../server/authorization/audit_logger.ts | 117 ++ x-pack/plugins/alerts/server/index.ts | 2 +- .../lib/validate_alert_type_params.test.ts | 6 +- x-pack/plugins/alerts/server/plugin.test.ts | 34 + x-pack/plugins/alerts/server/plugin.ts | 36 +- .../alerts/server/routes/create.test.ts | 7 - x-pack/plugins/alerts/server/routes/create.ts | 3 - .../alerts/server/routes/delete.test.ts | 7 - x-pack/plugins/alerts/server/routes/delete.ts | 3 - .../alerts/server/routes/disable.test.ts | 7 - .../plugins/alerts/server/routes/disable.ts | 3 - .../alerts/server/routes/enable.test.ts | 7 - x-pack/plugins/alerts/server/routes/enable.ts | 3 - .../plugins/alerts/server/routes/find.test.ts | 7 - x-pack/plugins/alerts/server/routes/find.ts | 5 +- .../plugins/alerts/server/routes/get.test.ts | 7 - x-pack/plugins/alerts/server/routes/get.ts | 3 - .../server/routes/get_alert_state.test.ts | 21 - .../alerts/server/routes/get_alert_state.ts | 3 - .../server/routes/list_alert_types.test.ts | 66 +- .../alerts/server/routes/list_alert_types.ts | 5 +- .../alerts/server/routes/mute_all.test.ts | 7 - .../plugins/alerts/server/routes/mute_all.ts | 3 - .../server/routes/mute_instance.test.ts | 7 - .../alerts/server/routes/mute_instance.ts | 3 - .../alerts/server/routes/unmute_all.test.ts | 7 - .../alerts/server/routes/unmute_all.ts | 3 - .../server/routes/unmute_instance.test.ts | 7 - .../alerts/server/routes/unmute_instance.ts | 3 - .../alerts/server/routes/update.test.ts | 7 - x-pack/plugins/alerts/server/routes/update.ts | 3 - .../server/routes/update_api_key.test.ts | 7 - .../alerts/server/routes/update_api_key.ts | 3 - .../server/saved_objects/migrations.test.ts | 34 + .../alerts/server/saved_objects/migrations.ts | 26 +- .../create_execution_handler.test.ts | 2 +- .../server/task_runner/task_runner.test.ts | 114 +- .../alerts/server/task_runner/task_runner.ts | 85 +- .../task_runner/task_runner_factory.test.ts | 6 +- .../server/task_runner/task_runner_factory.ts | 4 +- x-pack/plugins/apm/server/feature.ts | 50 +- x-pack/plugins/features/common/feature.ts | 11 + .../common/feature_kibana_privileges.ts | 28 + .../__snapshots__/oss_features.test.ts.snap | 12 + .../features/server/feature_registry.test.ts | 162 ++ .../plugins/features/server/feature_schema.ts | 43 +- .../inventory/components/alert_flyout.tsx | 2 +- .../components/alert_flyout.tsx | 2 +- x-pack/plugins/infra/server/features.ts | 47 +- ...r_inventory_metric_threshold_alert_type.ts | 2 +- .../register_metric_threshold_alert_type.ts | 2 +- x-pack/plugins/monitoring/server/plugin.ts | 5 + .../__snapshots__/alerting.test.ts.snap | 37 + .../authorization/actions/actions.mock.ts | 35 + .../server/authorization/actions/actions.ts | 3 + .../authorization/actions/alerting.test.ts | 45 + .../server/authorization/actions/alerting.ts | 31 + .../disable_ui_capabilities.test.ts | 3 + .../server/authorization/index.mock.ts | 5 +- .../alerting.test.ts | 178 +++ .../feature_privilege_builder/alerting.ts | 42 + .../feature_privilege_builder/index.ts | 2 + .../feature_privilege_iterator.test.ts | 143 ++ .../feature_privilege_iterator.ts | 8 + .../authorization/privileges/privileges.ts | 1 - x-pack/plugins/security/server/mocks.ts | 1 + x-pack/plugins/security/server/plugin.test.ts | 4 + x-pack/plugins/security/server/plugin.ts | 6 +- .../notifications/create_notifications.ts | 4 +- .../detection_engine/rules/create_rules.ts | 4 +- .../security_solution/server/plugin.ts | 59 +- .../email/email_connector.test.tsx | 1 + .../email/email_connector.tsx | 8 +- .../es_index/es_index_connector.test.tsx | 1 + .../es_index/es_index_connector.tsx | 5 +- .../pagerduty/pagerduty_connectors.test.tsx | 1 + .../pagerduty/pagerduty_connectors.tsx | 4 +- .../servicenow/servicenow_connectors.test.tsx | 2 + .../servicenow/servicenow_connectors.tsx | 5 +- .../slack/slack_connectors.test.tsx | 1 + .../slack/slack_connectors.tsx | 3 +- .../webhook/webhook_connectors.test.tsx | 1 + .../webhook/webhook_connectors.tsx | 9 +- .../application/lib/action_variables.test.ts | 4 +- .../public/application/lib/alert_api.test.ts | 4 +- .../public/application/lib/capabilities.ts | 24 +- .../action_connector_form.test.tsx | 7 + .../action_connector_form.tsx | 9 +- .../action_connector_form/action_form.tsx | 93 +- .../connector_add_flyout.tsx | 1 + .../connector_add_modal.test.tsx | 8 +- .../connector_add_modal.tsx | 1 + .../connector_edit_flyout.tsx | 1 + .../actions_connectors_list.test.tsx | 42 +- .../components/actions_connectors_list.tsx | 54 +- .../components/alert_details.test.tsx | 187 ++- .../components/alert_details.tsx | 27 +- .../components/alert_instances.test.tsx | 7 +- .../components/alert_instances.tsx | 8 +- .../components/alert_instances_route.test.tsx | 6 +- .../components/alert_instances_route.tsx | 9 +- .../sections/alert_form/alert_add.test.tsx | 40 +- .../sections/alert_form/alert_add.tsx | 3 + .../sections/alert_form/alert_edit.tsx | 3 + .../sections/alert_form/alert_form.test.tsx | 61 +- .../sections/alert_form/alert_form.tsx | 86 +- .../components/alerts_list.test.tsx | 27 +- .../alerts_list/components/alerts_list.tsx | 96 +- .../components/collapsed_item_actions.tsx | 15 +- .../triggers_actions_ui/public/types.ts | 5 +- x-pack/plugins/uptime/server/kibana.index.ts | 44 +- .../actions_simulators/server/plugin.ts | 14 +- .../fixtures/plugins/alerts/kibana.json | 2 +- .../plugins/alerts/server/alert_types.ts | 18 +- .../fixtures/plugins/alerts/server/plugin.ts | 44 +- .../plugins/alerts_restricted/kibana.json | 10 + .../plugins/alerts_restricted/package.json | 20 + .../alerts_restricted/server/alert_types.ts | 33 + .../plugins/alerts_restricted/server/index.ts | 9 + .../alerts_restricted/server/plugin.ts | 62 + .../common/lib/alert_utils.ts | 20 +- .../common/lib/get_test_alert_data.ts | 2 +- .../common/lib/index.ts | 6 +- .../security_and_spaces/scenarios.ts | 96 +- .../tests/actions/create.ts | 49 +- .../tests/actions/delete.ts | 41 +- .../tests/actions/execute.ts | 70 +- .../security_and_spaces/tests/actions/get.ts | 31 +- .../tests/actions/get_all.ts | 30 +- .../tests/actions/list_action_types.ts | 10 +- .../tests/actions/update.ts | 69 +- .../tests/alerting/alerts.ts | 225 ++- .../tests/alerting/create.ts | 266 +++- .../tests/alerting/delete.ts | 221 ++- .../tests/alerting/disable.ts | 227 ++- .../tests/alerting/enable.ts | 239 ++- .../tests/alerting/find.ts | 215 ++- .../security_and_spaces/tests/alerting/get.ts | 188 ++- .../tests/alerting/get_alert_state.ts | 90 +- .../tests/alerting/index.ts | 2 +- .../tests/alerting/list_alert_types.ts | 124 +- .../tests/alerting/mute_all.ts | 237 ++- .../tests/alerting/mute_instance.ts | 251 ++- .../tests/alerting/unmute_all.ts | 252 ++- .../tests/alerting/unmute_instance.ts | 258 ++- .../tests/alerting/update.ts | 399 ++++- .../tests/alerting/update_api_key.ts | 251 ++- .../index_threshold/alert.ts | 2 +- .../spaces_only/tests/alerting/create.ts | 28 +- .../spaces_only/tests/alerting/find.ts | 2 +- .../spaces_only/tests/alerting/get.ts | 2 +- .../tests/alerting/get_alert_state.ts | 2 +- .../tests/alerting/list_alert_types.ts | 7 +- .../spaces_only/tests/alerting/migrations.ts | 9 + .../spaces_only/tests/alerting/update.ts | 2 +- .../apis/features/features/features.ts | 2 + .../apis/security/privileges.ts | 2 + .../apis/security/privileges_basic.ts | 2 + .../functional/es_archives/alerts/data.json | 42 + .../apps/triggers_actions_ui/alerts.ts | 4 +- .../fixtures/plugins/alerts/kibana.json | 2 +- .../fixtures/plugins/alerts/public/plugin.ts | 6 +- .../fixtures/plugins/alerts/server/plugin.ts | 36 +- .../services/alerting/alerts.ts | 6 +- 226 files changed, 10844 insertions(+), 1704 deletions(-) create mode 100644 x-pack/plugins/actions/server/authorization/actions_authorization.mock.ts create mode 100644 x-pack/plugins/actions/server/authorization/actions_authorization.test.ts create mode 100644 x-pack/plugins/actions/server/authorization/actions_authorization.ts create mode 100644 x-pack/plugins/actions/server/authorization/audit_logger.mock.ts create mode 100644 x-pack/plugins/actions/server/authorization/audit_logger.test.ts create mode 100644 x-pack/plugins/actions/server/authorization/audit_logger.ts create mode 100644 x-pack/plugins/actions/server/feature.ts create mode 100644 x-pack/plugins/alerting_builtins/common/index.ts create mode 100644 x-pack/plugins/alerting_builtins/server/feature.ts create mode 100644 x-pack/plugins/alerts/server/authorization/alerts_authorization.mock.ts create mode 100644 x-pack/plugins/alerts/server/authorization/alerts_authorization.test.ts create mode 100644 x-pack/plugins/alerts/server/authorization/alerts_authorization.ts create mode 100644 x-pack/plugins/alerts/server/authorization/audit_logger.mock.ts create mode 100644 x-pack/plugins/alerts/server/authorization/audit_logger.test.ts create mode 100644 x-pack/plugins/alerts/server/authorization/audit_logger.ts create mode 100644 x-pack/plugins/security/server/authorization/actions/__snapshots__/alerting.test.ts.snap create mode 100644 x-pack/plugins/security/server/authorization/actions/actions.mock.ts create mode 100644 x-pack/plugins/security/server/authorization/actions/alerting.test.ts create mode 100644 x-pack/plugins/security/server/authorization/actions/alerting.ts create mode 100644 x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.test.ts create mode 100644 x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.ts create mode 100644 x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts_restricted/kibana.json create mode 100644 x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts_restricted/package.json create mode 100644 x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts_restricted/server/alert_types.ts create mode 100644 x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts_restricted/server/index.ts create mode 100644 x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts_restricted/server/plugin.ts diff --git a/examples/alerting_example/kibana.json b/examples/alerting_example/kibana.json index 6c04218ca45e..a2691c5fdcab 100644 --- a/examples/alerting_example/kibana.json +++ b/examples/alerting_example/kibana.json @@ -4,6 +4,6 @@ "kibanaVersion": "kibana", "server": true, "ui": true, - "requiredPlugins": ["triggers_actions_ui", "charts", "data", "alerts", "actions", "developerExamples"], + "requiredPlugins": ["triggers_actions_ui", "charts", "data", "alerts", "actions", "features", "developerExamples"], "optionalPlugins": [] } diff --git a/examples/alerting_example/server/plugin.ts b/examples/alerting_example/server/plugin.ts index cdb005feca35..49352cc28569 100644 --- a/examples/alerting_example/server/plugin.ts +++ b/examples/alerting_example/server/plugin.ts @@ -18,20 +18,56 @@ */ import { Plugin, CoreSetup } from 'kibana/server'; +import { i18n } from '@kbn/i18n'; import { PluginSetupContract as AlertingSetup } from '../../../x-pack/plugins/alerts/server'; +import { PluginSetupContract as FeaturesPluginSetup } from '../../../x-pack/plugins/features/server'; import { alertType as alwaysFiringAlert } from './alert_types/always_firing'; import { alertType as peopleInSpaceAlert } from './alert_types/astros'; +import { INDEX_THRESHOLD_ID } from '../../../x-pack/plugins/alerting_builtins/server'; +import { ALERTING_EXAMPLE_APP_ID } from '../common/constants'; // this plugin's dependendencies export interface AlertingExampleDeps { alerts: AlertingSetup; + features: FeaturesPluginSetup; } export class AlertingExamplePlugin implements Plugin { - public setup(core: CoreSetup, { alerts }: AlertingExampleDeps) { + public setup(core: CoreSetup, { alerts, features }: AlertingExampleDeps) { alerts.registerType(alwaysFiringAlert); alerts.registerType(peopleInSpaceAlert); + + features.registerFeature({ + id: ALERTING_EXAMPLE_APP_ID, + name: i18n.translate('alertsExample.featureRegistry.alertsExampleFeatureName', { + defaultMessage: 'Alerts Example', + }), + app: [], + alerting: [alwaysFiringAlert.id, peopleInSpaceAlert.id, INDEX_THRESHOLD_ID], + privileges: { + all: { + alerting: { + all: [alwaysFiringAlert.id, peopleInSpaceAlert.id, INDEX_THRESHOLD_ID], + }, + savedObject: { + all: [], + read: [], + }, + ui: ['alerting:show'], + }, + read: { + alerting: { + read: [alwaysFiringAlert.id, peopleInSpaceAlert.id, INDEX_THRESHOLD_ID], + }, + savedObject: { + all: [], + read: [], + }, + ui: ['alerting:show'], + }, + }, + }); } public start() {} diff --git a/x-pack/plugins/actions/kibana.json b/x-pack/plugins/actions/kibana.json index 14ddb8257ff3..ef604a9cf641 100644 --- a/x-pack/plugins/actions/kibana.json +++ b/x-pack/plugins/actions/kibana.json @@ -4,7 +4,7 @@ "version": "8.0.0", "kibanaVersion": "kibana", "configPath": ["xpack", "actions"], - "requiredPlugins": ["licensing", "taskManager", "encryptedSavedObjects", "eventLog"], - "optionalPlugins": ["usageCollection", "spaces"], + "requiredPlugins": ["licensing", "taskManager", "encryptedSavedObjects", "eventLog", "features"], + "optionalPlugins": ["usageCollection", "spaces", "security"], "ui": false } diff --git a/x-pack/plugins/actions/server/actions_client.mock.ts b/x-pack/plugins/actions/server/actions_client.mock.ts index efd044c7e249..48122a5ce4e0 100644 --- a/x-pack/plugins/actions/server/actions_client.mock.ts +++ b/x-pack/plugins/actions/server/actions_client.mock.ts @@ -19,6 +19,7 @@ const createActionsClientMock = () => { getBulk: jest.fn(), execute: jest.fn(), enqueueExecution: jest.fn(), + listTypes: jest.fn(), }; return mocked; }; diff --git a/x-pack/plugins/actions/server/actions_client.test.ts b/x-pack/plugins/actions/server/actions_client.test.ts index 807d75cd0d70..90b989ac3b52 100644 --- a/x-pack/plugins/actions/server/actions_client.test.ts +++ b/x-pack/plugins/actions/server/actions_client.test.ts @@ -22,11 +22,14 @@ import { import { actionExecutorMock } from './lib/action_executor.mock'; import uuid from 'uuid'; import { KibanaRequest } from 'kibana/server'; +import { ActionsAuthorization } from './authorization/actions_authorization'; +import { actionsAuthorizationMock } from './authorization/actions_authorization.mock'; const defaultKibanaIndex = '.kibana'; -const savedObjectsClient = savedObjectsClientMock.create(); +const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); const scopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); const actionExecutor = actionExecutorMock.create(); +const authorization = actionsAuthorizationMock.create(); const executionEnqueuer = jest.fn(); const request = {} as KibanaRequest; @@ -55,17 +58,88 @@ beforeEach(() => { actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams); actionsClient = new ActionsClient({ actionTypeRegistry, - savedObjectsClient, + unsecuredSavedObjectsClient, scopedClusterClient, defaultKibanaIndex, preconfiguredActions: [], actionExecutor, executionEnqueuer, request, + authorization: (authorization as unknown) as ActionsAuthorization, }); }); describe('create()', () => { + describe('authorization', () => { + test('ensures user is authorised to create this type of action', async () => { + const savedObjectCreateResult = { + id: '1', + type: 'action', + attributes: { + name: 'my name', + actionTypeId: 'my-action-type', + config: {}, + }, + references: [], + }; + actionTypeRegistry.register({ + id: 'my-action-type', + name: 'My action type', + minimumLicenseRequired: 'basic', + executor, + }); + unsecuredSavedObjectsClient.create.mockResolvedValueOnce(savedObjectCreateResult); + + await actionsClient.create({ + action: { + name: 'my name', + actionTypeId: 'my-action-type', + config: {}, + secrets: {}, + }, + }); + + expect(authorization.ensureAuthorized).toHaveBeenCalledWith('create', 'my-action-type'); + }); + + test('throws when user is not authorised to create this type of action', async () => { + const savedObjectCreateResult = { + id: '1', + type: 'action', + attributes: { + name: 'my name', + actionTypeId: 'my-action-type', + config: {}, + }, + references: [], + }; + actionTypeRegistry.register({ + id: 'my-action-type', + name: 'My action type', + minimumLicenseRequired: 'basic', + executor, + }); + unsecuredSavedObjectsClient.create.mockResolvedValueOnce(savedObjectCreateResult); + + authorization.ensureAuthorized.mockRejectedValue( + new Error(`Unauthorized to create a "my-action-type" action`) + ); + + await expect( + actionsClient.create({ + action: { + name: 'my name', + actionTypeId: 'my-action-type', + config: {}, + secrets: {}, + }, + }) + ).rejects.toMatchInlineSnapshot(`[Error: Unauthorized to create a "my-action-type" action]`); + + expect(authorization.ensureAuthorized).toHaveBeenCalledWith('create', 'my-action-type'); + }); + }); + test('creates an action with all given properties', async () => { const savedObjectCreateResult = { id: '1', @@ -83,7 +157,7 @@ describe('create()', () => { minimumLicenseRequired: 'basic', executor, }); - savedObjectsClient.create.mockResolvedValueOnce(savedObjectCreateResult); + unsecuredSavedObjectsClient.create.mockResolvedValueOnce(savedObjectCreateResult); const result = await actionsClient.create({ action: { name: 'my name', @@ -99,8 +173,8 @@ describe('create()', () => { actionTypeId: 'my-action-type', config: {}, }); - expect(savedObjectsClient.create).toHaveBeenCalledTimes(1); - expect(savedObjectsClient.create.mock.calls[0]).toMatchInlineSnapshot(` + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(1); + expect(unsecuredSavedObjectsClient.create.mock.calls[0]).toMatchInlineSnapshot(` Array [ "action", Object { @@ -161,7 +235,7 @@ describe('create()', () => { minimumLicenseRequired: 'basic', executor, }); - savedObjectsClient.create.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: '1', type: 'type', attributes: { @@ -199,8 +273,8 @@ describe('create()', () => { c: true, }, }); - expect(savedObjectsClient.create).toHaveBeenCalledTimes(1); - expect(savedObjectsClient.create.mock.calls[0]).toMatchInlineSnapshot(` + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(1); + expect(unsecuredSavedObjectsClient.create.mock.calls[0]).toMatchInlineSnapshot(` Array [ "action", Object { @@ -237,13 +311,14 @@ describe('create()', () => { actionTypeRegistry = new ActionTypeRegistry(localActionTypeRegistryParams); actionsClient = new ActionsClient({ actionTypeRegistry, - savedObjectsClient, + unsecuredSavedObjectsClient, scopedClusterClient, defaultKibanaIndex, preconfiguredActions: [], actionExecutor, executionEnqueuer, request, + authorization: (authorization as unknown) as ActionsAuthorization, }); const savedObjectCreateResult = { @@ -262,7 +337,7 @@ describe('create()', () => { minimumLicenseRequired: 'basic', executor, }); - savedObjectsClient.create.mockResolvedValueOnce(savedObjectCreateResult); + unsecuredSavedObjectsClient.create.mockResolvedValueOnce(savedObjectCreateResult); await expect( actionsClient.create({ @@ -298,7 +373,7 @@ describe('create()', () => { mockedLicenseState.ensureLicenseForActionType.mockImplementation(() => { throw new Error('Fail'); }); - savedObjectsClient.create.mockResolvedValueOnce(savedObjectCreateResult); + unsecuredSavedObjectsClient.create.mockResolvedValueOnce(savedObjectCreateResult); await expect( actionsClient.create({ action: { @@ -313,8 +388,118 @@ describe('create()', () => { }); describe('get()', () => { - test('calls savedObjectsClient with id', async () => { - savedObjectsClient.get.mockResolvedValueOnce({ + describe('authorization', () => { + test('ensures user is authorised to get the type of action', async () => { + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: 'type', + attributes: { + name: 'my name', + actionTypeId: 'my-action-type', + config: {}, + }, + references: [], + }); + + await actionsClient.get({ id: '1' }); + + expect(authorization.ensureAuthorized).toHaveBeenCalledWith('get'); + }); + + test('ensures user is authorised to get preconfigured type of action', async () => { + actionsClient = new ActionsClient({ + actionTypeRegistry, + unsecuredSavedObjectsClient, + scopedClusterClient, + defaultKibanaIndex, + actionExecutor, + executionEnqueuer, + request, + authorization: (authorization as unknown) as ActionsAuthorization, + preconfiguredActions: [ + { + id: 'testPreconfigured', + actionTypeId: 'my-action-type', + secrets: { + test: 'test1', + }, + isPreconfigured: true, + name: 'test', + config: { + foo: 'bar', + }, + }, + ], + }); + + await actionsClient.get({ id: 'testPreconfigured' }); + + expect(authorization.ensureAuthorized).toHaveBeenCalledWith('get'); + }); + + test('throws when user is not authorised to create the type of action', async () => { + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: 'type', + attributes: { + name: 'my name', + actionTypeId: 'my-action-type', + config: {}, + }, + references: [], + }); + + authorization.ensureAuthorized.mockRejectedValue( + new Error(`Unauthorized to get a "my-action-type" action`) + ); + + await expect(actionsClient.get({ id: '1' })).rejects.toMatchInlineSnapshot( + `[Error: Unauthorized to get a "my-action-type" action]` + ); + + expect(authorization.ensureAuthorized).toHaveBeenCalledWith('get'); + }); + + test('throws when user is not authorised to create preconfigured of action', async () => { + actionsClient = new ActionsClient({ + actionTypeRegistry, + unsecuredSavedObjectsClient, + scopedClusterClient, + defaultKibanaIndex, + actionExecutor, + executionEnqueuer, + request, + authorization: (authorization as unknown) as ActionsAuthorization, + preconfiguredActions: [ + { + id: 'testPreconfigured', + actionTypeId: 'my-action-type', + secrets: { + test: 'test1', + }, + isPreconfigured: true, + name: 'test', + config: { + foo: 'bar', + }, + }, + ], + }); + + authorization.ensureAuthorized.mockRejectedValue( + new Error(`Unauthorized to get a "my-action-type" action`) + ); + + await expect(actionsClient.get({ id: 'testPreconfigured' })).rejects.toMatchInlineSnapshot( + `[Error: Unauthorized to get a "my-action-type" action]` + ); + + expect(authorization.ensureAuthorized).toHaveBeenCalledWith('get'); + }); + }); + + test('calls unsecuredSavedObjectsClient with id', async () => { + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ id: '1', type: 'type', attributes: {}, @@ -325,8 +510,8 @@ describe('get()', () => { id: '1', isPreconfigured: false, }); - expect(savedObjectsClient.get).toHaveBeenCalledTimes(1); - expect(savedObjectsClient.get.mock.calls[0]).toMatchInlineSnapshot(` + expect(unsecuredSavedObjectsClient.get).toHaveBeenCalledTimes(1); + expect(unsecuredSavedObjectsClient.get.mock.calls[0]).toMatchInlineSnapshot(` Array [ "action", "1", @@ -337,12 +522,13 @@ describe('get()', () => { test('return predefined action with id', async () => { actionsClient = new ActionsClient({ actionTypeRegistry, - savedObjectsClient, + unsecuredSavedObjectsClient, scopedClusterClient, defaultKibanaIndex, actionExecutor, executionEnqueuer, request, + authorization: (authorization as unknown) as ActionsAuthorization, preconfiguredActions: [ { id: 'testPreconfigured', @@ -366,12 +552,84 @@ describe('get()', () => { isPreconfigured: true, name: 'test', }); - expect(savedObjectsClient.get).not.toHaveBeenCalled(); + expect(unsecuredSavedObjectsClient.get).not.toHaveBeenCalled(); }); }); describe('getAll()', () => { - test('calls savedObjectsClient with parameters', async () => { + describe('authorization', () => { + function getAllOperation(): ReturnType { + const expectedResult = { + total: 1, + per_page: 10, + page: 1, + saved_objects: [ + { + id: '1', + type: 'type', + attributes: { + name: 'test', + config: { + foo: 'bar', + }, + }, + score: 1, + references: [], + }, + ], + }; + unsecuredSavedObjectsClient.find.mockResolvedValueOnce(expectedResult); + scopedClusterClient.callAsInternalUser.mockResolvedValueOnce({ + aggregations: { + '1': { doc_count: 6 }, + testPreconfigured: { doc_count: 2 }, + }, + }); + + actionsClient = new ActionsClient({ + actionTypeRegistry, + unsecuredSavedObjectsClient, + scopedClusterClient, + defaultKibanaIndex, + actionExecutor, + executionEnqueuer, + request, + authorization: (authorization as unknown) as ActionsAuthorization, + preconfiguredActions: [ + { + id: 'testPreconfigured', + actionTypeId: '.slack', + secrets: {}, + isPreconfigured: true, + name: 'test', + config: { + foo: 'bar', + }, + }, + ], + }); + return actionsClient.getAll(); + } + + test('ensures user is authorised to get the type of action', async () => { + await getAllOperation(); + expect(authorization.ensureAuthorized).toHaveBeenCalledWith('get'); + }); + + test('throws when user is not authorised to create the type of action', async () => { + authorization.ensureAuthorized.mockRejectedValue( + new Error(`Unauthorized to get all actions`) + ); + + await expect(getAllOperation()).rejects.toMatchInlineSnapshot( + `[Error: Unauthorized to get all actions]` + ); + + expect(authorization.ensureAuthorized).toHaveBeenCalledWith('get'); + }); + }); + + test('calls unsecuredSavedObjectsClient with parameters', async () => { const expectedResult = { total: 1, per_page: 10, @@ -391,7 +649,7 @@ describe('getAll()', () => { }, ], }; - savedObjectsClient.find.mockResolvedValueOnce(expectedResult); + unsecuredSavedObjectsClient.find.mockResolvedValueOnce(expectedResult); scopedClusterClient.callAsInternalUser.mockResolvedValueOnce({ aggregations: { '1': { doc_count: 6 }, @@ -401,12 +659,13 @@ describe('getAll()', () => { actionsClient = new ActionsClient({ actionTypeRegistry, - savedObjectsClient, + unsecuredSavedObjectsClient, scopedClusterClient, defaultKibanaIndex, actionExecutor, executionEnqueuer, request, + authorization: (authorization as unknown) as ActionsAuthorization, preconfiguredActions: [ { id: 'testPreconfigured', @@ -443,8 +702,76 @@ describe('getAll()', () => { }); describe('getBulk()', () => { - test('calls getBulk savedObjectsClient with parameters', async () => { - savedObjectsClient.bulkGet.mockResolvedValueOnce({ + describe('authorization', () => { + function getBulkOperation(): ReturnType { + unsecuredSavedObjectsClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [ + { + id: '1', + type: 'action', + attributes: { + actionTypeId: 'test', + name: 'test', + config: { + foo: 'bar', + }, + }, + references: [], + }, + ], + }); + scopedClusterClient.callAsInternalUser.mockResolvedValueOnce({ + aggregations: { + '1': { doc_count: 6 }, + testPreconfigured: { doc_count: 2 }, + }, + }); + + actionsClient = new ActionsClient({ + actionTypeRegistry, + unsecuredSavedObjectsClient, + scopedClusterClient, + defaultKibanaIndex, + actionExecutor, + executionEnqueuer, + request, + authorization: (authorization as unknown) as ActionsAuthorization, + preconfiguredActions: [ + { + id: 'testPreconfigured', + actionTypeId: '.slack', + secrets: {}, + isPreconfigured: true, + name: 'test', + config: { + foo: 'bar', + }, + }, + ], + }); + return actionsClient.getBulk(['1', 'testPreconfigured']); + } + + test('ensures user is authorised to get the type of action', async () => { + await getBulkOperation(); + expect(authorization.ensureAuthorized).toHaveBeenCalledWith('get'); + }); + + test('throws when user is not authorised to create the type of action', async () => { + authorization.ensureAuthorized.mockRejectedValue( + new Error(`Unauthorized to get all actions`) + ); + + await expect(getBulkOperation()).rejects.toMatchInlineSnapshot( + `[Error: Unauthorized to get all actions]` + ); + + expect(authorization.ensureAuthorized).toHaveBeenCalledWith('get'); + }); + }); + + test('calls getBulk unsecuredSavedObjectsClient with parameters', async () => { + unsecuredSavedObjectsClient.bulkGet.mockResolvedValueOnce({ saved_objects: [ { id: '1', @@ -469,12 +796,13 @@ describe('getBulk()', () => { actionsClient = new ActionsClient({ actionTypeRegistry, - savedObjectsClient, + unsecuredSavedObjectsClient, scopedClusterClient, defaultKibanaIndex, actionExecutor, executionEnqueuer, request, + authorization: (authorization as unknown) as ActionsAuthorization, preconfiguredActions: [ { id: 'testPreconfigured', @@ -514,13 +842,32 @@ describe('getBulk()', () => { }); describe('delete()', () => { - test('calls savedObjectsClient with id', async () => { + describe('authorization', () => { + test('ensures user is authorised to delete actions', async () => { + await actionsClient.delete({ id: '1' }); + expect(authorization.ensureAuthorized).toHaveBeenCalledWith('delete'); + }); + + test('throws when user is not authorised to create the type of action', async () => { + authorization.ensureAuthorized.mockRejectedValue( + new Error(`Unauthorized to delete all actions`) + ); + + await expect(actionsClient.delete({ id: '1' })).rejects.toMatchInlineSnapshot( + `[Error: Unauthorized to delete all actions]` + ); + + expect(authorization.ensureAuthorized).toHaveBeenCalledWith('delete'); + }); + }); + + test('calls unsecuredSavedObjectsClient with id', async () => { const expectedResult = Symbol(); - savedObjectsClient.delete.mockResolvedValueOnce(expectedResult); + unsecuredSavedObjectsClient.delete.mockResolvedValueOnce(expectedResult); const result = await actionsClient.delete({ id: '1' }); expect(result).toEqual(expectedResult); - expect(savedObjectsClient.delete).toHaveBeenCalledTimes(1); - expect(savedObjectsClient.delete.mock.calls[0]).toMatchInlineSnapshot(` + expect(unsecuredSavedObjectsClient.delete).toHaveBeenCalledTimes(1); + expect(unsecuredSavedObjectsClient.delete.mock.calls[0]).toMatchInlineSnapshot(` Array [ "action", "1", @@ -530,6 +877,60 @@ describe('delete()', () => { }); describe('update()', () => { + describe('authorization', () => { + function updateOperation(): ReturnType { + actionTypeRegistry.register({ + id: 'my-action-type', + name: 'My action type', + minimumLicenseRequired: 'basic', + executor, + }); + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: 'action', + attributes: { + actionTypeId: 'my-action-type', + }, + references: [], + }); + unsecuredSavedObjectsClient.update.mockResolvedValueOnce({ + id: 'my-action', + type: 'action', + attributes: { + actionTypeId: 'my-action-type', + name: 'my name', + config: {}, + secrets: {}, + }, + references: [], + }); + return actionsClient.update({ + id: 'my-action', + action: { + name: 'my name', + config: {}, + secrets: {}, + }, + }); + } + test('ensures user is authorised to update actions', async () => { + await updateOperation(); + expect(authorization.ensureAuthorized).toHaveBeenCalledWith('update'); + }); + + test('throws when user is not authorised to create the type of action', async () => { + authorization.ensureAuthorized.mockRejectedValue( + new Error(`Unauthorized to update all actions`) + ); + + await expect(updateOperation()).rejects.toMatchInlineSnapshot( + `[Error: Unauthorized to update all actions]` + ); + + expect(authorization.ensureAuthorized).toHaveBeenCalledWith('update'); + }); + }); + test('updates an action with all given properties', async () => { actionTypeRegistry.register({ id: 'my-action-type', @@ -537,7 +938,7 @@ describe('update()', () => { minimumLicenseRequired: 'basic', executor, }); - savedObjectsClient.get.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ id: '1', type: 'action', attributes: { @@ -545,7 +946,7 @@ describe('update()', () => { }, references: [], }); - savedObjectsClient.update.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.update.mockResolvedValueOnce({ id: 'my-action', type: 'action', attributes: { @@ -571,8 +972,8 @@ describe('update()', () => { name: 'my name', config: {}, }); - expect(savedObjectsClient.update).toHaveBeenCalledTimes(1); - expect(savedObjectsClient.update.mock.calls[0]).toMatchInlineSnapshot(` + expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledTimes(1); + expect(unsecuredSavedObjectsClient.update.mock.calls[0]).toMatchInlineSnapshot(` Array [ "action", "my-action", @@ -584,8 +985,8 @@ describe('update()', () => { }, ] `); - expect(savedObjectsClient.get).toHaveBeenCalledTimes(1); - expect(savedObjectsClient.get.mock.calls[0]).toMatchInlineSnapshot(` + expect(unsecuredSavedObjectsClient.get).toHaveBeenCalledTimes(1); + expect(unsecuredSavedObjectsClient.get.mock.calls[0]).toMatchInlineSnapshot(` Array [ "action", "my-action", @@ -605,7 +1006,7 @@ describe('update()', () => { }, executor, }); - savedObjectsClient.get.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ id: 'my-action', type: 'action', attributes: { @@ -634,7 +1035,7 @@ describe('update()', () => { minimumLicenseRequired: 'basic', executor, }); - savedObjectsClient.get.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ id: 'my-action', type: 'action', attributes: { @@ -642,7 +1043,7 @@ describe('update()', () => { }, references: [], }); - savedObjectsClient.update.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.update.mockResolvedValueOnce({ id: 'my-action', type: 'action', attributes: { @@ -680,8 +1081,8 @@ describe('update()', () => { c: true, }, }); - expect(savedObjectsClient.update).toHaveBeenCalledTimes(1); - expect(savedObjectsClient.update.mock.calls[0]).toMatchInlineSnapshot(` + expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledTimes(1); + expect(unsecuredSavedObjectsClient.update.mock.calls[0]).toMatchInlineSnapshot(` Array [ "action", "my-action", @@ -709,7 +1110,7 @@ describe('update()', () => { mockedLicenseState.ensureLicenseForActionType.mockImplementation(() => { throw new Error('Fail'); }); - savedObjectsClient.get.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ id: '1', type: 'action', attributes: { @@ -717,7 +1118,7 @@ describe('update()', () => { }, references: [], }); - savedObjectsClient.update.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.update.mockResolvedValueOnce({ id: 'my-action', type: 'action', attributes: { @@ -742,6 +1143,35 @@ describe('update()', () => { }); describe('execute()', () => { + describe('authorization', () => { + test('ensures user is authorised to excecute actions', async () => { + await actionsClient.execute({ + actionId: 'action-id', + params: { + name: 'my name', + }, + }); + expect(authorization.ensureAuthorized).toHaveBeenCalledWith('execute'); + }); + + test('throws when user is not authorised to create the type of action', async () => { + authorization.ensureAuthorized.mockRejectedValue( + new Error(`Unauthorized to execute all actions`) + ); + + await expect( + actionsClient.execute({ + actionId: 'action-id', + params: { + name: 'my name', + }, + }) + ).rejects.toMatchInlineSnapshot(`[Error: Unauthorized to execute all actions]`); + + expect(authorization.ensureAuthorized).toHaveBeenCalledWith('execute'); + }); + }); + test('calls the actionExecutor with the appropriate parameters', async () => { const actionId = uuid.v4(); actionExecutor.execute.mockResolvedValue({ status: 'ok', actionId }); @@ -765,6 +1195,35 @@ describe('execute()', () => { }); describe('enqueueExecution()', () => { + describe('authorization', () => { + test('ensures user is authorised to excecute actions', async () => { + await actionsClient.enqueueExecution({ + id: uuid.v4(), + params: {}, + spaceId: 'default', + apiKey: null, + }); + expect(authorization.ensureAuthorized).toHaveBeenCalledWith('execute'); + }); + + test('throws when user is not authorised to create the type of action', async () => { + authorization.ensureAuthorized.mockRejectedValue( + new Error(`Unauthorized to execute all actions`) + ); + + await expect( + actionsClient.enqueueExecution({ + id: uuid.v4(), + params: {}, + spaceId: 'default', + apiKey: null, + }) + ).rejects.toMatchInlineSnapshot(`[Error: Unauthorized to execute all actions]`); + + expect(authorization.ensureAuthorized).toHaveBeenCalledWith('execute'); + }); + }); + test('calls the executionEnqueuer with the appropriate parameters', async () => { const opts = { id: uuid.v4(), @@ -774,6 +1233,6 @@ describe('enqueueExecution()', () => { }; await expect(actionsClient.enqueueExecution(opts)).resolves.toMatchInlineSnapshot(`undefined`); - expect(executionEnqueuer).toHaveBeenCalledWith(savedObjectsClient, opts); + expect(executionEnqueuer).toHaveBeenCalledWith(unsecuredSavedObjectsClient, opts); }); }); diff --git a/x-pack/plugins/actions/server/actions_client.ts b/x-pack/plugins/actions/server/actions_client.ts index 44f9cfd5c9e6..6744a8d11162 100644 --- a/x-pack/plugins/actions/server/actions_client.ts +++ b/x-pack/plugins/actions/server/actions_client.ts @@ -28,6 +28,8 @@ import { ExecutionEnqueuer, ExecuteOptions as EnqueueExecutionOptions, } from './create_execute_function'; +import { ActionsAuthorization } from './authorization/actions_authorization'; +import { ActionType } from '../common'; // We are assuming there won't be many actions. This is why we will load // all the actions in advance and assume the total count to not go over 10000. @@ -52,11 +54,12 @@ interface ConstructorOptions { defaultKibanaIndex: string; scopedClusterClient: ILegacyScopedClusterClient; actionTypeRegistry: ActionTypeRegistry; - savedObjectsClient: SavedObjectsClientContract; + unsecuredSavedObjectsClient: SavedObjectsClientContract; preconfiguredActions: PreConfiguredAction[]; actionExecutor: ActionExecutorContract; executionEnqueuer: ExecutionEnqueuer; request: KibanaRequest; + authorization: ActionsAuthorization; } interface UpdateOptions { @@ -67,45 +70,51 @@ interface UpdateOptions { export class ActionsClient { private readonly defaultKibanaIndex: string; private readonly scopedClusterClient: ILegacyScopedClusterClient; - private readonly savedObjectsClient: SavedObjectsClientContract; + private readonly unsecuredSavedObjectsClient: SavedObjectsClientContract; private readonly actionTypeRegistry: ActionTypeRegistry; private readonly preconfiguredActions: PreConfiguredAction[]; private readonly actionExecutor: ActionExecutorContract; private readonly request: KibanaRequest; + private readonly authorization: ActionsAuthorization; private readonly executionEnqueuer: ExecutionEnqueuer; constructor({ actionTypeRegistry, defaultKibanaIndex, scopedClusterClient, - savedObjectsClient, + unsecuredSavedObjectsClient, preconfiguredActions, actionExecutor, executionEnqueuer, request, + authorization, }: ConstructorOptions) { this.actionTypeRegistry = actionTypeRegistry; - this.savedObjectsClient = savedObjectsClient; + this.unsecuredSavedObjectsClient = unsecuredSavedObjectsClient; this.scopedClusterClient = scopedClusterClient; this.defaultKibanaIndex = defaultKibanaIndex; this.preconfiguredActions = preconfiguredActions; this.actionExecutor = actionExecutor; this.executionEnqueuer = executionEnqueuer; this.request = request; + this.authorization = authorization; } /** * Create an action */ - public async create({ action }: CreateOptions): Promise { - const { actionTypeId, name, config, secrets } = action; + public async create({ + action: { actionTypeId, name, config, secrets }, + }: CreateOptions): Promise { + await this.authorization.ensureAuthorized('create', actionTypeId); + const actionType = this.actionTypeRegistry.get(actionTypeId); const validatedActionTypeConfig = validateConfig(actionType, config); const validatedActionTypeSecrets = validateSecrets(actionType, secrets); this.actionTypeRegistry.ensureActionTypeEnabled(actionTypeId); - const result = await this.savedObjectsClient.create('action', { + const result = await this.unsecuredSavedObjectsClient.create('action', { actionTypeId, name, config: validatedActionTypeConfig as SavedObjectAttributes, @@ -125,6 +134,8 @@ export class ActionsClient { * Update action */ public async update({ id, action }: UpdateOptions): Promise { + await this.authorization.ensureAuthorized('update'); + if ( this.preconfiguredActions.find((preconfiguredAction) => preconfiguredAction.id === id) !== undefined @@ -139,7 +150,7 @@ export class ActionsClient { 'update' ); } - const existingObject = await this.savedObjectsClient.get('action', id); + const existingObject = await this.unsecuredSavedObjectsClient.get('action', id); const { actionTypeId } = existingObject.attributes; const { name, config, secrets } = action; const actionType = this.actionTypeRegistry.get(actionTypeId); @@ -148,7 +159,7 @@ export class ActionsClient { this.actionTypeRegistry.ensureActionTypeEnabled(actionTypeId); - const result = await this.savedObjectsClient.update('action', id, { + const result = await this.unsecuredSavedObjectsClient.update('action', id, { actionTypeId, name, config: validatedActionTypeConfig as SavedObjectAttributes, @@ -168,6 +179,8 @@ export class ActionsClient { * Get an action */ public async get({ id }: { id: string }): Promise { + await this.authorization.ensureAuthorized('get'); + const preconfiguredActionsList = this.preconfiguredActions.find( (preconfiguredAction) => preconfiguredAction.id === id ); @@ -179,7 +192,7 @@ export class ActionsClient { isPreconfigured: true, }; } - const result = await this.savedObjectsClient.get('action', id); + const result = await this.unsecuredSavedObjectsClient.get('action', id); return { id, @@ -194,8 +207,10 @@ export class ActionsClient { * Get all actions with preconfigured list */ public async getAll(): Promise { + await this.authorization.ensureAuthorized('get'); + const savedObjectsActions = ( - await this.savedObjectsClient.find({ + await this.unsecuredSavedObjectsClient.find({ perPage: MAX_ACTIONS_RETURNED, type: 'action', }) @@ -221,6 +236,8 @@ export class ActionsClient { * Get bulk actions with preconfigured list */ public async getBulk(ids: string[]): Promise { + await this.authorization.ensureAuthorized('get'); + const actionResults = new Array(); for (const actionId of ids) { const action = this.preconfiguredActions.find( @@ -242,7 +259,7 @@ export class ActionsClient { ]; const bulkGetOpts = actionSavedObjectsIds.map((id) => ({ id, type: 'action' })); - const bulkGetResult = await this.savedObjectsClient.bulkGet(bulkGetOpts); + const bulkGetResult = await this.unsecuredSavedObjectsClient.bulkGet(bulkGetOpts); for (const action of bulkGetResult.saved_objects) { if (action.error) { @@ -259,6 +276,8 @@ export class ActionsClient { * Delete action */ public async delete({ id }: { id: string }) { + await this.authorization.ensureAuthorized('delete'); + if ( this.preconfiguredActions.find((preconfiguredAction) => preconfiguredAction.id === id) !== undefined @@ -273,18 +292,24 @@ export class ActionsClient { 'delete' ); } - return await this.savedObjectsClient.delete('action', id); + return await this.unsecuredSavedObjectsClient.delete('action', id); } public async execute({ actionId, params, }: Omit): Promise { + await this.authorization.ensureAuthorized('execute'); return this.actionExecutor.execute({ actionId, params, request: this.request }); } public async enqueueExecution(options: EnqueueExecutionOptions): Promise { - return this.executionEnqueuer(this.savedObjectsClient, options); + await this.authorization.ensureAuthorized('execute'); + return this.executionEnqueuer(this.unsecuredSavedObjectsClient, options); + } + + public async listTypes(): Promise { + return this.actionTypeRegistry.list(); } } diff --git a/x-pack/plugins/actions/server/authorization/actions_authorization.mock.ts b/x-pack/plugins/actions/server/authorization/actions_authorization.mock.ts new file mode 100644 index 000000000000..6b55c18241c5 --- /dev/null +++ b/x-pack/plugins/actions/server/authorization/actions_authorization.mock.ts @@ -0,0 +1,22 @@ +/* + * 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 { ActionsAuthorization } from './actions_authorization'; + +export type ActionsAuthorizationMock = jest.Mocked>; + +const createActionsAuthorizationMock = () => { + const mocked: ActionsAuthorizationMock = { + ensureAuthorized: jest.fn(), + }; + return mocked; +}; + +export const actionsAuthorizationMock: { + create: () => ActionsAuthorizationMock; +} = { + create: createActionsAuthorizationMock, +}; diff --git a/x-pack/plugins/actions/server/authorization/actions_authorization.test.ts b/x-pack/plugins/actions/server/authorization/actions_authorization.test.ts new file mode 100644 index 000000000000..a48124cdbcb6 --- /dev/null +++ b/x-pack/plugins/actions/server/authorization/actions_authorization.test.ts @@ -0,0 +1,191 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { KibanaRequest } from 'kibana/server'; +import { securityMock } from '../../../../plugins/security/server/mocks'; +import { ActionsAuthorization } from './actions_authorization'; +import { actionsAuthorizationAuditLoggerMock } from './audit_logger.mock'; +import { ActionsAuthorizationAuditLogger, AuthorizationResult } from './audit_logger'; +import { ACTION_SAVED_OBJECT_TYPE, ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE } from '../saved_objects'; + +const request = {} as KibanaRequest; + +const auditLogger = actionsAuthorizationAuditLoggerMock.create(); +const realAuditLogger = new ActionsAuthorizationAuditLogger(); + +const mockAuthorizationAction = (type: string, operation: string) => `${type}/${operation}`; +function mockSecurity() { + const security = securityMock.createSetup(); + const authorization = security.authz; + // typescript is having trouble inferring jest's automocking + (authorization.actions.savedObject.get as jest.MockedFunction< + typeof authorization.actions.savedObject.get + >).mockImplementation(mockAuthorizationAction); + authorization.mode.useRbacForRequest.mockReturnValue(true); + return { authorization }; +} + +beforeEach(() => { + jest.resetAllMocks(); + auditLogger.actionsAuthorizationFailure.mockImplementation((username, ...args) => + realAuditLogger.getAuthorizationMessage(AuthorizationResult.Unauthorized, ...args) + ); + auditLogger.actionsAuthorizationSuccess.mockImplementation((username, ...args) => + realAuditLogger.getAuthorizationMessage(AuthorizationResult.Authorized, ...args) + ); +}); + +describe('ensureAuthorized', () => { + test('is a no-op when there is no authorization api', async () => { + const actionsAuthorization = new ActionsAuthorization({ + request, + auditLogger, + }); + + await actionsAuthorization.ensureAuthorized('create', 'myType'); + }); + + test('is a no-op when the security license is disabled', async () => { + const { authorization } = mockSecurity(); + authorization.mode.useRbacForRequest.mockReturnValue(false); + const actionsAuthorization = new ActionsAuthorization({ + request, + authorization, + auditLogger, + }); + + await actionsAuthorization.ensureAuthorized('create', 'myType'); + }); + + test('ensures the user has privileges to use the operation on the Actions Saved Object type', async () => { + const { authorization } = mockSecurity(); + const checkPrivileges: jest.MockedFunction> = jest.fn(); + authorization.checkPrivilegesDynamicallyWithRequest.mockReturnValue(checkPrivileges); + const actionsAuthorization = new ActionsAuthorization({ + request, + authorization, + auditLogger, + }); + + checkPrivileges.mockResolvedValueOnce({ + username: 'some-user', + hasAllRequested: true, + privileges: [ + { + privilege: mockAuthorizationAction('myType', 'create'), + authorized: true, + }, + ], + }); + + await actionsAuthorization.ensureAuthorized('create', 'myType'); + + expect(authorization.actions.savedObject.get).toHaveBeenCalledWith('action', 'create'); + expect(checkPrivileges).toHaveBeenCalledWith(mockAuthorizationAction('action', 'create')); + + expect(auditLogger.actionsAuthorizationSuccess).toHaveBeenCalledTimes(1); + expect(auditLogger.actionsAuthorizationFailure).not.toHaveBeenCalled(); + expect(auditLogger.actionsAuthorizationSuccess.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "some-user", + "create", + "myType", + ] + `); + }); + + test('ensures the user has privileges to execute an Actions Saved Object type', async () => { + const { authorization } = mockSecurity(); + const checkPrivileges: jest.MockedFunction> = jest.fn(); + authorization.checkPrivilegesDynamicallyWithRequest.mockReturnValue(checkPrivileges); + const actionsAuthorization = new ActionsAuthorization({ + request, + authorization, + auditLogger, + }); + + checkPrivileges.mockResolvedValueOnce({ + username: 'some-user', + hasAllRequested: true, + privileges: [ + { + privilege: mockAuthorizationAction('myType', 'execute'), + authorized: true, + }, + ], + }); + + await actionsAuthorization.ensureAuthorized('execute', 'myType'); + + expect(authorization.actions.savedObject.get).toHaveBeenCalledWith( + ACTION_SAVED_OBJECT_TYPE, + 'get' + ); + expect(authorization.actions.savedObject.get).toHaveBeenCalledWith( + ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE, + 'create' + ); + expect(checkPrivileges).toHaveBeenCalledWith([ + mockAuthorizationAction(ACTION_SAVED_OBJECT_TYPE, 'get'), + mockAuthorizationAction(ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE, 'create'), + ]); + + expect(auditLogger.actionsAuthorizationSuccess).toHaveBeenCalledTimes(1); + expect(auditLogger.actionsAuthorizationFailure).not.toHaveBeenCalled(); + expect(auditLogger.actionsAuthorizationSuccess.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "some-user", + "execute", + "myType", + ] + `); + }); + + test('throws if user lacks the required privieleges', async () => { + const { authorization } = mockSecurity(); + const checkPrivileges: jest.MockedFunction> = jest.fn(); + authorization.checkPrivilegesDynamicallyWithRequest.mockReturnValue(checkPrivileges); + const actionsAuthorization = new ActionsAuthorization({ + request, + authorization, + auditLogger, + }); + + checkPrivileges.mockResolvedValueOnce({ + username: 'some-user', + hasAllRequested: false, + privileges: [ + { + privilege: mockAuthorizationAction('myType', 'create'), + authorized: false, + }, + { + privilege: mockAuthorizationAction('myOtherType', 'create'), + authorized: true, + }, + ], + }); + + await expect( + actionsAuthorization.ensureAuthorized('create', 'myType') + ).rejects.toThrowErrorMatchingInlineSnapshot(`"Unauthorized to create a \\"myType\\" action"`); + + expect(auditLogger.actionsAuthorizationSuccess).not.toHaveBeenCalled(); + expect(auditLogger.actionsAuthorizationFailure).toHaveBeenCalledTimes(1); + expect(auditLogger.actionsAuthorizationFailure.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "some-user", + "create", + "myType", + ] + `); + }); +}); diff --git a/x-pack/plugins/actions/server/authorization/actions_authorization.ts b/x-pack/plugins/actions/server/authorization/actions_authorization.ts new file mode 100644 index 000000000000..da5a5a1cdc3e --- /dev/null +++ b/x-pack/plugins/actions/server/authorization/actions_authorization.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Boom from 'boom'; +import { KibanaRequest } from 'src/core/server'; +import { SecurityPluginSetup } from '../../../security/server'; +import { ActionsAuthorizationAuditLogger } from './audit_logger'; +import { ACTION_SAVED_OBJECT_TYPE, ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE } from '../saved_objects'; + +export interface ConstructorOptions { + request: KibanaRequest; + auditLogger: ActionsAuthorizationAuditLogger; + authorization?: SecurityPluginSetup['authz']; +} + +const operationAlias: Record< + string, + (authorization: SecurityPluginSetup['authz']) => string | string[] +> = { + execute: (authorization) => [ + authorization.actions.savedObject.get(ACTION_SAVED_OBJECT_TYPE, 'get'), + authorization.actions.savedObject.get(ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE, 'create'), + ], + list: (authorization) => authorization.actions.savedObject.get(ACTION_SAVED_OBJECT_TYPE, 'find'), +}; + +export class ActionsAuthorization { + private readonly request: KibanaRequest; + private readonly authorization?: SecurityPluginSetup['authz']; + private readonly auditLogger: ActionsAuthorizationAuditLogger; + + constructor({ request, authorization, auditLogger }: ConstructorOptions) { + this.request = request; + this.authorization = authorization; + this.auditLogger = auditLogger; + } + + public async ensureAuthorized(operation: string, actionTypeId?: string) { + const { authorization } = this; + if (authorization?.mode?.useRbacForRequest(this.request)) { + const checkPrivileges = authorization.checkPrivilegesDynamicallyWithRequest(this.request); + const { hasAllRequested, username } = await checkPrivileges( + operationAlias[operation] + ? operationAlias[operation](authorization) + : authorization.actions.savedObject.get(ACTION_SAVED_OBJECT_TYPE, operation) + ); + if (hasAllRequested) { + this.auditLogger.actionsAuthorizationSuccess(username, operation, actionTypeId); + } else { + throw Boom.forbidden( + this.auditLogger.actionsAuthorizationFailure(username, operation, actionTypeId) + ); + } + } + } +} diff --git a/x-pack/plugins/actions/server/authorization/audit_logger.mock.ts b/x-pack/plugins/actions/server/authorization/audit_logger.mock.ts new file mode 100644 index 000000000000..95d4f4ebcd3a --- /dev/null +++ b/x-pack/plugins/actions/server/authorization/audit_logger.mock.ts @@ -0,0 +1,22 @@ +/* + * 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 { ActionsAuthorizationAuditLogger } from './audit_logger'; + +const createActionsAuthorizationAuditLoggerMock = () => { + const mocked = ({ + getAuthorizationMessage: jest.fn(), + actionsAuthorizationFailure: jest.fn(), + actionsAuthorizationSuccess: jest.fn(), + } as unknown) as jest.Mocked; + return mocked; +}; + +export const actionsAuthorizationAuditLoggerMock: { + create: () => jest.Mocked; +} = { + create: createActionsAuthorizationAuditLoggerMock, +}; diff --git a/x-pack/plugins/actions/server/authorization/audit_logger.test.ts b/x-pack/plugins/actions/server/authorization/audit_logger.test.ts new file mode 100644 index 000000000000..d700abdaa70f --- /dev/null +++ b/x-pack/plugins/actions/server/authorization/audit_logger.test.ts @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { ActionsAuthorizationAuditLogger } from './audit_logger'; + +const createMockAuditLogger = () => { + return { + log: jest.fn(), + }; +}; + +describe(`#constructor`, () => { + test('initializes a noop auditLogger if security logger is unavailable', () => { + const actionsAuditLogger = new ActionsAuthorizationAuditLogger(undefined); + + const username = 'foo-user'; + const actionTypeId = 'action-type-id'; + const operation = 'create'; + expect(() => { + actionsAuditLogger.actionsAuthorizationFailure(username, operation, actionTypeId); + actionsAuditLogger.actionsAuthorizationSuccess(username, operation, actionTypeId); + }).not.toThrow(); + }); +}); + +describe(`#actionsAuthorizationFailure`, () => { + test('logs auth failure', () => { + const auditLogger = createMockAuditLogger(); + const actionsAuditLogger = new ActionsAuthorizationAuditLogger(auditLogger); + const username = 'foo-user'; + const actionTypeId = 'action-type-id'; + const operation = 'create'; + + actionsAuditLogger.actionsAuthorizationFailure(username, operation, actionTypeId); + + expect(auditLogger.log.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "actions_authorization_failure", + "foo-user Unauthorized to create a \\"action-type-id\\" action", + Object { + "actionTypeId": "action-type-id", + "operation": "create", + "username": "foo-user", + }, + ] + `); + }); +}); + +describe(`#savedObjectsAuthorizationSuccess`, () => { + test('logs auth success', () => { + const auditLogger = createMockAuditLogger(); + const actionsAuditLogger = new ActionsAuthorizationAuditLogger(auditLogger); + const username = 'foo-user'; + const actionTypeId = 'action-type-id'; + + const operation = 'create'; + + actionsAuditLogger.actionsAuthorizationSuccess(username, operation, actionTypeId); + + expect(auditLogger.log.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "actions_authorization_success", + "foo-user Authorized to create a \\"action-type-id\\" action", + Object { + "actionTypeId": "action-type-id", + "operation": "create", + "username": "foo-user", + }, + ] + `); + }); +}); diff --git a/x-pack/plugins/actions/server/authorization/audit_logger.ts b/x-pack/plugins/actions/server/authorization/audit_logger.ts new file mode 100644 index 000000000000..7e0adc920665 --- /dev/null +++ b/x-pack/plugins/actions/server/authorization/audit_logger.ts @@ -0,0 +1,66 @@ +/* + * 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 { AuditLogger } from '../../../security/server'; + +export enum AuthorizationResult { + Unauthorized = 'Unauthorized', + Authorized = 'Authorized', +} + +export class ActionsAuthorizationAuditLogger { + private readonly auditLogger: AuditLogger; + + constructor(auditLogger: AuditLogger = { log() {} }) { + this.auditLogger = auditLogger; + } + + public getAuthorizationMessage( + authorizationResult: AuthorizationResult, + operation: string, + actionTypeId?: string + ): string { + return `${authorizationResult} to ${operation} ${ + actionTypeId ? `a "${actionTypeId}" action` : `actions` + }`; + } + + public actionsAuthorizationFailure( + username: string, + operation: string, + actionTypeId?: string + ): string { + const message = this.getAuthorizationMessage( + AuthorizationResult.Unauthorized, + operation, + actionTypeId + ); + this.auditLogger.log('actions_authorization_failure', `${username} ${message}`, { + username, + actionTypeId, + operation, + }); + return message; + } + + public actionsAuthorizationSuccess( + username: string, + operation: string, + actionTypeId?: string + ): string { + const message = this.getAuthorizationMessage( + AuthorizationResult.Authorized, + operation, + actionTypeId + ); + this.auditLogger.log('actions_authorization_success', `${username} ${message}`, { + username, + actionTypeId, + operation, + }); + return message; + } +} diff --git a/x-pack/plugins/actions/server/create_execute_function.ts b/x-pack/plugins/actions/server/create_execute_function.ts index 2bad33d56f22..85052eef93e0 100644 --- a/x-pack/plugins/actions/server/create_execute_function.ts +++ b/x-pack/plugins/actions/server/create_execute_function.ts @@ -7,6 +7,7 @@ import { SavedObjectsClientContract } from '../../../../src/core/server'; import { TaskManagerStartContract } from '../../task_manager/server'; import { RawAction, ActionTypeRegistryContract, PreConfiguredAction } from './types'; +import { ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE } from './saved_objects'; interface CreateExecuteFunctionOptions { taskManager: TaskManagerStartContract; @@ -49,11 +50,14 @@ export function createExecutionEnqueuerFunction({ actionTypeRegistry.ensureActionTypeEnabled(actionTypeId); } - const actionTaskParamsRecord = await savedObjectsClient.create('action_task_params', { - actionId: id, - params, - apiKey, - }); + const actionTaskParamsRecord = await savedObjectsClient.create( + ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE, + { + actionId: id, + params, + apiKey, + } + ); await taskManager.schedule({ taskType: `actions:${actionTypeId}`, diff --git a/x-pack/plugins/actions/server/feature.ts b/x-pack/plugins/actions/server/feature.ts new file mode 100644 index 000000000000..c06acb676145 --- /dev/null +++ b/x-pack/plugins/actions/server/feature.ts @@ -0,0 +1,41 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { ACTION_SAVED_OBJECT_TYPE, ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE } from './saved_objects'; + +export const ACTIONS_FEATURE = { + id: 'actions', + name: i18n.translate('xpack.actions.featureRegistry.actionsFeatureName', { + defaultMessage: 'Actions', + }), + icon: 'bell', + navLinkId: 'actions', + app: [], + privileges: { + all: { + app: [], + api: [], + catalogue: [], + savedObject: { + all: [ACTION_SAVED_OBJECT_TYPE, ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE], + read: [], + }, + ui: ['show', 'execute', 'save', 'delete'], + }, + read: { + app: [], + api: [], + catalogue: [], + savedObject: { + // action execution requires 'read' over `actions`, but 'all' over `action_task_params` + all: [ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE], + read: [ACTION_SAVED_OBJECT_TYPE], + }, + ui: ['show', 'execute'], + }, + }, +}; diff --git a/x-pack/plugins/actions/server/index.ts b/x-pack/plugins/actions/server/index.ts index 88553c314112..fef70c3a4845 100644 --- a/x-pack/plugins/actions/server/index.ts +++ b/x-pack/plugins/actions/server/index.ts @@ -8,8 +8,10 @@ import { PluginInitializerContext } from '../../../../src/core/server'; import { ActionsPlugin } from './plugin'; import { configSchema } from './config'; import { ActionsClient as ActionsClientClass } from './actions_client'; +import { ActionsAuthorization as ActionsAuthorizationClass } from './authorization/actions_authorization'; export type ActionsClient = PublicMethodsOf; +export type ActionsAuthorization = PublicMethodsOf; export { ActionsPlugin, diff --git a/x-pack/plugins/actions/server/lib/action_executor.test.ts b/x-pack/plugins/actions/server/lib/action_executor.test.ts index c8e6669275e1..65fd0646c639 100644 --- a/x-pack/plugins/actions/server/lib/action_executor.test.ts +++ b/x-pack/plugins/actions/server/lib/action_executor.test.ts @@ -9,15 +9,17 @@ import { schema } from '@kbn/config-schema'; import { ActionExecutor } from './action_executor'; import { actionTypeRegistryMock } from '../action_type_registry.mock'; import { encryptedSavedObjectsMock } from '../../../encrypted_saved_objects/server/mocks'; -import { loggingSystemMock, savedObjectsClientMock } from '../../../../../src/core/server/mocks'; +import { loggingSystemMock } from '../../../../../src/core/server/mocks'; import { eventLoggerMock } from '../../../event_log/server/mocks'; import { spacesServiceMock } from '../../../spaces/server/spaces_service/spaces_service.mock'; import { ActionType } from '../types'; -import { actionsMock } from '../mocks'; +import { actionsMock, actionsClientMock } from '../mocks'; +import { pick } from 'lodash'; const actionExecutor = new ActionExecutor({ isESOUsingEphemeralEncryptionKey: false }); const services = actionsMock.createServices(); -const savedObjectsClientWithHidden = savedObjectsClientMock.create(); + +const actionsClient = actionsClientMock.create(); const encryptedSavedObjectsClient = encryptedSavedObjectsMock.createClient(); const actionTypeRegistry = actionTypeRegistryMock.create(); @@ -30,11 +32,12 @@ const executeParams = { }; const spacesMock = spacesServiceMock.createSetupContract(); +const getActionsClientWithRequest = jest.fn(); actionExecutor.initialize({ logger: loggingSystemMock.create().get(), spaces: spacesMock, getServices: () => services, - getScopedSavedObjectsClient: () => savedObjectsClientWithHidden, + getActionsClientWithRequest, actionTypeRegistry, encryptedSavedObjectsClient, eventLogger: eventLoggerMock.create(), @@ -44,6 +47,7 @@ actionExecutor.initialize({ beforeEach(() => { jest.resetAllMocks(); spacesMock.getSpaceId.mockReturnValue('some-namespace'); + getActionsClientWithRequest.mockResolvedValue(actionsClient); }); test('successfully executes', async () => { @@ -67,7 +71,13 @@ test('successfully executes', async () => { }, references: [], }; - savedObjectsClientWithHidden.get.mockResolvedValueOnce(actionSavedObject); + const actionResult = { + id: actionSavedObject.id, + name: actionSavedObject.id, + ...pick(actionSavedObject.attributes, 'actionTypeId', 'config'), + isPreconfigured: false, + }; + actionsClient.get.mockResolvedValueOnce(actionResult); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce(actionSavedObject); actionTypeRegistry.get.mockReturnValueOnce(actionType); await actionExecutor.execute(executeParams); @@ -108,7 +118,13 @@ test('provides empty config when config and / or secrets is empty', async () => }, references: [], }; - savedObjectsClientWithHidden.get.mockResolvedValueOnce(actionSavedObject); + const actionResult = { + id: actionSavedObject.id, + name: actionSavedObject.id, + actionTypeId: actionSavedObject.attributes.actionTypeId, + isPreconfigured: false, + }; + actionsClient.get.mockResolvedValueOnce(actionResult); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce(actionSavedObject); actionTypeRegistry.get.mockReturnValueOnce(actionType); await actionExecutor.execute(executeParams); @@ -138,7 +154,13 @@ test('throws an error when config is invalid', async () => { }, references: [], }; - savedObjectsClientWithHidden.get.mockResolvedValueOnce(actionSavedObject); + const actionResult = { + id: actionSavedObject.id, + name: actionSavedObject.id, + actionTypeId: actionSavedObject.attributes.actionTypeId, + isPreconfigured: false, + }; + actionsClient.get.mockResolvedValueOnce(actionResult); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce(actionSavedObject); actionTypeRegistry.get.mockReturnValueOnce(actionType); @@ -171,7 +193,13 @@ test('throws an error when params is invalid', async () => { }, references: [], }; - savedObjectsClientWithHidden.get.mockResolvedValueOnce(actionSavedObject); + const actionResult = { + id: actionSavedObject.id, + name: actionSavedObject.id, + actionTypeId: actionSavedObject.attributes.actionTypeId, + isPreconfigured: false, + }; + actionsClient.get.mockResolvedValueOnce(actionResult); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce(actionSavedObject); actionTypeRegistry.get.mockReturnValueOnce(actionType); @@ -185,7 +213,7 @@ test('throws an error when params is invalid', async () => { }); test('throws an error when failing to load action through savedObjectsClient', async () => { - savedObjectsClientWithHidden.get.mockRejectedValueOnce(new Error('No access')); + actionsClient.get.mockRejectedValueOnce(new Error('No access')); await expect(actionExecutor.execute(executeParams)).rejects.toThrowErrorMatchingInlineSnapshot( `"No access"` ); @@ -206,7 +234,13 @@ test('throws an error if actionType is not enabled', async () => { }, references: [], }; - savedObjectsClientWithHidden.get.mockResolvedValueOnce(actionSavedObject); + const actionResult = { + id: actionSavedObject.id, + name: actionSavedObject.id, + actionTypeId: actionSavedObject.attributes.actionTypeId, + isPreconfigured: false, + }; + actionsClient.get.mockResolvedValueOnce(actionResult); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce(actionSavedObject); actionTypeRegistry.get.mockReturnValueOnce(actionType); actionTypeRegistry.ensureActionTypeEnabled.mockImplementationOnce(() => { @@ -240,7 +274,13 @@ test('should not throws an error if actionType is preconfigured', async () => { }, references: [], }; - savedObjectsClientWithHidden.get.mockResolvedValueOnce(actionSavedObject); + const actionResult = { + id: actionSavedObject.id, + name: actionSavedObject.id, + ...pick(actionSavedObject.attributes, 'actionTypeId', 'config', 'secrets'), + isPreconfigured: false, + }; + actionsClient.get.mockResolvedValueOnce(actionResult); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce(actionSavedObject); actionTypeRegistry.get.mockReturnValueOnce(actionType); actionTypeRegistry.ensureActionTypeEnabled.mockImplementationOnce(() => { @@ -268,7 +308,7 @@ test('throws an error when passing isESOUsingEphemeralEncryptionKey with value o customActionExecutor.initialize({ logger: loggingSystemMock.create().get(), spaces: spacesMock, - getScopedSavedObjectsClient: () => savedObjectsClientWithHidden, + getActionsClientWithRequest, getServices: () => services, actionTypeRegistry, encryptedSavedObjectsClient, diff --git a/x-pack/plugins/actions/server/lib/action_executor.ts b/x-pack/plugins/actions/server/lib/action_executor.ts index 250bfc2752f1..0e63cc8f5956 100644 --- a/x-pack/plugins/actions/server/lib/action_executor.ts +++ b/x-pack/plugins/actions/server/lib/action_executor.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Logger, KibanaRequest, SavedObjectsClientContract } from '../../../../../src/core/server'; +import { Logger, KibanaRequest } from '../../../../../src/core/server'; import { validateParams, validateConfig, validateSecrets } from './validate_with_schema'; import { ActionTypeExecutorResult, @@ -15,14 +15,15 @@ import { } from '../types'; import { EncryptedSavedObjectsClient } from '../../../encrypted_saved_objects/server'; import { SpacesServiceSetup } from '../../../spaces/server'; -import { EVENT_LOG_ACTIONS } from '../plugin'; +import { EVENT_LOG_ACTIONS, PluginStartContract } from '../plugin'; import { IEvent, IEventLogger, SAVED_OBJECT_REL_PRIMARY } from '../../../event_log/server'; +import { ActionsClient } from '../actions_client'; export interface ActionExecutorContext { logger: Logger; spaces?: SpacesServiceSetup; getServices: GetServicesFunction; - getScopedSavedObjectsClient: (req: KibanaRequest) => SavedObjectsClientContract; + getActionsClientWithRequest: PluginStartContract['getActionsClientWithRequest']; encryptedSavedObjectsClient: EncryptedSavedObjectsClient; actionTypeRegistry: ActionTypeRegistryContract; eventLogger: IEventLogger; @@ -76,7 +77,7 @@ export class ActionExecutor { actionTypeRegistry, eventLogger, preconfiguredActions, - getScopedSavedObjectsClient, + getActionsClientWithRequest, } = this.actionExecutorContext!; const services = getServices(request); @@ -84,7 +85,7 @@ export class ActionExecutor { const namespace = spaceId && spaceId !== 'default' ? { namespace: spaceId } : {}; const { actionTypeId, name, config, secrets } = await getActionInfo( - getScopedSavedObjectsClient(request), + await getActionsClientWithRequest(request), encryptedSavedObjectsClient, preconfiguredActions, actionId, @@ -196,7 +197,7 @@ interface ActionInfo { } async function getActionInfo( - savedObjectsClient: SavedObjectsClientContract, + actionsClient: PublicMethodsOf, encryptedSavedObjectsClient: EncryptedSavedObjectsClient, preconfiguredActions: PreConfiguredAction[], actionId: string, @@ -217,9 +218,7 @@ async function getActionInfo( // if not pre-configured action, should be a saved object // ensure user can read the action before processing - const { - attributes: { actionTypeId, config, name }, - } = await savedObjectsClient.get('action', actionId); + const { actionTypeId, config, name } = await actionsClient.get({ id: actionId }); const { attributes: { secrets }, diff --git a/x-pack/plugins/actions/server/lib/task_runner_factory.test.ts b/x-pack/plugins/actions/server/lib/task_runner_factory.test.ts index 06cb84ad79a8..78522682054e 100644 --- a/x-pack/plugins/actions/server/lib/task_runner_factory.test.ts +++ b/x-pack/plugins/actions/server/lib/task_runner_factory.test.ts @@ -15,6 +15,7 @@ import { encryptedSavedObjectsMock } from '../../../encrypted_saved_objects/serv import { savedObjectsClientMock, loggingSystemMock } from 'src/core/server/mocks'; import { eventLoggerMock } from '../../../event_log/server/mocks'; import { ActionTypeDisabledError } from './errors'; +import { actionsClientMock } from '../mocks'; const spaceIdToNamespace = jest.fn(); const actionTypeRegistry = actionTypeRegistryMock.create(); @@ -59,7 +60,7 @@ const actionExecutorInitializerParams = { logger: loggingSystemMock.create().get(), getServices: jest.fn().mockReturnValue(services), actionTypeRegistry, - getScopedSavedObjectsClient: () => savedObjectsClientMock.create(), + getActionsClientWithRequest: jest.fn(async () => actionsClientMock.create()), encryptedSavedObjectsClient: mockedEncryptedSavedObjectsClient, eventLogger: eventLoggerMock.create(), preconfiguredActions: [], diff --git a/x-pack/plugins/actions/server/lib/task_runner_factory.ts b/x-pack/plugins/actions/server/lib/task_runner_factory.ts index a962497f906a..9204c41b9288 100644 --- a/x-pack/plugins/actions/server/lib/task_runner_factory.ts +++ b/x-pack/plugins/actions/server/lib/task_runner_factory.ts @@ -17,6 +17,7 @@ import { SpaceIdToNamespaceFunction, ActionTypeExecutorResult, } from '../types'; +import { ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE } from '../saved_objects'; export interface TaskRunnerContext { logger: Logger; @@ -66,7 +67,7 @@ export class TaskRunnerFactory { const { attributes: { actionId, params, apiKey }, } = await encryptedSavedObjectsClient.getDecryptedAsInternalUser( - 'action_task_params', + ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE, actionTaskParamsId, { namespace } ); @@ -121,11 +122,11 @@ export class TaskRunnerFactory { // Cleanup action_task_params object now that we're done with it try { const savedObjectsClient = getScopedSavedObjectsClient(fakeRequest); - await savedObjectsClient.delete('action_task_params', actionTaskParamsId); + await savedObjectsClient.delete(ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE, actionTaskParamsId); } catch (e) { // Log error only, we shouldn't fail the task because of an error here (if ever there's retry logic) logger.error( - `Failed to cleanup action_task_params object [id="${actionTaskParamsId}"]: ${e.message}` + `Failed to cleanup ${ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE} object [id="${actionTaskParamsId}"]: ${e.message}` ); } }, diff --git a/x-pack/plugins/actions/server/mocks.ts b/x-pack/plugins/actions/server/mocks.ts index b1e40dce811a..e2f11abeefff 100644 --- a/x-pack/plugins/actions/server/mocks.ts +++ b/x-pack/plugins/actions/server/mocks.ts @@ -11,7 +11,9 @@ import { elasticsearchServiceMock, savedObjectsClientMock, } from '../../../../src/core/server/mocks'; +import { actionsAuthorizationMock } from './authorization/actions_authorization.mock'; +export { actionsAuthorizationMock }; export { actionsClientMock }; const createSetupMock = () => { @@ -26,6 +28,9 @@ const createStartMock = () => { isActionTypeEnabled: jest.fn(), isActionExecutable: jest.fn(), getActionsClientWithRequest: jest.fn().mockResolvedValue(actionsClientMock.create()), + getActionsAuthorizationWithRequest: jest + .fn() + .mockReturnValue(actionsAuthorizationMock.create()), preconfiguredActions: [], }; return mock; diff --git a/x-pack/plugins/actions/server/plugin.test.ts b/x-pack/plugins/actions/server/plugin.test.ts index 1602b26559be..ac4b332e7fd7 100644 --- a/x-pack/plugins/actions/server/plugin.test.ts +++ b/x-pack/plugins/actions/server/plugin.test.ts @@ -8,6 +8,7 @@ import { PluginInitializerContext, RequestHandlerContext } from '../../../../src import { coreMock, httpServerMock } from '../../../../src/core/server/mocks'; import { usageCollectionPluginMock } from '../../../../src/plugins/usage_collection/server/mocks'; import { licensingMock } from '../../licensing/server/mocks'; +import { featuresPluginMock } from '../../features/server/mocks'; import { encryptedSavedObjectsMock } from '../../encrypted_saved_objects/server/mocks'; import { taskManagerMock } from '../../task_manager/server/mocks'; import { eventLogMock } from '../../event_log/server/mocks'; @@ -43,6 +44,7 @@ describe('Actions Plugin', () => { licensing: licensingMock.createSetup(), eventLog: eventLogMock.createSetup(), usageCollection: usageCollectionPluginMock.createSetupContract(), + features: featuresPluginMock.createSetup(), }; }); @@ -200,6 +202,7 @@ describe('Actions Plugin', () => { licensing: licensingMock.createSetup(), eventLog: eventLogMock.createSetup(), usageCollection: usageCollectionPluginMock.createSetupContract(), + features: featuresPluginMock.createSetup(), }; pluginsStart = { taskManager: taskManagerMock.createStart(), diff --git a/x-pack/plugins/actions/server/plugin.ts b/x-pack/plugins/actions/server/plugin.ts index 114c85ae9f9d..5b8b25d02658 100644 --- a/x-pack/plugins/actions/server/plugin.ts +++ b/x-pack/plugins/actions/server/plugin.ts @@ -29,6 +29,8 @@ import { TaskManagerSetupContract, TaskManagerStartContract } from '../../task_m import { LicensingPluginSetup } from '../../licensing/server'; import { LICENSE_TYPE } from '../../licensing/common/types'; import { SpacesPluginSetup, SpacesServiceSetup } from '../../spaces/server'; +import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; +import { SecurityPluginSetup } from '../../security/server'; import { ActionsConfig } from './config'; import { Services, ActionType, PreConfiguredAction } from './types'; @@ -52,7 +54,14 @@ import { } from './routes'; import { IEventLogger, IEventLogService } from '../../event_log/server'; import { initializeActionsTelemetry, scheduleActionsTelemetry } from './usage/task'; -import { setupSavedObjects } from './saved_objects'; +import { + setupSavedObjects, + ACTION_SAVED_OBJECT_TYPE, + ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE, +} from './saved_objects'; +import { ACTIONS_FEATURE } from './feature'; +import { ActionsAuthorization } from './authorization/actions_authorization'; +import { ActionsAuthorizationAuditLogger } from './authorization/audit_logger'; const EVENT_LOG_PROVIDER = 'actions'; export const EVENT_LOG_ACTIONS = { @@ -68,6 +77,7 @@ export interface PluginStartContract { isActionTypeEnabled(id: string): boolean; isActionExecutable(actionId: string, actionTypeId: string): boolean; getActionsClientWithRequest(request: KibanaRequest): Promise>; + getActionsAuthorizationWithRequest(request: KibanaRequest): PublicMethodsOf; preconfiguredActions: PreConfiguredAction[]; } @@ -78,13 +88,15 @@ export interface ActionsPluginsSetup { spaces?: SpacesPluginSetup; eventLog: IEventLogService; usageCollection?: UsageCollectionSetup; + security?: SecurityPluginSetup; + features: FeaturesPluginSetup; } export interface ActionsPluginsStart { encryptedSavedObjects: EncryptedSavedObjectsPluginStart; taskManager: TaskManagerStartContract; } -const includedHiddenTypes = ['action', 'action_task_params']; +const includedHiddenTypes = [ACTION_SAVED_OBJECT_TYPE, ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE]; export class ActionsPlugin implements Plugin, PluginStartContract> { private readonly kibanaIndex: Promise; @@ -97,6 +109,7 @@ export class ActionsPlugin implements Plugin, Plugi private actionExecutor?: ActionExecutor; private licenseState: ILicenseState | null = null; private spaces?: SpacesServiceSetup; + private security?: SecurityPluginSetup; private eventLogger?: IEventLogger; private isESOUsingEphemeralEncryptionKey?: boolean; private readonly telemetryLogger: Logger; @@ -131,6 +144,7 @@ export class ActionsPlugin implements Plugin, Plugi ); } + plugins.features.registerFeature(ACTIONS_FEATURE); setupSavedObjects(core.savedObjects, plugins.encryptedSavedObjects); plugins.eventLog.registerProviderActions(EVENT_LOG_PROVIDER, Object.values(EVENT_LOG_ACTIONS)); @@ -167,6 +181,7 @@ export class ActionsPlugin implements Plugin, Plugi this.serverBasePath = core.http.basePath.serverBasePath; this.actionExecutor = actionExecutor; this.spaces = plugins.spaces?.spacesService; + this.security = plugins.security; registerBuiltInActionTypes({ logger: this.logger, @@ -227,16 +242,39 @@ export class ActionsPlugin implements Plugin, Plugi kibanaIndex, isESOUsingEphemeralEncryptionKey, preconfiguredActions, + instantiateAuthorization, } = this; const encryptedSavedObjectsClient = plugins.encryptedSavedObjects.getClient({ includedHiddenTypes, }); - const getScopedSavedObjectsClient = (request: KibanaRequest) => - core.savedObjects.getScopedClient(request, { - includedHiddenTypes, + const getActionsClientWithRequest = async (request: KibanaRequest) => { + if (isESOUsingEphemeralEncryptionKey === true) { + throw new Error( + `Unable to create actions client due to the Encrypted Saved Objects plugin using an ephemeral encryption key. Please set xpack.encryptedSavedObjects.encryptionKey in kibana.yml` + ); + } + return new ActionsClient({ + unsecuredSavedObjectsClient: core.savedObjects.getScopedClient(request, { + excludedWrappers: ['security'], + includedHiddenTypes, + }), + actionTypeRegistry: actionTypeRegistry!, + defaultKibanaIndex: await kibanaIndex, + scopedClusterClient: core.elasticsearch.legacy.client.asScoped(request), + preconfiguredActions, + request, + authorization: instantiateAuthorization(request), + actionExecutor: actionExecutor!, + executionEnqueuer: createExecutionEnqueuerFunction({ + taskManager: plugins.taskManager, + actionTypeRegistry: actionTypeRegistry!, + isESOUsingEphemeralEncryptionKey: isESOUsingEphemeralEncryptionKey!, + preconfiguredActions, + }), }); + }; const getScopedSavedObjectsClientWithoutAccessToActions = (request: KibanaRequest) => core.savedObjects.getScopedClient(request); @@ -245,7 +283,7 @@ export class ActionsPlugin implements Plugin, Plugi logger, eventLogger: this.eventLogger!, spaces: this.spaces, - getScopedSavedObjectsClient, + getActionsClientWithRequest, getServices: this.getServicesFactory( getScopedSavedObjectsClientWithoutAccessToActions, core.elasticsearch @@ -261,7 +299,10 @@ export class ActionsPlugin implements Plugin, Plugi encryptedSavedObjectsClient, getBasePath: this.getBasePath, spaceIdToNamespace: this.spaceIdToNamespace, - getScopedSavedObjectsClient, + getScopedSavedObjectsClient: (request: KibanaRequest) => + core.savedObjects.getScopedClient(request, { + includedHiddenTypes, + }), }); scheduleActionsTelemetry(this.telemetryLogger, plugins.taskManager); @@ -273,33 +314,24 @@ export class ActionsPlugin implements Plugin, Plugi isActionExecutable: (actionId: string, actionTypeId: string) => { return this.actionTypeRegistry!.isActionExecutable(actionId, actionTypeId); }, - // Ability to get an actions client from legacy code - async getActionsClientWithRequest(request: KibanaRequest) { - if (isESOUsingEphemeralEncryptionKey === true) { - throw new Error( - `Unable to create actions client due to the Encrypted Saved Objects plugin using an ephemeral encryption key. Please set xpack.encryptedSavedObjects.encryptionKey in kibana.yml` - ); - } - return new ActionsClient({ - savedObjectsClient: getScopedSavedObjectsClient(request), - actionTypeRegistry: actionTypeRegistry!, - defaultKibanaIndex: await kibanaIndex, - scopedClusterClient: core.elasticsearch.legacy.client.asScoped(request), - preconfiguredActions, - request, - actionExecutor: actionExecutor!, - executionEnqueuer: createExecutionEnqueuerFunction({ - taskManager: plugins.taskManager, - actionTypeRegistry: actionTypeRegistry!, - isESOUsingEphemeralEncryptionKey: isESOUsingEphemeralEncryptionKey!, - preconfiguredActions, - }), - }); + getActionsAuthorizationWithRequest(request: KibanaRequest) { + return instantiateAuthorization(request); }, + getActionsClientWithRequest, preconfiguredActions, }; } + private instantiateAuthorization = (request: KibanaRequest) => { + return new ActionsAuthorization({ + request, + authorization: this.security?.authz, + auditLogger: new ActionsAuthorizationAuditLogger( + this.security?.audit.getLogger(ACTIONS_FEATURE.id) + ), + }); + }; + private getServicesFactory( getScopedClient: (request: KibanaRequest) => SavedObjectsClientContract, elasticsearch: ElasticsearchServiceStart @@ -322,6 +354,7 @@ export class ActionsPlugin implements Plugin, Plugi isESOUsingEphemeralEncryptionKey, preconfiguredActions, actionExecutor, + instantiateAuthorization, } = this; return async function actionsRouteHandlerContext(context, request) { @@ -334,12 +367,16 @@ export class ActionsPlugin implements Plugin, Plugi ); } return new ActionsClient({ - savedObjectsClient: savedObjects.getScopedClient(request, { includedHiddenTypes }), + unsecuredSavedObjectsClient: savedObjects.getScopedClient(request, { + excludedWrappers: ['security'], + includedHiddenTypes, + }), actionTypeRegistry: actionTypeRegistry!, defaultKibanaIndex, scopedClusterClient: context.core.elasticsearch.legacy.client, preconfiguredActions, request, + authorization: instantiateAuthorization(request), actionExecutor: actionExecutor!, executionEnqueuer: createExecutionEnqueuerFunction({ taskManager, diff --git a/x-pack/plugins/actions/server/routes/create.test.ts b/x-pack/plugins/actions/server/routes/create.test.ts index 940b8ecc61f4..76f2a79c9f3e 100644 --- a/x-pack/plugins/actions/server/routes/create.test.ts +++ b/x-pack/plugins/actions/server/routes/create.test.ts @@ -28,13 +28,6 @@ describe('createActionRoute', () => { const [config, handler] = router.post.mock.calls[0]; expect(config.path).toMatchInlineSnapshot(`"/api/actions/action"`); - expect(config.options).toMatchInlineSnapshot(` - Object { - "tags": Array [ - "access:actions-all", - ], - } - `); const createResult = { id: '1', diff --git a/x-pack/plugins/actions/server/routes/create.ts b/x-pack/plugins/actions/server/routes/create.ts index 813556715758..462d3f42b506 100644 --- a/x-pack/plugins/actions/server/routes/create.ts +++ b/x-pack/plugins/actions/server/routes/create.ts @@ -30,9 +30,6 @@ export const createActionRoute = (router: IRouter, licenseState: ILicenseState) validate: { body: bodySchema, }, - options: { - tags: ['access:actions-all'], - }, }, router.handleLegacyErrors(async function ( context: RequestHandlerContext, diff --git a/x-pack/plugins/actions/server/routes/delete.test.ts b/x-pack/plugins/actions/server/routes/delete.test.ts index 8d759f1a7565..3bd2d93f255d 100644 --- a/x-pack/plugins/actions/server/routes/delete.test.ts +++ b/x-pack/plugins/actions/server/routes/delete.test.ts @@ -28,13 +28,6 @@ describe('deleteActionRoute', () => { const [config, handler] = router.delete.mock.calls[0]; expect(config.path).toMatchInlineSnapshot(`"/api/actions/action/{id}"`); - expect(config.options).toMatchInlineSnapshot(` - Object { - "tags": Array [ - "access:actions-all", - ], - } - `); const actionsClient = actionsClientMock.create(); actionsClient.delete.mockResolvedValueOnce({}); diff --git a/x-pack/plugins/actions/server/routes/delete.ts b/x-pack/plugins/actions/server/routes/delete.ts index 9d4fa4019744..a7303247e95b 100644 --- a/x-pack/plugins/actions/server/routes/delete.ts +++ b/x-pack/plugins/actions/server/routes/delete.ts @@ -31,9 +31,6 @@ export const deleteActionRoute = (router: IRouter, licenseState: ILicenseState) validate: { params: paramSchema, }, - options: { - tags: ['access:actions-all'], - }, }, router.handleLegacyErrors(async function ( context: RequestHandlerContext, diff --git a/x-pack/plugins/actions/server/routes/execute.test.ts b/x-pack/plugins/actions/server/routes/execute.test.ts index 6e8ebbf6f91c..38fca656bef5 100644 --- a/x-pack/plugins/actions/server/routes/execute.test.ts +++ b/x-pack/plugins/actions/server/routes/execute.test.ts @@ -53,13 +53,6 @@ describe('executeActionRoute', () => { const [config, handler] = router.post.mock.calls[0]; expect(config.path).toMatchInlineSnapshot(`"/api/actions/action/{id}/_execute"`); - expect(config.options).toMatchInlineSnapshot(` - Object { - "tags": Array [ - "access:actions-read", - ], - } - `); expect(await handler(context, req, res)).toEqual({ body: executeResult }); diff --git a/x-pack/plugins/actions/server/routes/execute.ts b/x-pack/plugins/actions/server/routes/execute.ts index 28e6a54f5e92..0d49d9a3a256 100644 --- a/x-pack/plugins/actions/server/routes/execute.ts +++ b/x-pack/plugins/actions/server/routes/execute.ts @@ -32,9 +32,6 @@ export const executeActionRoute = (router: IRouter, licenseState: ILicenseState) body: bodySchema, params: paramSchema, }, - options: { - tags: ['access:actions-read'], - }, }, router.handleLegacyErrors(async function ( context: RequestHandlerContext, diff --git a/x-pack/plugins/actions/server/routes/get.test.ts b/x-pack/plugins/actions/server/routes/get.test.ts index ee2586851366..434bd6a9bc22 100644 --- a/x-pack/plugins/actions/server/routes/get.test.ts +++ b/x-pack/plugins/actions/server/routes/get.test.ts @@ -29,13 +29,6 @@ describe('getActionRoute', () => { const [config, handler] = router.get.mock.calls[0]; expect(config.path).toMatchInlineSnapshot(`"/api/actions/action/{id}"`); - expect(config.options).toMatchInlineSnapshot(` - Object { - "tags": Array [ - "access:actions-read", - ], - } - `); const getResult = { id: '1', diff --git a/x-pack/plugins/actions/server/routes/get.ts b/x-pack/plugins/actions/server/routes/get.ts index 224de241c737..33577fad87c0 100644 --- a/x-pack/plugins/actions/server/routes/get.ts +++ b/x-pack/plugins/actions/server/routes/get.ts @@ -26,9 +26,6 @@ export const getActionRoute = (router: IRouter, licenseState: ILicenseState) => validate: { params: paramSchema, }, - options: { - tags: ['access:actions-read'], - }, }, router.handleLegacyErrors(async function ( context: RequestHandlerContext, diff --git a/x-pack/plugins/actions/server/routes/get_all.test.ts b/x-pack/plugins/actions/server/routes/get_all.test.ts index 6550921278aa..35db22d2da48 100644 --- a/x-pack/plugins/actions/server/routes/get_all.test.ts +++ b/x-pack/plugins/actions/server/routes/get_all.test.ts @@ -29,13 +29,6 @@ describe('getAllActionRoute', () => { const [config, handler] = router.get.mock.calls[0]; expect(config.path).toMatchInlineSnapshot(`"/api/actions"`); - expect(config.options).toMatchInlineSnapshot(` - Object { - "tags": Array [ - "access:actions-read", - ], - } - `); const actionsClient = actionsClientMock.create(); actionsClient.getAll.mockResolvedValueOnce([]); @@ -64,13 +57,6 @@ describe('getAllActionRoute', () => { const [config, handler] = router.get.mock.calls[0]; expect(config.path).toMatchInlineSnapshot(`"/api/actions"`); - expect(config.options).toMatchInlineSnapshot(` - Object { - "tags": Array [ - "access:actions-read", - ], - } - `); const actionsClient = actionsClientMock.create(); actionsClient.getAll.mockResolvedValueOnce([]); @@ -95,13 +81,6 @@ describe('getAllActionRoute', () => { const [config, handler] = router.get.mock.calls[0]; expect(config.path).toMatchInlineSnapshot(`"/api/actions"`); - expect(config.options).toMatchInlineSnapshot(` - Object { - "tags": Array [ - "access:actions-read", - ], - } - `); const actionsClient = actionsClientMock.create(); actionsClient.getAll.mockResolvedValueOnce([]); diff --git a/x-pack/plugins/actions/server/routes/get_all.ts b/x-pack/plugins/actions/server/routes/get_all.ts index 03a4a97855b6..1b57f31d14a0 100644 --- a/x-pack/plugins/actions/server/routes/get_all.ts +++ b/x-pack/plugins/actions/server/routes/get_all.ts @@ -19,9 +19,6 @@ export const getAllActionRoute = (router: IRouter, licenseState: ILicenseState) { path: `${BASE_ACTION_API_PATH}`, validate: {}, - options: { - tags: ['access:actions-read'], - }, }, router.handleLegacyErrors(async function ( context: RequestHandlerContext, diff --git a/x-pack/plugins/actions/server/routes/list_action_types.test.ts b/x-pack/plugins/actions/server/routes/list_action_types.test.ts index f231efe1a07f..982b64c339a5 100644 --- a/x-pack/plugins/actions/server/routes/list_action_types.test.ts +++ b/x-pack/plugins/actions/server/routes/list_action_types.test.ts @@ -10,6 +10,7 @@ import { licenseStateMock } from '../lib/license_state.mock'; import { verifyApiAccess } from '../lib'; import { mockHandlerArguments } from './_mock_handler_arguments'; import { LicenseType } from '../../../../plugins/licensing/server'; +import { actionsClientMock } from '../mocks'; jest.mock('../lib/verify_api_access.ts', () => ({ verifyApiAccess: jest.fn(), @@ -29,13 +30,6 @@ describe('listActionTypesRoute', () => { const [config, handler] = router.get.mock.calls[0]; expect(config.path).toMatchInlineSnapshot(`"/api/actions/list_action_types"`); - expect(config.options).toMatchInlineSnapshot(` - Object { - "tags": Array [ - "access:actions-read", - ], - } - `); const listTypes = [ { @@ -48,7 +42,9 @@ describe('listActionTypesRoute', () => { }, ]; - const [context, req, res] = mockHandlerArguments({ listTypes }, {}, ['ok']); + const actionsClient = actionsClientMock.create(); + actionsClient.listTypes.mockResolvedValueOnce(listTypes); + const [context, req, res] = mockHandlerArguments({ actionsClient }, {}, ['ok']); expect(await handler(context, req, res)).toMatchInlineSnapshot(` Object { @@ -65,8 +61,6 @@ describe('listActionTypesRoute', () => { } `); - expect(context.actions!.listTypes).toHaveBeenCalledTimes(1); - expect(res.ok).toHaveBeenCalledWith({ body: listTypes, }); @@ -81,13 +75,6 @@ describe('listActionTypesRoute', () => { const [config, handler] = router.get.mock.calls[0]; expect(config.path).toMatchInlineSnapshot(`"/api/actions/list_action_types"`); - expect(config.options).toMatchInlineSnapshot(` - Object { - "tags": Array [ - "access:actions-read", - ], - } - `); const listTypes = [ { @@ -100,8 +87,11 @@ describe('listActionTypesRoute', () => { }, ]; + const actionsClient = actionsClientMock.create(); + actionsClient.listTypes.mockResolvedValueOnce(listTypes); + const [context, req, res] = mockHandlerArguments( - { listTypes }, + { actionsClient }, { params: { id: '1' }, }, @@ -126,13 +116,6 @@ describe('listActionTypesRoute', () => { const [config, handler] = router.get.mock.calls[0]; expect(config.path).toMatchInlineSnapshot(`"/api/actions/list_action_types"`); - expect(config.options).toMatchInlineSnapshot(` - Object { - "tags": Array [ - "access:actions-read", - ], - } - `); const listTypes = [ { @@ -145,8 +128,11 @@ describe('listActionTypesRoute', () => { }, ]; + const actionsClient = actionsClientMock.create(); + actionsClient.listTypes.mockResolvedValueOnce(listTypes); + const [context, req, res] = mockHandlerArguments( - { listTypes }, + { actionsClient }, { params: { id: '1' }, }, diff --git a/x-pack/plugins/actions/server/routes/list_action_types.ts b/x-pack/plugins/actions/server/routes/list_action_types.ts index bfb5fabe127f..c960a6bac6de 100644 --- a/x-pack/plugins/actions/server/routes/list_action_types.ts +++ b/x-pack/plugins/actions/server/routes/list_action_types.ts @@ -19,9 +19,6 @@ export const listActionTypesRoute = (router: IRouter, licenseState: ILicenseStat { path: `${BASE_ACTION_API_PATH}/list_action_types`, validate: {}, - options: { - tags: ['access:actions-read'], - }, }, router.handleLegacyErrors(async function ( context: RequestHandlerContext, @@ -32,8 +29,9 @@ export const listActionTypesRoute = (router: IRouter, licenseState: ILicenseStat if (!context.actions) { return res.badRequest({ body: 'RouteHandlerContext is not registered for actions' }); } + const actionsClient = context.actions.getActionsClient(); return res.ok({ - body: context.actions.listTypes(), + body: await actionsClient.listTypes(), }); }) ); diff --git a/x-pack/plugins/actions/server/routes/update.test.ts b/x-pack/plugins/actions/server/routes/update.test.ts index 323a52f2fc6e..6d5b78650ba2 100644 --- a/x-pack/plugins/actions/server/routes/update.test.ts +++ b/x-pack/plugins/actions/server/routes/update.test.ts @@ -28,13 +28,6 @@ describe('updateActionRoute', () => { const [config, handler] = router.put.mock.calls[0]; expect(config.path).toMatchInlineSnapshot(`"/api/actions/action/{id}"`); - expect(config.options).toMatchInlineSnapshot(` - Object { - "tags": Array [ - "access:actions-all", - ], - } - `); const updateResult = { id: '1', diff --git a/x-pack/plugins/actions/server/routes/update.ts b/x-pack/plugins/actions/server/routes/update.ts index 1e107a4d6edb..328ce74ef0b0 100644 --- a/x-pack/plugins/actions/server/routes/update.ts +++ b/x-pack/plugins/actions/server/routes/update.ts @@ -33,9 +33,6 @@ export const updateActionRoute = (router: IRouter, licenseState: ILicenseState) body: bodySchema, params: paramSchema, }, - options: { - tags: ['access:actions-all'], - }, }, router.handleLegacyErrors(async function ( context: RequestHandlerContext, diff --git a/x-pack/plugins/actions/server/saved_objects/index.ts b/x-pack/plugins/actions/server/saved_objects/index.ts index d68c96a5e927..54f186acc1ba 100644 --- a/x-pack/plugins/actions/server/saved_objects/index.ts +++ b/x-pack/plugins/actions/server/saved_objects/index.ts @@ -8,12 +8,15 @@ import { SavedObjectsServiceSetup } from 'kibana/server'; import mappings from './mappings.json'; import { EncryptedSavedObjectsPluginSetup } from '../../../encrypted_saved_objects/server'; +export const ACTION_SAVED_OBJECT_TYPE = 'action'; +export const ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE = 'action_task_params'; + export function setupSavedObjects( savedObjects: SavedObjectsServiceSetup, encryptedSavedObjects: EncryptedSavedObjectsPluginSetup ) { savedObjects.registerType({ - name: 'action', + name: ACTION_SAVED_OBJECT_TYPE, hidden: true, namespaceType: 'single', mappings: mappings.action, @@ -24,19 +27,19 @@ export function setupSavedObjects( // - `config` will be included in AAD // - everything else excluded from AAD encryptedSavedObjects.registerType({ - type: 'action', + type: ACTION_SAVED_OBJECT_TYPE, attributesToEncrypt: new Set(['secrets']), attributesToExcludeFromAAD: new Set(['name']), }); savedObjects.registerType({ - name: 'action_task_params', + name: ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE, hidden: true, namespaceType: 'single', mappings: mappings.action_task_params, }); encryptedSavedObjects.registerType({ - type: 'action_task_params', + type: ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE, attributesToEncrypt: new Set(['apiKey']), }); } diff --git a/x-pack/plugins/alerting_builtins/common/index.ts b/x-pack/plugins/alerting_builtins/common/index.ts new file mode 100644 index 000000000000..4f2c16666935 --- /dev/null +++ b/x-pack/plugins/alerting_builtins/common/index.ts @@ -0,0 +1,7 @@ +/* + * 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. + */ + +export const BUILT_IN_ALERTS_FEATURE_ID = 'builtInAlerts'; diff --git a/x-pack/plugins/alerting_builtins/kibana.json b/x-pack/plugins/alerting_builtins/kibana.json index cc613d5247ef..dd70e53604f1 100644 --- a/x-pack/plugins/alerting_builtins/kibana.json +++ b/x-pack/plugins/alerting_builtins/kibana.json @@ -3,7 +3,7 @@ "server": true, "version": "8.0.0", "kibanaVersion": "kibana", - "requiredPlugins": ["alerts"], + "requiredPlugins": ["alerts", "features"], "configPath": ["xpack", "alerting_builtins"], "ui": false } diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type.ts index 1a5da8a422b9..153334cb6404 100644 --- a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type.ts +++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type.ts @@ -9,11 +9,11 @@ import { AlertType, AlertExecutorOptions } from '../../types'; import { Params, ParamsSchema } from './alert_type_params'; import { BaseActionContext, addMessages } from './action_context'; import { TimeSeriesQuery } from './lib/time_series_query'; +import { Service } from '../../types'; +import { BUILT_IN_ALERTS_FEATURE_ID } from '../../../common'; export const ID = '.index-threshold'; -import { Service } from '../../types'; - const ActionGroupId = 'threshold met'; const ComparatorFns = getComparatorFns(); export const ComparatorFnNames = new Set(ComparatorFns.keys()); @@ -85,7 +85,7 @@ export function getAlertType(service: Service): AlertType { ], }, executor, - producer: 'alerting', + producer: BUILT_IN_ALERTS_FEATURE_ID, }; async function executor(options: AlertExecutorOptions) { diff --git a/x-pack/plugins/alerting_builtins/server/feature.ts b/x-pack/plugins/alerting_builtins/server/feature.ts new file mode 100644 index 000000000000..669d2ba62705 --- /dev/null +++ b/x-pack/plugins/alerting_builtins/server/feature.ts @@ -0,0 +1,49 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { ID as IndexThreshold } from './alert_types/index_threshold/alert_type'; +import { BUILT_IN_ALERTS_FEATURE_ID } from '../common'; + +export const BUILT_IN_ALERTS_FEATURE = { + id: BUILT_IN_ALERTS_FEATURE_ID, + name: i18n.translate('xpack.alertingBuiltins.featureRegistry.actionsFeatureName', { + defaultMessage: 'Built-In Alerts', + }), + icon: 'bell', + app: [], + alerting: [IndexThreshold], + privileges: { + all: { + app: [], + catalogue: [], + alerting: { + all: [IndexThreshold], + read: [], + }, + savedObject: { + all: [], + read: [], + }, + api: [], + ui: ['alerting:show'], + }, + read: { + app: [], + catalogue: [], + alerting: { + all: [], + read: [IndexThreshold], + }, + savedObject: { + all: [], + read: [], + }, + api: [], + ui: ['alerting:show'], + }, + }, +}; diff --git a/x-pack/plugins/alerting_builtins/server/index.ts b/x-pack/plugins/alerting_builtins/server/index.ts index 00613213d5ae..108393c0d146 100644 --- a/x-pack/plugins/alerting_builtins/server/index.ts +++ b/x-pack/plugins/alerting_builtins/server/index.ts @@ -7,6 +7,7 @@ import { PluginInitializerContext } from 'src/core/server'; import { AlertingBuiltinsPlugin } from './plugin'; import { configSchema } from './config'; +export { ID as INDEX_THRESHOLD_ID } from './alert_types/index_threshold/alert_type'; export const plugin = (ctx: PluginInitializerContext) => new AlertingBuiltinsPlugin(ctx); diff --git a/x-pack/plugins/alerting_builtins/server/plugin.test.ts b/x-pack/plugins/alerting_builtins/server/plugin.test.ts index 71a904dcbab3..15ad06652350 100644 --- a/x-pack/plugins/alerting_builtins/server/plugin.test.ts +++ b/x-pack/plugins/alerting_builtins/server/plugin.test.ts @@ -7,6 +7,8 @@ import { AlertingBuiltinsPlugin } from './plugin'; import { coreMock } from '../../../../src/core/server/mocks'; import { alertsMock } from '../../alerts/server/mocks'; +import { featuresPluginMock } from '../../features/server/mocks'; +import { BUILT_IN_ALERTS_FEATURE } from './feature'; describe('AlertingBuiltins Plugin', () => { describe('setup()', () => { @@ -22,7 +24,8 @@ describe('AlertingBuiltins Plugin', () => { it('should register built-in alert types', async () => { const alertingSetup = alertsMock.createSetup(); - await plugin.setup(coreSetup, { alerts: alertingSetup }); + const featuresSetup = featuresPluginMock.createSetup(); + await plugin.setup(coreSetup, { alerts: alertingSetup, features: featuresSetup }); expect(alertingSetup.registerType).toHaveBeenCalledTimes(1); @@ -40,11 +43,16 @@ describe('AlertingBuiltins Plugin', () => { "name": "Index threshold", } `); + expect(featuresSetup.registerFeature).toHaveBeenCalledWith(BUILT_IN_ALERTS_FEATURE); }); it('should return a service in the expected shape', async () => { const alertingSetup = alertsMock.createSetup(); - const service = await plugin.setup(coreSetup, { alerts: alertingSetup }); + const featuresSetup = featuresPluginMock.createSetup(); + const service = await plugin.setup(coreSetup, { + alerts: alertingSetup, + features: featuresSetup, + }); expect(typeof service.indexThreshold.timeSeriesQuery).toBe('function'); }); diff --git a/x-pack/plugins/alerting_builtins/server/plugin.ts b/x-pack/plugins/alerting_builtins/server/plugin.ts index 12d1b080c7c6..41871c01bfb5 100644 --- a/x-pack/plugins/alerting_builtins/server/plugin.ts +++ b/x-pack/plugins/alerting_builtins/server/plugin.ts @@ -9,6 +9,7 @@ import { Plugin, Logger, CoreSetup, CoreStart, PluginInitializerContext } from ' import { Service, IService, AlertingBuiltinsDeps } from './types'; import { getService as getServiceIndexThreshold } from './alert_types/index_threshold'; import { registerBuiltInAlertTypes } from './alert_types'; +import { BUILT_IN_ALERTS_FEATURE } from './feature'; export class AlertingBuiltinsPlugin implements Plugin { private readonly logger: Logger; @@ -22,7 +23,12 @@ export class AlertingBuiltinsPlugin implements Plugin { }; } - public async setup(core: CoreSetup, { alerts }: AlertingBuiltinsDeps): Promise { + public async setup( + core: CoreSetup, + { alerts, features }: AlertingBuiltinsDeps + ): Promise { + features.registerFeature(BUILT_IN_ALERTS_FEATURE); + registerBuiltInAlertTypes({ service: this.service, router: core.http.createRouter(), diff --git a/x-pack/plugins/alerting_builtins/server/types.ts b/x-pack/plugins/alerting_builtins/server/types.ts index 1fb5314ca4fd..f3abc26be8da 100644 --- a/x-pack/plugins/alerting_builtins/server/types.ts +++ b/x-pack/plugins/alerting_builtins/server/types.ts @@ -15,10 +15,12 @@ export { AlertType, AlertExecutorOptions, } from '../../alerts/server'; +import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; // this plugin's dependendencies export interface AlertingBuiltinsDeps { alerts: AlertingSetup; + features: FeaturesPluginSetup; } // external service exposed through plugin setup/start diff --git a/x-pack/plugins/alerts/README.md b/x-pack/plugins/alerts/README.md index 0464ec78a4e9..10568abbe3c7 100644 --- a/x-pack/plugins/alerts/README.md +++ b/x-pack/plugins/alerts/README.md @@ -18,6 +18,7 @@ Table of Contents - [Methods](#methods) - [Executor](#executor) - [Example](#example) + - [Role Based Access-Control](#role-based-access-control) - [Alert Navigation](#alert-navigation) - [RESTful API](#restful-api) - [`POST /api/alerts/alert`: Create alert](#post-apialert-create-alert) @@ -58,7 +59,8 @@ A Kibana alert detects a condition and executes one or more actions when that co ## Usage 1. Develop and register an alert type (see alert types -> example). -2. Create an alert using the RESTful API (see alerts -> create). +2. Configure feature level privileges using RBAC +3. Create an alert using the RESTful API (see alerts -> create). ## Limitations @@ -293,6 +295,111 @@ server.newPlatform.setup.plugins.alerts.registerType({ }); ``` +## Role Based Access-Control +Once you have registered your AlertType, you need to grant your users privileges to use it. +When registering a feature in Kibana you can specify multiple types of privileges which are granted to users when they're assigned certain roles. + +Assuming your feature introduces its own AlertTypes, you'll want to control which roles have all/read privileges for these AlertTypes when they're inside the feature. +In addition, when users are inside your feature you might want to grant them access to AlertTypes from other features, such as built-in AlertTypes or AlertTypes provided by other features. + +You can control all of these abilities by assigning privileges to the Alerting Framework from within your own feature, for example: + +```typescript +features.registerFeature({ + id: 'my-application-id', + name: 'My Application', + app: [], + privileges: { + all: { + alerting: { + all: [ + // grant `all` over our own types + 'my-application-id.my-alert-type', + 'my-application-id.my-restricted-alert-type', + // grant `all` over the built-in IndexThreshold + '.index-threshold', + // grant `all` over Uptime's TLS AlertType + 'xpack.uptime.alerts.actionGroups.tls' + ], + }, + }, + read: { + alerting: { + read: [ + // grant `read` over our own type + 'my-application-id.my-alert-type', + // grant `read` over the built-in IndexThreshold + '.index-threshold', + // grant `read` over Uptime's TLS AlertType + 'xpack.uptime.alerts.actionGroups.tls' + ], + }, + }, + }, +}); +``` + +In this example we can see the following: +- Our feature grants any user who's assigned the `all` role in our feature the `all` role in the Alerting framework over every alert of the `my-application-id.my-alert-type` type which is created _inside_ the feature. What that means is that this privilege will allow the user to execute any of the `all` operations (listed below) on these alerts as long as their `consumer` is `my-application-id`. Below that you'll notice we've done the same with the `read` role, which is grants the Alerting Framework's `read` role privileges over these very same alerts. +- In addition, our feature grants the same privileges over any alert of type `my-application-id.my-restricted-alert-type`, which is another hypothetical alertType registered by this feature. It's worth noting though that this type has been omitted from the `read` role. What this means is that only users with the `all` role will be able to interact with alerts of this type. +- Next, lets look at the `.index-threshold` and `xpack.uptime.alerts.actionGroups.tls` types. These have been specified in both `read` and `all`, which means that all the users in the feature will gain privileges over alerts of these types (as long as their `consumer` is `my-application-id`). The difference between these two and the previous two is that they are _produced_ by other features! `.index-threshold` is a built-in type, provided by the _Built-In Alerts_ feature, and `xpack.uptime.alerts.actionGroups.tls` is an AlertType provided by the _Uptime_ feature. Specifying these type here tells the Alerting Framework that as far as the `my-application-id` feature is concerned, the user is privileged to use them (with `all` and `read` applied), but that isn't enough. Using another feature's AlertType is only possible if both the producer of the AlertType, and the consumer of the AlertType, explicitly grant privileges to do so. In this case, the _Built-In Alerts_ & _Uptime_ features would have to explicitly add these privileges to a role and this role would have to be granted to this user. + +It's important to note that any role can be granted a mix of `all` and `read` privileges accross multiple type, for example: + +```typescript +features.registerFeature({ + id: 'my-application-id', + name: 'My Application', + app: [], + privileges: { + all: { + alerting: { + all: [ + 'my-application-id.my-alert-type', + 'my-application-id.my-restricted-alert-type' + ], + }, + }, + read: { + alerting: { + all: [ + 'my-application-id.my-alert-type' + ] + read: [ + 'my-application-id.my-restricted-alert-type' + ], + }, + }, + }, +}); +``` + +In the above example, you note that instead of denying users with the `read` role any access to the `my-application-id.my-restricted-alert-type` type, we've decided that these users _should_ be granted `read` privileges over the _resitricted_ AlertType. +As part of that same change, we also decided that not only should they be allowed to `read` the _restricted_ AlertType, but actually, despite having `read` privileges to the feature as a whole, we do actually want to allow them to create our basic 'my-application-id.my-alert-type' AlertType, as we consider it an extension of _reading_ data in our feature, rather than _writing_ it. + +### `read` privileges vs. `all` privileges +When a user is granted the `read` role in the Alerting Framework, they will be able to execute the following api calls: +- `get` +- `getAlertState` +- `find` + +When a user is granted the `all` role in the Alerting Framework, they will be able to execute all of the `read` privileged api calls, but in addition they'll be granted the following calls: +- `create` +- `delete` +- `update` +- `enable` +- `disable` +- `updateApiKey` +- `muteAll` +- `unmuteAll` +- `muteInstance` +- `unmuteInstance` + +Finally, all users, whether they're granted any role or not, are privileged to call the following: +- `listAlertTypes`, but the output is limited to displaying the AlertTypes the user is perivileged to `get` + +Attempting to execute any operation the user isn't privileged to execute will result in an Authorization error thrown by the AlertsClient. + ## Alert Navigation When registering an Alert Type, you'll likely want to provide a way of viewing alerts of that type within your own plugin, or perhaps you want to provide a view for all alerts created from within your solution within your own UI. diff --git a/x-pack/plugins/alerts/common/index.ts b/x-pack/plugins/alerts/common/index.ts index 88a8da5a3e57..b839c07a9db8 100644 --- a/x-pack/plugins/alerts/common/index.ts +++ b/x-pack/plugins/alerts/common/index.ts @@ -21,3 +21,4 @@ export interface AlertingFrameworkHealth { } export const BASE_ALERT_API_PATH = '/api/alerts'; +export const ALERTS_FEATURE_ID = 'alerts'; diff --git a/x-pack/plugins/alerts/kibana.json b/x-pack/plugins/alerts/kibana.json index eef61ff4b3d5..c0ab24283142 100644 --- a/x-pack/plugins/alerts/kibana.json +++ b/x-pack/plugins/alerts/kibana.json @@ -5,7 +5,7 @@ "version": "8.0.0", "kibanaVersion": "kibana", "configPath": ["xpack", "alerts"], - "requiredPlugins": ["licensing", "taskManager", "encryptedSavedObjects", "actions", "eventLog"], + "requiredPlugins": ["licensing", "taskManager", "encryptedSavedObjects", "actions", "eventLog", "features"], "optionalPlugins": ["usageCollection", "spaces", "security"], "extraPublicDirs": ["common", "common/parse_duration"] } diff --git a/x-pack/plugins/alerts/public/alert_api.test.ts b/x-pack/plugins/alerts/public/alert_api.test.ts index 45b9f5ba8fe2..3ee67b79b7bd 100644 --- a/x-pack/plugins/alerts/public/alert_api.test.ts +++ b/x-pack/plugins/alerts/public/alert_api.test.ts @@ -22,7 +22,7 @@ describe('loadAlertTypes', () => { actionVariables: ['var1'], actionGroups: [{ id: 'default', name: 'Default' }], defaultActionGroupId: 'default', - producer: 'alerting', + producer: 'alerts', }, ]; http.get.mockResolvedValueOnce(resolvedValue); @@ -45,7 +45,7 @@ describe('loadAlertType', () => { actionVariables: ['var1'], actionGroups: [{ id: 'default', name: 'Default' }], defaultActionGroupId: 'default', - producer: 'alerting', + producer: 'alerts', }; http.get.mockResolvedValueOnce([alertType]); @@ -65,7 +65,7 @@ describe('loadAlertType', () => { actionVariables: [], actionGroups: [{ id: 'default', name: 'Default' }], defaultActionGroupId: 'default', - producer: 'alerting', + producer: 'alerts', }; http.get.mockResolvedValueOnce([alertType]); @@ -80,7 +80,7 @@ describe('loadAlertType', () => { actionVariables: [], actionGroups: [{ id: 'default', name: 'Default' }], defaultActionGroupId: 'default', - producer: 'alerting', + producer: 'alerts', }, ]); diff --git a/x-pack/plugins/alerts/public/alert_navigation_registry/alert_navigation_registry.test.ts b/x-pack/plugins/alerts/public/alert_navigation_registry/alert_navigation_registry.test.ts index ff8a3a1c311c..72c955923a0c 100644 --- a/x-pack/plugins/alerts/public/alert_navigation_registry/alert_navigation_registry.test.ts +++ b/x-pack/plugins/alerts/public/alert_navigation_registry/alert_navigation_registry.test.ts @@ -16,7 +16,7 @@ const mockAlertType = (id: string): AlertType => ({ actionGroups: [], actionVariables: [], defaultActionGroupId: 'default', - producer: 'alerting', + producer: 'alerts', }); describe('AlertNavigationRegistry', () => { diff --git a/x-pack/plugins/alerts/server/alert_type_registry.test.ts b/x-pack/plugins/alerts/server/alert_type_registry.test.ts index 6d7cf621ab0c..c74039071371 100644 --- a/x-pack/plugins/alerts/server/alert_type_registry.test.ts +++ b/x-pack/plugins/alerts/server/alert_type_registry.test.ts @@ -36,13 +36,67 @@ describe('has()', () => { ], defaultActionGroupId: 'default', executor: jest.fn(), - producer: 'alerting', + producer: 'alerts', }); expect(registry.has('foo')).toEqual(true); }); }); describe('register()', () => { + test('throws if AlertType Id contains invalid characters', () => { + const alertType = { + id: 'test', + name: 'Test', + actionGroups: [ + { + id: 'default', + name: 'Default', + }, + ], + defaultActionGroupId: 'default', + executor: jest.fn(), + producer: 'alerts', + }; + // eslint-disable-next-line @typescript-eslint/no-var-requires + const registry = new AlertTypeRegistry(alertTypeRegistryParams); + + const invalidCharacters = [' ', ':', '*', '*', '/']; + for (const char of invalidCharacters) { + expect(() => registry.register({ ...alertType, id: `${alertType.id}${char}` })).toThrowError( + new Error(`expected AlertType Id not to include invalid character: ${char}`) + ); + } + + const [first, second] = invalidCharacters; + expect(() => + registry.register({ ...alertType, id: `${first}${alertType.id}${second}` }) + ).toThrowError( + new Error(`expected AlertType Id not to include invalid characters: ${first}, ${second}`) + ); + }); + + test('throws if AlertType Id isnt a string', () => { + const alertType = { + id: (123 as unknown) as string, + name: 'Test', + actionGroups: [ + { + id: 'default', + name: 'Default', + }, + ], + defaultActionGroupId: 'default', + executor: jest.fn(), + producer: 'alerts', + }; + // eslint-disable-next-line @typescript-eslint/no-var-requires + const registry = new AlertTypeRegistry(alertTypeRegistryParams); + + expect(() => registry.register(alertType)).toThrowError( + new Error(`expected value of type [string] but got [number]`) + ); + }); + test('registers the executor with the task manager', () => { const alertType = { id: 'test', @@ -55,7 +109,7 @@ describe('register()', () => { ], defaultActionGroupId: 'default', executor: jest.fn(), - producer: 'alerting', + producer: 'alerts', }; // eslint-disable-next-line @typescript-eslint/no-var-requires const registry = new AlertTypeRegistry(alertTypeRegistryParams); @@ -86,7 +140,7 @@ describe('register()', () => { ], defaultActionGroupId: 'default', executor: jest.fn(), - producer: 'alerting', + producer: 'alerts', }; const registry = new AlertTypeRegistry(alertTypeRegistryParams); registry.register(alertType); @@ -107,7 +161,7 @@ describe('register()', () => { ], defaultActionGroupId: 'default', executor: jest.fn(), - producer: 'alerting', + producer: 'alerts', }); expect(() => registry.register({ @@ -121,7 +175,7 @@ describe('register()', () => { ], defaultActionGroupId: 'default', executor: jest.fn(), - producer: 'alerting', + producer: 'alerts', }) ).toThrowErrorMatchingInlineSnapshot(`"Alert type \\"test\\" is already registered."`); }); @@ -141,7 +195,7 @@ describe('get()', () => { ], defaultActionGroupId: 'default', executor: jest.fn(), - producer: 'alerting', + producer: 'alerts', }); const alertType = registry.get('test'); expect(alertType).toMatchInlineSnapshot(` @@ -160,7 +214,7 @@ describe('get()', () => { "executor": [MockFunction], "id": "test", "name": "Test", - "producer": "alerting", + "producer": "alerts", } `); }); @@ -177,7 +231,7 @@ describe('list()', () => { test('should return empty when nothing is registered', () => { const registry = new AlertTypeRegistry(alertTypeRegistryParams); const result = registry.list(); - expect(result).toMatchInlineSnapshot(`Array []`); + expect(result).toMatchInlineSnapshot(`Set {}`); }); test('should return registered types', () => { @@ -193,11 +247,11 @@ describe('list()', () => { ], defaultActionGroupId: 'testActionGroup', executor: jest.fn(), - producer: 'alerting', + producer: 'alerts', }); const result = registry.list(); expect(result).toMatchInlineSnapshot(` - Array [ + Set { Object { "actionGroups": Array [ Object { @@ -212,9 +266,9 @@ describe('list()', () => { "defaultActionGroupId": "testActionGroup", "id": "test", "name": "Test", - "producer": "alerting", + "producer": "alerts", }, - ] + } `); }); @@ -260,7 +314,7 @@ function alertTypeWithVariables(id: string, context: string, state: string): Ale actionGroups: [], defaultActionGroupId: id, async executor() {}, - producer: 'alerting', + producer: 'alerts', }; if (!context && !state) { diff --git a/x-pack/plugins/alerts/server/alert_type_registry.ts b/x-pack/plugins/alerts/server/alert_type_registry.ts index 8f36afe062aa..c466d0e96382 100644 --- a/x-pack/plugins/alerts/server/alert_type_registry.ts +++ b/x-pack/plugins/alerts/server/alert_type_registry.ts @@ -6,6 +6,8 @@ import Boom from 'boom'; import { i18n } from '@kbn/i18n'; +import { schema } from '@kbn/config-schema'; +import typeDetect from 'type-detect'; import { RunContext, TaskManagerSetupContract } from '../../task_manager/server'; import { TaskRunnerFactory } from './task_runner'; import { AlertType } from './types'; @@ -15,6 +17,34 @@ interface ConstructorOptions { taskRunnerFactory: TaskRunnerFactory; } +export interface RegistryAlertType + extends Pick< + AlertType, + 'name' | 'actionGroups' | 'defaultActionGroupId' | 'actionVariables' | 'producer' + > { + id: string; +} + +/** + * AlertType IDs are used as part of the authorization strings used to + * grant users privileged operations. There is a limited range of characters + * we can use in these auth strings, so we apply these same limitations to + * the AlertType Ids. + * If you wish to change this, please confer with the Kibana security team. + */ +const alertIdSchema = schema.string({ + validate(value: string): string | void { + if (typeof value !== 'string') { + return `expected AlertType Id of type [string] but got [${typeDetect(value)}]`; + } else if (!value.match(/^[a-zA-Z0-9_\-\.]*$/)) { + const invalid = value.match(/[^a-zA-Z0-9_\-\.]+/g)!; + return `expected AlertType Id not to include invalid character${ + invalid.length > 1 ? `s` : `` + }: ${invalid?.join(`, `)}`; + } + }, +}); + export class AlertTypeRegistry { private readonly taskManager: TaskManagerSetupContract; private readonly alertTypes: Map = new Map(); @@ -41,7 +71,7 @@ export class AlertTypeRegistry { ); } alertType.actionVariables = normalizedActionVariables(alertType.actionVariables); - this.alertTypes.set(alertType.id, { ...alertType }); + this.alertTypes.set(alertIdSchema.validate(alertType.id), { ...alertType }); this.taskManager.registerTaskDefinitions({ [`alerting:${alertType.id}`]: { title: alertType.name, @@ -66,15 +96,22 @@ export class AlertTypeRegistry { return this.alertTypes.get(id)!; } - public list() { - return Array.from(this.alertTypes).map(([alertTypeId, alertType]) => ({ - id: alertTypeId, - name: alertType.name, - actionGroups: alertType.actionGroups, - defaultActionGroupId: alertType.defaultActionGroupId, - actionVariables: alertType.actionVariables, - producer: alertType.producer, - })); + public list(): Set { + return new Set( + Array.from(this.alertTypes).map( + ([id, { name, actionGroups, defaultActionGroupId, actionVariables, producer }]: [ + string, + AlertType + ]) => ({ + id, + name, + actionGroups, + defaultActionGroupId, + actionVariables, + producer, + }) + ) + ); } } diff --git a/x-pack/plugins/alerts/server/alerts_client.mock.ts b/x-pack/plugins/alerts/server/alerts_client.mock.ts index 1848b3432ae5..be70e441b6fc 100644 --- a/x-pack/plugins/alerts/server/alerts_client.mock.ts +++ b/x-pack/plugins/alerts/server/alerts_client.mock.ts @@ -24,6 +24,7 @@ const createAlertsClientMock = () => { unmuteAll: jest.fn(), muteInstance: jest.fn(), unmuteInstance: jest.fn(), + listAlertTypes: jest.fn(), }; return mocked; }; diff --git a/x-pack/plugins/alerts/server/alerts_client.test.ts b/x-pack/plugins/alerts/server/alerts_client.test.ts index d69d04f71ce9..c25e040ad09c 100644 --- a/x-pack/plugins/alerts/server/alerts_client.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client.test.ts @@ -5,25 +5,32 @@ */ import uuid from 'uuid'; import { schema } from '@kbn/config-schema'; -import { AlertsClient, CreateOptions } from './alerts_client'; +import { AlertsClient, CreateOptions, ConstructorOptions } from './alerts_client'; import { savedObjectsClientMock, loggingSystemMock } from '../../../../src/core/server/mocks'; import { taskManagerMock } from '../../task_manager/server/task_manager.mock'; import { alertTypeRegistryMock } from './alert_type_registry.mock'; +import { alertsAuthorizationMock } from './authorization/alerts_authorization.mock'; import { TaskStatus } from '../../task_manager/server'; import { IntervalSchedule } from './types'; import { resolvable } from './test_utils'; import { encryptedSavedObjectsMock } from '../../encrypted_saved_objects/server/mocks'; -import { actionsClientMock } from '../../actions/server/mocks'; +import { actionsClientMock, actionsAuthorizationMock } from '../../actions/server/mocks'; +import { AlertsAuthorization } from './authorization/alerts_authorization'; +import { ActionsAuthorization } from '../../actions/server'; const taskManager = taskManagerMock.start(); const alertTypeRegistry = alertTypeRegistryMock.create(); -const savedObjectsClient = savedObjectsClientMock.create(); +const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); const encryptedSavedObjects = encryptedSavedObjectsMock.createClient(); +const authorization = alertsAuthorizationMock.create(); +const actionsAuthorization = actionsAuthorizationMock.create(); -const alertsClientParams = { +const alertsClientParams: jest.Mocked = { taskManager, alertTypeRegistry, - savedObjectsClient, + unsecuredSavedObjectsClient, + authorization: (authorization as unknown) as AlertsAuthorization, + actionsAuthorization: (actionsAuthorization as unknown) as ActionsAuthorization, spaceId: 'default', namespace: 'default', getUserName: jest.fn(), @@ -39,7 +46,11 @@ beforeEach(() => { alertsClientParams.createAPIKey.mockResolvedValue({ apiKeysEnabled: false }); alertsClientParams.invalidateAPIKey.mockResolvedValue({ apiKeysEnabled: true, - result: { error_count: 0 }, + result: { + invalidated_api_keys: [], + previously_invalidated_api_keys: [], + error_count: 0, + }, }); alertsClientParams.getUserName.mockResolvedValue('elastic'); taskManager.runNow.mockResolvedValue({ id: '' }); @@ -71,6 +82,15 @@ beforeEach(() => { }, ]); alertsClientParams.getActionsClient.mockResolvedValue(actionsClient); + + alertTypeRegistry.get.mockImplementation((id) => ({ + id: '123', + name: 'Test', + actionGroups: [{ id: 'default', name: 'Default' }], + defaultActionGroupId: 'default', + async executor() {}, + producer: 'alerts', + })); }); const mockedDate = new Date('2019-02-12T21:01:22.479Z'); @@ -114,19 +134,116 @@ describe('create()', () => { beforeEach(() => { alertsClient = new AlertsClient(alertsClientParams); - alertTypeRegistry.get.mockReturnValue({ - id: '123', - name: 'Test', - actionGroups: [{ id: 'default', name: 'Default' }], - defaultActionGroupId: 'default', - async executor() {}, - producer: 'alerting', + }); + + describe('authorization', () => { + function tryToExecuteOperation(options: CreateOptions): Promise { + unsecuredSavedObjectsClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [ + { + id: '1', + type: 'action', + attributes: { + actions: [], + actionTypeId: 'test', + }, + references: [], + }, + ], + }); + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + alertTypeId: '123', + schedule: { interval: '10s' }, + params: { + bar: true, + }, + createdAt: '2019-02-12T21:01:22.479Z', + actions: [ + { + group: 'default', + actionRef: 'action_0', + actionTypeId: 'test', + params: { + foo: true, + }, + }, + ], + }, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + ], + }); + taskManager.schedule.mockResolvedValueOnce({ + id: 'task-123', + taskType: 'alerting:123', + scheduledAt: new Date(), + attempts: 1, + status: TaskStatus.Idle, + runAt: new Date(), + startedAt: null, + retryAt: null, + state: {}, + params: {}, + ownerId: null, + }); + unsecuredSavedObjectsClient.update.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + actions: [], + scheduledTaskId: 'task-123', + }, + references: [ + { + id: '1', + name: 'action_0', + type: 'action', + }, + ], + }); + + return alertsClient.create(options); + } + + test('ensures user is authorised to create this type of alert under the consumer', async () => { + const data = getMockData({ + alertTypeId: 'myType', + consumer: 'myApp', + }); + + await tryToExecuteOperation({ data }); + + expect(authorization.ensureAuthorized).toHaveBeenCalledWith('myType', 'myApp', 'create'); + }); + + test('throws when user is not authorised to create this type of alert', async () => { + const data = getMockData({ + alertTypeId: 'myType', + consumer: 'myApp', + }); + + authorization.ensureAuthorized.mockRejectedValue( + new Error(`Unauthorized to create a "myType" alert for "myApp"`) + ); + + await expect(tryToExecuteOperation({ data })).rejects.toMatchInlineSnapshot( + `[Error: Unauthorized to create a "myType" alert for "myApp"]` + ); + + expect(authorization.ensureAuthorized).toHaveBeenCalledWith('myType', 'myApp', 'create'); }); }); test('creates an alert', async () => { const data = getMockData(); - savedObjectsClient.create.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: '1', type: 'alert', attributes: { @@ -168,10 +285,11 @@ describe('create()', () => { params: {}, ownerId: null, }); - savedObjectsClient.update.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.update.mockResolvedValueOnce({ id: '1', type: 'alert', attributes: { + actions: [], scheduledTaskId: 'task-123', }, references: [ @@ -183,6 +301,7 @@ describe('create()', () => { ], }); const result = await alertsClient.create({ data }); + expect(authorization.ensureAuthorized).toHaveBeenCalledWith('123', 'bar', 'create'); expect(result).toMatchInlineSnapshot(` Object { "actions": Array [ @@ -208,10 +327,10 @@ describe('create()', () => { "updatedAt": 2019-02-12T21:01:22.479Z, } `); - expect(savedObjectsClient.create).toHaveBeenCalledTimes(1); - expect(savedObjectsClient.create.mock.calls[0]).toHaveLength(3); - expect(savedObjectsClient.create.mock.calls[0][0]).toEqual('alert'); - expect(savedObjectsClient.create.mock.calls[0][1]).toMatchInlineSnapshot(` + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(1); + expect(unsecuredSavedObjectsClient.create.mock.calls[0]).toHaveLength(3); + expect(unsecuredSavedObjectsClient.create.mock.calls[0][0]).toEqual('alert'); + expect(unsecuredSavedObjectsClient.create.mock.calls[0][1]).toMatchInlineSnapshot(` Object { "actions": Array [ Object { @@ -246,7 +365,7 @@ describe('create()', () => { "updatedBy": "elastic", } `); - expect(savedObjectsClient.create.mock.calls[0][2]).toMatchInlineSnapshot(` + expect(unsecuredSavedObjectsClient.create.mock.calls[0][2]).toMatchInlineSnapshot(` Object { "references": Array [ Object { @@ -277,11 +396,11 @@ describe('create()', () => { }, ] `); - expect(savedObjectsClient.update).toHaveBeenCalledTimes(1); - expect(savedObjectsClient.update.mock.calls[0]).toHaveLength(3); - expect(savedObjectsClient.update.mock.calls[0][0]).toEqual('alert'); - expect(savedObjectsClient.update.mock.calls[0][1]).toEqual('1'); - expect(savedObjectsClient.update.mock.calls[0][2]).toMatchInlineSnapshot(` + expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledTimes(1); + expect(unsecuredSavedObjectsClient.update.mock.calls[0]).toHaveLength(3); + expect(unsecuredSavedObjectsClient.update.mock.calls[0][0]).toEqual('alert'); + expect(unsecuredSavedObjectsClient.update.mock.calls[0][1]).toEqual('1'); + expect(unsecuredSavedObjectsClient.update.mock.calls[0][2]).toMatchInlineSnapshot(` Object { "scheduledTaskId": "task-123", } @@ -314,7 +433,7 @@ describe('create()', () => { }, ], }); - savedObjectsClient.create.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: '1', type: 'alert', attributes: { @@ -382,10 +501,11 @@ describe('create()', () => { params: {}, ownerId: null, }); - savedObjectsClient.update.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.update.mockResolvedValueOnce({ id: '1', type: 'alert', attributes: { + actions: [], scheduledTaskId: 'task-123', }, references: [], @@ -436,19 +556,20 @@ describe('create()', () => { test('creates a disabled alert', async () => { const data = getMockData({ enabled: false }); - savedObjectsClient.bulkGet.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.bulkGet.mockResolvedValueOnce({ saved_objects: [ { id: '1', type: 'action', attributes: { + actions: [], actionTypeId: 'test', }, references: [], }, ], }); - savedObjectsClient.create.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: '1', type: 'alert', attributes: { @@ -504,7 +625,7 @@ describe('create()', () => { "updatedAt": 2019-02-12T21:01:22.479Z, } `); - expect(savedObjectsClient.create).toHaveBeenCalledTimes(1); + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(1); expect(taskManager.schedule).toHaveBeenCalledTimes(0); }); @@ -527,7 +648,7 @@ describe('create()', () => { }), }, async executor() {}, - producer: 'alerting', + producer: 'alerts', }); await expect(alertsClient.create({ data })).rejects.toThrowErrorMatchingInlineSnapshot( `"params invalid: [param1]: expected value of type [string] but got [undefined]"` @@ -542,25 +663,26 @@ describe('create()', () => { await expect(alertsClient.create({ data })).rejects.toThrowErrorMatchingInlineSnapshot( `"Test Error"` ); - expect(savedObjectsClient.create).not.toHaveBeenCalled(); + expect(unsecuredSavedObjectsClient.create).not.toHaveBeenCalled(); expect(taskManager.schedule).not.toHaveBeenCalled(); }); test('throws error if create saved object fails', async () => { const data = getMockData(); - savedObjectsClient.bulkGet.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.bulkGet.mockResolvedValueOnce({ saved_objects: [ { id: '1', type: 'action', attributes: { + actions: [], actionTypeId: 'test', }, references: [], }, ], }); - savedObjectsClient.create.mockRejectedValueOnce(new Error('Test failure')); + unsecuredSavedObjectsClient.create.mockRejectedValueOnce(new Error('Test failure')); await expect(alertsClient.create({ data })).rejects.toThrowErrorMatchingInlineSnapshot( `"Test failure"` ); @@ -569,19 +691,20 @@ describe('create()', () => { test('attempts to remove saved object if scheduling failed', async () => { const data = getMockData(); - savedObjectsClient.bulkGet.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.bulkGet.mockResolvedValueOnce({ saved_objects: [ { id: '1', type: 'action', attributes: { + actions: [], actionTypeId: 'test', }, references: [], }, ], }); - savedObjectsClient.create.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: '1', type: 'alert', attributes: { @@ -610,12 +733,12 @@ describe('create()', () => { ], }); taskManager.schedule.mockRejectedValueOnce(new Error('Test failure')); - savedObjectsClient.delete.mockResolvedValueOnce({}); + unsecuredSavedObjectsClient.delete.mockResolvedValueOnce({}); await expect(alertsClient.create({ data })).rejects.toThrowErrorMatchingInlineSnapshot( `"Test failure"` ); - expect(savedObjectsClient.delete).toHaveBeenCalledTimes(1); - expect(savedObjectsClient.delete.mock.calls[0]).toMatchInlineSnapshot(` + expect(unsecuredSavedObjectsClient.delete).toHaveBeenCalledTimes(1); + expect(unsecuredSavedObjectsClient.delete.mock.calls[0]).toMatchInlineSnapshot(` Array [ "alert", "1", @@ -625,19 +748,20 @@ describe('create()', () => { test('returns task manager error if cleanup fails, logs to console', async () => { const data = getMockData(); - savedObjectsClient.bulkGet.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.bulkGet.mockResolvedValueOnce({ saved_objects: [ { id: '1', type: 'action', attributes: { + actions: [], actionTypeId: 'test', }, references: [], }, ], }); - savedObjectsClient.create.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: '1', type: 'alert', attributes: { @@ -666,7 +790,9 @@ describe('create()', () => { ], }); taskManager.schedule.mockRejectedValueOnce(new Error('Task manager error')); - savedObjectsClient.delete.mockRejectedValueOnce(new Error('Saved object delete error')); + unsecuredSavedObjectsClient.delete.mockRejectedValueOnce( + new Error('Saved object delete error') + ); await expect(alertsClient.create({ data })).rejects.toThrowErrorMatchingInlineSnapshot( `"Task manager error"` ); @@ -689,21 +815,22 @@ describe('create()', () => { const data = getMockData(); alertsClientParams.createAPIKey.mockResolvedValueOnce({ apiKeysEnabled: true, - result: { id: '123', api_key: 'abc' }, + result: { id: '123', name: '123', api_key: 'abc' }, }); - savedObjectsClient.bulkGet.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.bulkGet.mockResolvedValueOnce({ saved_objects: [ { id: '1', type: 'action', attributes: { + actions: [], actionTypeId: 'test', }, references: [], }, ], }); - savedObjectsClient.create.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: '1', type: 'alert', attributes: { @@ -744,10 +871,11 @@ describe('create()', () => { params: {}, ownerId: null, }); - savedObjectsClient.update.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.update.mockResolvedValueOnce({ id: '1', type: 'alert', attributes: { + actions: [], scheduledTaskId: 'task-123', }, references: [ @@ -761,7 +889,7 @@ describe('create()', () => { await alertsClient.create({ data }); expect(alertsClientParams.createAPIKey).toHaveBeenCalledTimes(1); - expect(savedObjectsClient.create).toHaveBeenCalledWith( + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledWith( 'alert', { actions: [ @@ -802,19 +930,20 @@ describe('create()', () => { test(`doesn't create API key for disabled alerts`, async () => { const data = getMockData({ enabled: false }); - savedObjectsClient.bulkGet.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.bulkGet.mockResolvedValueOnce({ saved_objects: [ { id: '1', type: 'action', attributes: { + actions: [], actionTypeId: 'test', }, references: [], }, ], }); - savedObjectsClient.create.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: '1', type: 'alert', attributes: { @@ -855,10 +984,11 @@ describe('create()', () => { params: {}, ownerId: null, }); - savedObjectsClient.update.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.update.mockResolvedValueOnce({ id: '1', type: 'alert', attributes: { + actions: [], scheduledTaskId: 'task-123', }, references: [ @@ -872,7 +1002,7 @@ describe('create()', () => { await alertsClient.create({ data }); expect(alertsClientParams.createAPIKey).not.toHaveBeenCalled(); - expect(savedObjectsClient.create).toHaveBeenCalledWith( + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledWith( 'alert', { actions: [ @@ -918,9 +1048,21 @@ describe('enable()', () => { id: '1', type: 'alert', attributes: { + consumer: 'myApp', schedule: { interval: '10s' }, - alertTypeId: '2', + alertTypeId: 'myType', enabled: false, + actions: [ + { + group: 'default', + id: '1', + actionTypeId: '1', + actionRef: '1', + params: { + foo: true, + }, + }, + ], }, version: '123', references: [], @@ -929,7 +1071,7 @@ describe('enable()', () => { beforeEach(() => { alertsClient = new AlertsClient(alertsClientParams); encryptedSavedObjects.getDecryptedAsInternalUser.mockResolvedValue(existingAlert); - savedObjectsClient.get.mockResolvedValue(existingAlert); + unsecuredSavedObjectsClient.get.mockResolvedValue(existingAlert); alertsClientParams.createAPIKey.mockResolvedValue({ apiKeysEnabled: false, }); @@ -948,31 +1090,85 @@ describe('enable()', () => { }); }); + describe('authorization', () => { + beforeEach(() => { + encryptedSavedObjects.getDecryptedAsInternalUser.mockResolvedValue(existingAlert); + unsecuredSavedObjectsClient.get.mockResolvedValue(existingAlert); + alertsClientParams.createAPIKey.mockResolvedValue({ + apiKeysEnabled: false, + }); + taskManager.schedule.mockResolvedValue({ + id: 'task-123', + scheduledAt: new Date(), + attempts: 0, + status: TaskStatus.Idle, + runAt: new Date(), + state: {}, + params: {}, + taskType: '', + startedAt: null, + retryAt: null, + ownerId: null, + }); + }); + + test('ensures user is authorised to enable this type of alert under the consumer', async () => { + await alertsClient.enable({ id: '1' }); + + expect(authorization.ensureAuthorized).toHaveBeenCalledWith('myType', 'myApp', 'enable'); + expect(actionsAuthorization.ensureAuthorized).toHaveBeenCalledWith('execute'); + }); + + test('throws when user is not authorised to enable this type of alert', async () => { + authorization.ensureAuthorized.mockRejectedValue( + new Error(`Unauthorized to enable a "myType" alert for "myApp"`) + ); + + await expect(alertsClient.enable({ id: '1' })).rejects.toMatchInlineSnapshot( + `[Error: Unauthorized to enable a "myType" alert for "myApp"]` + ); + + expect(authorization.ensureAuthorized).toHaveBeenCalledWith('myType', 'myApp', 'enable'); + }); + }); + test('enables an alert', async () => { await alertsClient.enable({ id: '1' }); - expect(savedObjectsClient.get).not.toHaveBeenCalled(); + expect(unsecuredSavedObjectsClient.get).not.toHaveBeenCalled(); expect(encryptedSavedObjects.getDecryptedAsInternalUser).toHaveBeenCalledWith('alert', '1', { namespace: 'default', }); expect(alertsClientParams.invalidateAPIKey).not.toHaveBeenCalled(); expect(alertsClientParams.createAPIKey).toHaveBeenCalled(); - expect(savedObjectsClient.update).toHaveBeenCalledWith( + expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledWith( 'alert', '1', { schedule: { interval: '10s' }, - alertTypeId: '2', + alertTypeId: 'myType', + consumer: 'myApp', enabled: true, updatedBy: 'elastic', apiKey: null, apiKeyOwner: null, + actions: [ + { + group: 'default', + id: '1', + actionTypeId: '1', + actionRef: '1', + params: { + foo: true, + }, + }, + ], }, { version: '123', } ); expect(taskManager.schedule).toHaveBeenCalledWith({ - taskType: `alerting:2`, + taskType: `alerting:myType`, params: { alertId: '1', spaceId: 'default', @@ -984,7 +1180,7 @@ describe('enable()', () => { }, scope: ['alerting'], }); - expect(savedObjectsClient.update).toHaveBeenCalledWith('alert', '1', { + expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledWith('alert', '1', { scheduledTaskId: 'task-123', }); }); @@ -999,7 +1195,7 @@ describe('enable()', () => { }); await alertsClient.enable({ id: '1' }); - expect(savedObjectsClient.get).not.toHaveBeenCalled(); + expect(unsecuredSavedObjectsClient.get).not.toHaveBeenCalled(); expect(encryptedSavedObjects.getDecryptedAsInternalUser).toHaveBeenCalledWith('alert', '1', { namespace: 'default', }); @@ -1018,27 +1214,39 @@ describe('enable()', () => { await alertsClient.enable({ id: '1' }); expect(alertsClientParams.getUserName).not.toHaveBeenCalled(); expect(alertsClientParams.createAPIKey).not.toHaveBeenCalled(); - expect(savedObjectsClient.update).not.toHaveBeenCalled(); + expect(unsecuredSavedObjectsClient.update).not.toHaveBeenCalled(); expect(taskManager.schedule).not.toHaveBeenCalled(); }); test('sets API key when createAPIKey returns one', async () => { alertsClientParams.createAPIKey.mockResolvedValueOnce({ apiKeysEnabled: true, - result: { id: '123', api_key: 'abc' }, + result: { id: '123', name: '123', api_key: 'abc' }, }); await alertsClient.enable({ id: '1' }); - expect(savedObjectsClient.update).toHaveBeenCalledWith( + expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledWith( 'alert', '1', { schedule: { interval: '10s' }, - alertTypeId: '2', + alertTypeId: 'myType', + consumer: 'myApp', enabled: true, apiKey: Buffer.from('123:abc').toString('base64'), apiKeyOwner: 'elastic', updatedBy: 'elastic', + actions: [ + { + group: 'default', + id: '1', + actionTypeId: '1', + actionRef: '1', + params: { + foo: true, + }, + }, + ], }, { version: '123', @@ -1050,7 +1258,7 @@ describe('enable()', () => { encryptedSavedObjects.getDecryptedAsInternalUser.mockRejectedValue(new Error('Fail')); await alertsClient.enable({ id: '1' }); - expect(savedObjectsClient.get).toHaveBeenCalledWith('alert', '1'); + expect(unsecuredSavedObjectsClient.get).toHaveBeenCalledWith('alert', '1'); expect(alertsClientParams.logger.error).toHaveBeenCalledWith( 'enable(): Failed to load API key to invalidate on alert 1: Fail' ); @@ -1058,45 +1266,47 @@ describe('enable()', () => { test('throws error when failing to load the saved object using SOC', async () => { encryptedSavedObjects.getDecryptedAsInternalUser.mockRejectedValue(new Error('Fail')); - savedObjectsClient.get.mockRejectedValueOnce(new Error('Fail to get')); + unsecuredSavedObjectsClient.get.mockRejectedValueOnce(new Error('Fail to get')); await expect(alertsClient.enable({ id: '1' })).rejects.toThrowErrorMatchingInlineSnapshot( `"Fail to get"` ); expect(alertsClientParams.getUserName).not.toHaveBeenCalled(); expect(alertsClientParams.createAPIKey).not.toHaveBeenCalled(); - expect(savedObjectsClient.update).not.toHaveBeenCalled(); + expect(unsecuredSavedObjectsClient.update).not.toHaveBeenCalled(); expect(taskManager.schedule).not.toHaveBeenCalled(); }); test('throws error when failing to update the first time', async () => { - savedObjectsClient.update.mockRejectedValueOnce(new Error('Fail to update')); + unsecuredSavedObjectsClient.update.mockRejectedValueOnce(new Error('Fail to update')); await expect(alertsClient.enable({ id: '1' })).rejects.toThrowErrorMatchingInlineSnapshot( `"Fail to update"` ); expect(alertsClientParams.getUserName).toHaveBeenCalled(); expect(alertsClientParams.createAPIKey).toHaveBeenCalled(); - expect(savedObjectsClient.update).toHaveBeenCalledTimes(1); + expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledTimes(1); expect(taskManager.schedule).not.toHaveBeenCalled(); }); test('throws error when failing to update the second time', async () => { - savedObjectsClient.update.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.update.mockResolvedValueOnce({ ...existingAlert, attributes: { ...existingAlert.attributes, enabled: true, }, }); - savedObjectsClient.update.mockRejectedValueOnce(new Error('Fail to update second time')); + unsecuredSavedObjectsClient.update.mockRejectedValueOnce( + new Error('Fail to update second time') + ); await expect(alertsClient.enable({ id: '1' })).rejects.toThrowErrorMatchingInlineSnapshot( `"Fail to update second time"` ); expect(alertsClientParams.getUserName).toHaveBeenCalled(); expect(alertsClientParams.createAPIKey).toHaveBeenCalled(); - expect(savedObjectsClient.update).toHaveBeenCalledTimes(2); + expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledTimes(2); expect(taskManager.schedule).toHaveBeenCalled(); }); @@ -1108,7 +1318,7 @@ describe('enable()', () => { ); expect(alertsClientParams.getUserName).toHaveBeenCalled(); expect(alertsClientParams.createAPIKey).toHaveBeenCalled(); - expect(savedObjectsClient.update).toHaveBeenCalled(); + expect(unsecuredSavedObjectsClient.update).toHaveBeenCalled(); }); }); @@ -1118,10 +1328,22 @@ describe('disable()', () => { id: '1', type: 'alert', attributes: { + consumer: 'myApp', schedule: { interval: '10s' }, - alertTypeId: '2', + alertTypeId: 'myType', enabled: true, scheduledTaskId: 'task-123', + actions: [ + { + group: 'default', + id: '1', + actionTypeId: '1', + actionRef: '1', + params: { + foo: true, + }, + }, + ], }, version: '123', references: [], @@ -1136,27 +1358,59 @@ describe('disable()', () => { beforeEach(() => { alertsClient = new AlertsClient(alertsClientParams); - savedObjectsClient.get.mockResolvedValue(existingAlert); + unsecuredSavedObjectsClient.get.mockResolvedValue(existingAlert); encryptedSavedObjects.getDecryptedAsInternalUser.mockResolvedValue(existingDecryptedAlert); }); + describe('authorization', () => { + test('ensures user is authorised to disable this type of alert under the consumer', async () => { + await alertsClient.disable({ id: '1' }); + + expect(authorization.ensureAuthorized).toHaveBeenCalledWith('myType', 'myApp', 'disable'); + }); + + test('throws when user is not authorised to disable this type of alert', async () => { + authorization.ensureAuthorized.mockRejectedValue( + new Error(`Unauthorized to disable a "myType" alert for "myApp"`) + ); + + await expect(alertsClient.disable({ id: '1' })).rejects.toMatchInlineSnapshot( + `[Error: Unauthorized to disable a "myType" alert for "myApp"]` + ); + + expect(authorization.ensureAuthorized).toHaveBeenCalledWith('myType', 'myApp', 'disable'); + }); + }); + test('disables an alert', async () => { await alertsClient.disable({ id: '1' }); - expect(savedObjectsClient.get).not.toHaveBeenCalled(); + expect(unsecuredSavedObjectsClient.get).not.toHaveBeenCalled(); expect(encryptedSavedObjects.getDecryptedAsInternalUser).toHaveBeenCalledWith('alert', '1', { namespace: 'default', }); - expect(savedObjectsClient.update).toHaveBeenCalledWith( + expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledWith( 'alert', '1', { + consumer: 'myApp', schedule: { interval: '10s' }, - alertTypeId: '2', + alertTypeId: 'myType', apiKey: null, apiKeyOwner: null, enabled: false, scheduledTaskId: null, updatedBy: 'elastic', + actions: [ + { + group: 'default', + id: '1', + actionTypeId: '1', + actionRef: '1', + params: { + foo: true, + }, + }, + ], }, { version: '123', @@ -1170,21 +1424,33 @@ describe('disable()', () => { encryptedSavedObjects.getDecryptedAsInternalUser.mockRejectedValueOnce(new Error('Fail')); await alertsClient.disable({ id: '1' }); - expect(savedObjectsClient.get).toHaveBeenCalledWith('alert', '1'); + expect(unsecuredSavedObjectsClient.get).toHaveBeenCalledWith('alert', '1'); expect(encryptedSavedObjects.getDecryptedAsInternalUser).toHaveBeenCalledWith('alert', '1', { namespace: 'default', }); - expect(savedObjectsClient.update).toHaveBeenCalledWith( + expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledWith( 'alert', '1', { + consumer: 'myApp', schedule: { interval: '10s' }, - alertTypeId: '2', + alertTypeId: 'myType', apiKey: null, apiKeyOwner: null, enabled: false, scheduledTaskId: null, updatedBy: 'elastic', + actions: [ + { + group: 'default', + id: '1', + actionTypeId: '1', + actionRef: '1', + params: { + foo: true, + }, + }, + ], }, { version: '123', @@ -1199,12 +1465,13 @@ describe('disable()', () => { ...existingDecryptedAlert, attributes: { ...existingDecryptedAlert.attributes, + actions: [], enabled: false, }, }); await alertsClient.disable({ id: '1' }); - expect(savedObjectsClient.update).not.toHaveBeenCalled(); + expect(unsecuredSavedObjectsClient.update).not.toHaveBeenCalled(); expect(taskManager.remove).not.toHaveBeenCalled(); expect(alertsClientParams.invalidateAPIKey).not.toHaveBeenCalled(); }); @@ -1220,7 +1487,7 @@ describe('disable()', () => { encryptedSavedObjects.getDecryptedAsInternalUser.mockRejectedValueOnce(new Error('Fail')); await alertsClient.disable({ id: '1' }); - expect(savedObjectsClient.update).toHaveBeenCalled(); + expect(unsecuredSavedObjectsClient.update).toHaveBeenCalled(); expect(taskManager.remove).toHaveBeenCalled(); expect(alertsClientParams.invalidateAPIKey).not.toHaveBeenCalled(); expect(alertsClientParams.logger.error).toHaveBeenCalledWith( @@ -1228,8 +1495,8 @@ describe('disable()', () => { ); }); - test('throws when savedObjectsClient update fails', async () => { - savedObjectsClient.update.mockRejectedValueOnce(new Error('Failed to update')); + test('throws when unsecuredSavedObjectsClient update fails', async () => { + unsecuredSavedObjectsClient.update.mockRejectedValueOnce(new Error('Failed to update')); await expect(alertsClient.disable({ id: '1' })).rejects.toThrowErrorMatchingInlineSnapshot( `"Failed to update"` @@ -1257,52 +1524,181 @@ describe('disable()', () => { describe('muteAll()', () => { test('mutes an alert', async () => { const alertsClient = new AlertsClient(alertsClientParams); - savedObjectsClient.get.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ id: '1', type: 'alert', attributes: { + actions: [ + { + group: 'default', + id: '1', + actionTypeId: '1', + actionRef: '1', + params: { + foo: true, + }, + }, + ], muteAll: false, }, references: [], }); await alertsClient.muteAll({ id: '1' }); - expect(savedObjectsClient.update).toHaveBeenCalledWith('alert', '1', { + expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledWith('alert', '1', { muteAll: true, mutedInstanceIds: [], updatedBy: 'elastic', }); }); + + describe('authorization', () => { + beforeEach(() => { + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + actions: [ + { + group: 'default', + id: '1', + actionTypeId: '1', + actionRef: '1', + params: { + foo: true, + }, + }, + ], + consumer: 'myApp', + schedule: { interval: '10s' }, + alertTypeId: 'myType', + apiKey: null, + apiKeyOwner: null, + enabled: false, + scheduledTaskId: null, + updatedBy: 'elastic', + muteAll: false, + }, + references: [], + }); + }); + + test('ensures user is authorised to muteAll this type of alert under the consumer', async () => { + const alertsClient = new AlertsClient(alertsClientParams); + await alertsClient.muteAll({ id: '1' }); + + expect(authorization.ensureAuthorized).toHaveBeenCalledWith('myType', 'myApp', 'muteAll'); + expect(actionsAuthorization.ensureAuthorized).toHaveBeenCalledWith('execute'); + }); + + test('throws when user is not authorised to muteAll this type of alert', async () => { + const alertsClient = new AlertsClient(alertsClientParams); + authorization.ensureAuthorized.mockRejectedValue( + new Error(`Unauthorized to muteAll a "myType" alert for "myApp"`) + ); + + await expect(alertsClient.muteAll({ id: '1' })).rejects.toMatchInlineSnapshot( + `[Error: Unauthorized to muteAll a "myType" alert for "myApp"]` + ); + + expect(authorization.ensureAuthorized).toHaveBeenCalledWith('myType', 'myApp', 'muteAll'); + }); + }); }); describe('unmuteAll()', () => { test('unmutes an alert', async () => { const alertsClient = new AlertsClient(alertsClientParams); - savedObjectsClient.get.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ id: '1', type: 'alert', attributes: { + actions: [ + { + group: 'default', + id: '1', + actionTypeId: '1', + actionRef: '1', + params: { + foo: true, + }, + }, + ], muteAll: true, }, references: [], }); await alertsClient.unmuteAll({ id: '1' }); - expect(savedObjectsClient.update).toHaveBeenCalledWith('alert', '1', { + expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledWith('alert', '1', { muteAll: false, mutedInstanceIds: [], updatedBy: 'elastic', }); }); + + describe('authorization', () => { + beforeEach(() => { + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + actions: [ + { + group: 'default', + id: '1', + actionTypeId: '1', + actionRef: '1', + params: { + foo: true, + }, + }, + ], + consumer: 'myApp', + schedule: { interval: '10s' }, + alertTypeId: 'myType', + apiKey: null, + apiKeyOwner: null, + enabled: false, + scheduledTaskId: null, + updatedBy: 'elastic', + muteAll: false, + }, + references: [], + }); + }); + + test('ensures user is authorised to unmuteAll this type of alert under the consumer', async () => { + const alertsClient = new AlertsClient(alertsClientParams); + await alertsClient.unmuteAll({ id: '1' }); + + expect(authorization.ensureAuthorized).toHaveBeenCalledWith('myType', 'myApp', 'unmuteAll'); + expect(actionsAuthorization.ensureAuthorized).toHaveBeenCalledWith('execute'); + }); + + test('throws when user is not authorised to unmuteAll this type of alert', async () => { + const alertsClient = new AlertsClient(alertsClientParams); + authorization.ensureAuthorized.mockRejectedValue( + new Error(`Unauthorized to unmuteAll a "myType" alert for "myApp"`) + ); + + await expect(alertsClient.unmuteAll({ id: '1' })).rejects.toMatchInlineSnapshot( + `[Error: Unauthorized to unmuteAll a "myType" alert for "myApp"]` + ); + + expect(authorization.ensureAuthorized).toHaveBeenCalledWith('myType', 'myApp', 'unmuteAll'); + }); + }); }); describe('muteInstance()', () => { test('mutes an alert instance', async () => { const alertsClient = new AlertsClient(alertsClientParams); - savedObjectsClient.get.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ id: '1', type: 'alert', attributes: { + actions: [], schedule: { interval: '10s' }, alertTypeId: '2', enabled: true, @@ -1314,7 +1710,7 @@ describe('muteInstance()', () => { }); await alertsClient.muteInstance({ alertId: '1', alertInstanceId: '2' }); - expect(savedObjectsClient.update).toHaveBeenCalledWith( + expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledWith( 'alert', '1', { @@ -1327,10 +1723,11 @@ describe('muteInstance()', () => { test('skips muting when alert instance already muted', async () => { const alertsClient = new AlertsClient(alertsClientParams); - savedObjectsClient.get.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ id: '1', type: 'alert', attributes: { + actions: [], schedule: { interval: '10s' }, alertTypeId: '2', enabled: true, @@ -1341,15 +1738,16 @@ describe('muteInstance()', () => { }); await alertsClient.muteInstance({ alertId: '1', alertInstanceId: '2' }); - expect(savedObjectsClient.update).not.toHaveBeenCalled(); + expect(unsecuredSavedObjectsClient.update).not.toHaveBeenCalled(); }); test('skips muting when alert is muted', async () => { const alertsClient = new AlertsClient(alertsClientParams); - savedObjectsClient.get.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ id: '1', type: 'alert', attributes: { + actions: [], schedule: { interval: '10s' }, alertTypeId: '2', enabled: true, @@ -1361,17 +1759,79 @@ describe('muteInstance()', () => { }); await alertsClient.muteInstance({ alertId: '1', alertInstanceId: '2' }); - expect(savedObjectsClient.update).not.toHaveBeenCalled(); + expect(unsecuredSavedObjectsClient.update).not.toHaveBeenCalled(); + }); + + describe('authorization', () => { + beforeEach(() => { + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + actions: [ + { + group: 'default', + id: '1', + actionTypeId: '1', + actionRef: '1', + params: { + foo: true, + }, + }, + ], + schedule: { interval: '10s' }, + alertTypeId: 'myType', + consumer: 'myApp', + enabled: true, + scheduledTaskId: 'task-123', + mutedInstanceIds: [], + }, + version: '123', + references: [], + }); + }); + + test('ensures user is authorised to muteInstance this type of alert under the consumer', async () => { + const alertsClient = new AlertsClient(alertsClientParams); + await alertsClient.muteInstance({ alertId: '1', alertInstanceId: '2' }); + + expect(actionsAuthorization.ensureAuthorized).toHaveBeenCalledWith('execute'); + expect(authorization.ensureAuthorized).toHaveBeenCalledWith( + 'myType', + 'myApp', + 'muteInstance' + ); + }); + + test('throws when user is not authorised to muteInstance this type of alert', async () => { + const alertsClient = new AlertsClient(alertsClientParams); + authorization.ensureAuthorized.mockRejectedValue( + new Error(`Unauthorized to muteInstance a "myType" alert for "myApp"`) + ); + + await expect( + alertsClient.muteInstance({ alertId: '1', alertInstanceId: '2' }) + ).rejects.toMatchInlineSnapshot( + `[Error: Unauthorized to muteInstance a "myType" alert for "myApp"]` + ); + + expect(authorization.ensureAuthorized).toHaveBeenCalledWith( + 'myType', + 'myApp', + 'muteInstance' + ); + }); }); }); describe('unmuteInstance()', () => { test('unmutes an alert instance', async () => { const alertsClient = new AlertsClient(alertsClientParams); - savedObjectsClient.get.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ id: '1', type: 'alert', attributes: { + actions: [], schedule: { interval: '10s' }, alertTypeId: '2', enabled: true, @@ -1383,7 +1843,7 @@ describe('unmuteInstance()', () => { }); await alertsClient.unmuteInstance({ alertId: '1', alertInstanceId: '2' }); - expect(savedObjectsClient.update).toHaveBeenCalledWith( + expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledWith( 'alert', '1', { @@ -1396,10 +1856,11 @@ describe('unmuteInstance()', () => { test('skips unmuting when alert instance not muted', async () => { const alertsClient = new AlertsClient(alertsClientParams); - savedObjectsClient.get.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ id: '1', type: 'alert', attributes: { + actions: [], schedule: { interval: '10s' }, alertTypeId: '2', enabled: true, @@ -1410,15 +1871,16 @@ describe('unmuteInstance()', () => { }); await alertsClient.unmuteInstance({ alertId: '1', alertInstanceId: '2' }); - expect(savedObjectsClient.update).not.toHaveBeenCalled(); + expect(unsecuredSavedObjectsClient.update).not.toHaveBeenCalled(); }); test('skips unmuting when alert is muted', async () => { const alertsClient = new AlertsClient(alertsClientParams); - savedObjectsClient.get.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ id: '1', type: 'alert', attributes: { + actions: [], schedule: { interval: '10s' }, alertTypeId: '2', enabled: true, @@ -1430,14 +1892,75 @@ describe('unmuteInstance()', () => { }); await alertsClient.unmuteInstance({ alertId: '1', alertInstanceId: '2' }); - expect(savedObjectsClient.update).not.toHaveBeenCalled(); + expect(unsecuredSavedObjectsClient.update).not.toHaveBeenCalled(); + }); + + describe('authorization', () => { + beforeEach(() => { + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + actions: [ + { + group: 'default', + id: '1', + actionTypeId: '1', + actionRef: '1', + params: { + foo: true, + }, + }, + ], + alertTypeId: 'myType', + consumer: 'myApp', + schedule: { interval: '10s' }, + enabled: true, + scheduledTaskId: 'task-123', + mutedInstanceIds: ['2'], + }, + version: '123', + references: [], + }); + }); + + test('ensures user is authorised to unmuteInstance this type of alert under the consumer', async () => { + const alertsClient = new AlertsClient(alertsClientParams); + await alertsClient.unmuteInstance({ alertId: '1', alertInstanceId: '2' }); + + expect(actionsAuthorization.ensureAuthorized).toHaveBeenCalledWith('execute'); + expect(authorization.ensureAuthorized).toHaveBeenCalledWith( + 'myType', + 'myApp', + 'unmuteInstance' + ); + }); + + test('throws when user is not authorised to unmuteInstance this type of alert', async () => { + const alertsClient = new AlertsClient(alertsClientParams); + authorization.ensureAuthorized.mockRejectedValue( + new Error(`Unauthorized to unmuteInstance a "myType" alert for "myApp"`) + ); + + await expect( + alertsClient.unmuteInstance({ alertId: '1', alertInstanceId: '2' }) + ).rejects.toMatchInlineSnapshot( + `[Error: Unauthorized to unmuteInstance a "myType" alert for "myApp"]` + ); + + expect(authorization.ensureAuthorized).toHaveBeenCalledWith( + 'myType', + 'myApp', + 'unmuteInstance' + ); + }); }); }); describe('get()', () => { test('calls saved objects client with given params', async () => { const alertsClient = new AlertsClient(alertsClientParams); - savedObjectsClient.get.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ id: '1', type: 'alert', attributes: { @@ -1446,6 +1969,7 @@ describe('get()', () => { params: { bar: true, }, + createdAt: new Date().toISOString(), actions: [ { group: 'default', @@ -1488,8 +2012,8 @@ describe('get()', () => { "updatedAt": 2019-02-12T21:01:22.479Z, } `); - expect(savedObjectsClient.get).toHaveBeenCalledTimes(1); - expect(savedObjectsClient.get.mock.calls[0]).toMatchInlineSnapshot(` + expect(unsecuredSavedObjectsClient.get).toHaveBeenCalledTimes(1); + expect(unsecuredSavedObjectsClient.get.mock.calls[0]).toMatchInlineSnapshot(` Array [ "alert", "1", @@ -1499,7 +2023,7 @@ describe('get()', () => { test(`throws an error when references aren't found`, async () => { const alertsClient = new AlertsClient(alertsClientParams); - savedObjectsClient.get.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ id: '1', type: 'alert', attributes: { @@ -1517,19 +2041,72 @@ describe('get()', () => { }, }, ], - }, - references: [], + }, + references: [], + }); + await expect(alertsClient.get({ id: '1' })).rejects.toThrowErrorMatchingInlineSnapshot( + `"Action reference \\"action_0\\" not found in alert id: 1"` + ); + }); + + describe('authorization', () => { + beforeEach(() => { + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + alertTypeId: 'myType', + consumer: 'myApp', + schedule: { interval: '10s' }, + params: { + bar: true, + }, + actions: [ + { + group: 'default', + actionRef: 'action_0', + params: { + foo: true, + }, + }, + ], + }, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + ], + }); + }); + + test('ensures user is authorised to get this type of alert under the consumer', async () => { + const alertsClient = new AlertsClient(alertsClientParams); + await alertsClient.get({ id: '1' }); + + expect(authorization.ensureAuthorized).toHaveBeenCalledWith('myType', 'myApp', 'get'); + }); + + test('throws when user is not authorised to get this type of alert', async () => { + const alertsClient = new AlertsClient(alertsClientParams); + authorization.ensureAuthorized.mockRejectedValue( + new Error(`Unauthorized to get a "myType" alert for "myApp"`) + ); + + await expect(alertsClient.get({ id: '1' })).rejects.toMatchInlineSnapshot( + `[Error: Unauthorized to get a "myType" alert for "myApp"]` + ); + + expect(authorization.ensureAuthorized).toHaveBeenCalledWith('myType', 'myApp', 'get'); }); - await expect(alertsClient.get({ id: '1' })).rejects.toThrowErrorMatchingInlineSnapshot( - `"Reference action_0 not found"` - ); }); }); describe('getAlertState()', () => { test('calls saved objects client with given params', async () => { const alertsClient = new AlertsClient(alertsClientParams); - savedObjectsClient.get.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ id: '1', type: 'alert', attributes: { @@ -1572,8 +2149,8 @@ describe('getAlertState()', () => { }); await alertsClient.getAlertState({ id: '1' }); - expect(savedObjectsClient.get).toHaveBeenCalledTimes(1); - expect(savedObjectsClient.get.mock.calls[0]).toMatchInlineSnapshot(` + expect(unsecuredSavedObjectsClient.get).toHaveBeenCalledTimes(1); + expect(unsecuredSavedObjectsClient.get.mock.calls[0]).toMatchInlineSnapshot(` Array [ "alert", "1", @@ -1586,7 +2163,7 @@ describe('getAlertState()', () => { const scheduledTaskId = 'task-123'; - savedObjectsClient.get.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ id: '1', type: 'alert', attributes: { @@ -1638,12 +2215,103 @@ describe('getAlertState()', () => { expect(taskManager.get).toHaveBeenCalledTimes(1); expect(taskManager.get).toHaveBeenCalledWith(scheduledTaskId); }); + + describe('authorization', () => { + beforeEach(() => { + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + alertTypeId: 'myType', + consumer: 'myApp', + schedule: { interval: '10s' }, + params: { + bar: true, + }, + actions: [ + { + group: 'default', + actionRef: 'action_0', + params: { + foo: true, + }, + }, + ], + }, + references: [ + { + name: 'action_0', + type: 'action', + id: '1', + }, + ], + }); + + taskManager.get.mockResolvedValueOnce({ + id: '1', + taskType: 'alerting:123', + scheduledAt: new Date(), + attempts: 1, + status: TaskStatus.Idle, + runAt: new Date(), + startedAt: null, + retryAt: null, + state: {}, + params: {}, + ownerId: null, + }); + }); + + test('ensures user is authorised to get this type of alert under the consumer', async () => { + const alertsClient = new AlertsClient(alertsClientParams); + await alertsClient.getAlertState({ id: '1' }); + + expect(authorization.ensureAuthorized).toHaveBeenCalledWith( + 'myType', + 'myApp', + 'getAlertState' + ); + }); + + test('throws when user is not authorised to getAlertState this type of alert', async () => { + const alertsClient = new AlertsClient(alertsClientParams); + // `get` check + authorization.ensureAuthorized.mockResolvedValueOnce(); + // `getAlertState` check + authorization.ensureAuthorized.mockRejectedValueOnce( + new Error(`Unauthorized to getAlertState a "myType" alert for "myApp"`) + ); + + await expect(alertsClient.getAlertState({ id: '1' })).rejects.toMatchInlineSnapshot( + `[Error: Unauthorized to getAlertState a "myType" alert for "myApp"]` + ); + + expect(authorization.ensureAuthorized).toHaveBeenCalledWith( + 'myType', + 'myApp', + 'getAlertState' + ); + }); + }); }); describe('find()', () => { - test('calls saved objects client with given params', async () => { - const alertsClient = new AlertsClient(alertsClientParams); - savedObjectsClient.find.mockResolvedValueOnce({ + const listedTypes = new Set([ + { + actionGroups: [], + actionVariables: undefined, + defaultActionGroupId: 'default', + id: 'myType', + name: 'myType', + producer: 'myApp', + }, + ]); + beforeEach(() => { + authorization.getFindAuthorizationFilter.mockResolvedValue({ + ensureAlertTypeIsAuthorized() {}, + logSuccessfulAuthorization() {}, + }); + unsecuredSavedObjectsClient.find.mockResolvedValueOnce({ total: 1, per_page: 10, page: 1, @@ -1652,11 +2320,12 @@ describe('find()', () => { id: '1', type: 'alert', attributes: { - alertTypeId: '123', + alertTypeId: 'myType', schedule: { interval: '10s' }, params: { bar: true, }, + createdAt: new Date().toISOString(), actions: [ { group: 'default', @@ -1678,6 +2347,25 @@ describe('find()', () => { }, ], }); + alertTypeRegistry.list.mockReturnValue(listedTypes); + authorization.filterByAlertTypeAuthorization.mockResolvedValue( + new Set([ + { + id: 'myType', + name: 'Test', + actionGroups: [{ id: 'default', name: 'Default' }], + defaultActionGroupId: 'default', + producer: 'alerts', + authorizedConsumers: { + myApp: { read: true, all: true }, + }, + }, + ]) + ); + }); + + test('calls saved objects client with given params', async () => { + const alertsClient = new AlertsClient(alertsClientParams); const result = await alertsClient.find({ options: {} }); expect(result).toMatchInlineSnapshot(` Object { @@ -1692,7 +2380,7 @@ describe('find()', () => { }, }, ], - "alertTypeId": "123", + "alertTypeId": "myType", "createdAt": 2019-02-12T21:01:22.479Z, "id": "1", "params": Object { @@ -1709,14 +2397,100 @@ describe('find()', () => { "total": 1, } `); - expect(savedObjectsClient.find).toHaveBeenCalledTimes(1); - expect(savedObjectsClient.find.mock.calls[0]).toMatchInlineSnapshot(` - Array [ - Object { - "type": "alert", - }, - ] - `); + expect(unsecuredSavedObjectsClient.find).toHaveBeenCalledTimes(1); + expect(unsecuredSavedObjectsClient.find.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "fields": undefined, + "type": "alert", + }, + ] + `); + }); + + describe('authorization', () => { + test('ensures user is query filter types down to those the user is authorized to find', async () => { + authorization.getFindAuthorizationFilter.mockResolvedValue({ + filter: + '((alert.attributes.alertTypeId:myType and alert.attributes.consumer:myApp) or (alert.attributes.alertTypeId:myOtherType and alert.attributes.consumer:myApp) or (alert.attributes.alertTypeId:myOtherType and alert.attributes.consumer:myOtherApp))', + ensureAlertTypeIsAuthorized() {}, + logSuccessfulAuthorization() {}, + }); + + const alertsClient = new AlertsClient(alertsClientParams); + await alertsClient.find({ options: { filter: 'someTerm' } }); + + const [options] = unsecuredSavedObjectsClient.find.mock.calls[0]; + expect(options.filter).toMatchInlineSnapshot( + `"someTerm and ((alert.attributes.alertTypeId:myType and alert.attributes.consumer:myApp) or (alert.attributes.alertTypeId:myOtherType and alert.attributes.consumer:myApp) or (alert.attributes.alertTypeId:myOtherType and alert.attributes.consumer:myOtherApp))"` + ); + expect(authorization.getFindAuthorizationFilter).toHaveBeenCalledTimes(1); + }); + + test('throws if user is not authorized to find any types', async () => { + const alertsClient = new AlertsClient(alertsClientParams); + authorization.getFindAuthorizationFilter.mockRejectedValue(new Error('not authorized')); + await expect(alertsClient.find({ options: {} })).rejects.toThrowErrorMatchingInlineSnapshot( + `"not authorized"` + ); + }); + + test('ensures authorization even when the fields required to authorize are omitted from the find', async () => { + const ensureAlertTypeIsAuthorized = jest.fn(); + const logSuccessfulAuthorization = jest.fn(); + authorization.getFindAuthorizationFilter.mockResolvedValue({ + filter: '', + ensureAlertTypeIsAuthorized, + logSuccessfulAuthorization, + }); + + unsecuredSavedObjectsClient.find.mockReset(); + unsecuredSavedObjectsClient.find.mockResolvedValue({ + total: 1, + per_page: 10, + page: 1, + saved_objects: [ + { + id: '1', + type: 'alert', + attributes: { + actions: [], + alertTypeId: 'myType', + consumer: 'myApp', + tags: ['myTag'], + }, + score: 1, + references: [], + }, + ], + }); + + const alertsClient = new AlertsClient(alertsClientParams); + expect(await alertsClient.find({ options: { fields: ['tags'] } })).toMatchInlineSnapshot(` + Object { + "data": Array [ + Object { + "actions": Array [], + "id": "1", + "schedule": undefined, + "tags": Array [ + "myTag", + ], + }, + ], + "page": 1, + "perPage": 10, + "total": 1, + } + `); + + expect(unsecuredSavedObjectsClient.find).toHaveBeenCalledWith({ + fields: ['tags', 'alertTypeId', 'consumer'], + type: 'alert', + }); + expect(ensureAlertTypeIsAuthorized).toHaveBeenCalledWith('myType', 'myApp'); + expect(logSuccessfulAuthorization).toHaveBeenCalled(); + }); }); }); @@ -1726,7 +2500,8 @@ describe('delete()', () => { id: '1', type: 'alert', attributes: { - alertTypeId: '123', + alertTypeId: 'myType', + consumer: 'myApp', schedule: { interval: '10s' }, params: { bar: true, @@ -1735,6 +2510,7 @@ describe('delete()', () => { actions: [ { group: 'default', + actionTypeId: '.no-op', actionRef: 'action_0', params: { foo: true, @@ -1760,8 +2536,8 @@ describe('delete()', () => { beforeEach(() => { alertsClient = new AlertsClient(alertsClientParams); - savedObjectsClient.get.mockResolvedValue(existingAlert); - savedObjectsClient.delete.mockResolvedValue({ + unsecuredSavedObjectsClient.get.mockResolvedValue(existingAlert); + unsecuredSavedObjectsClient.delete.mockResolvedValue({ success: true, }); encryptedSavedObjects.getDecryptedAsInternalUser.mockResolvedValue(existingDecryptedAlert); @@ -1770,13 +2546,13 @@ describe('delete()', () => { test('successfully removes an alert', async () => { const result = await alertsClient.delete({ id: '1' }); expect(result).toEqual({ success: true }); - expect(savedObjectsClient.delete).toHaveBeenCalledWith('alert', '1'); + expect(unsecuredSavedObjectsClient.delete).toHaveBeenCalledWith('alert', '1'); expect(taskManager.remove).toHaveBeenCalledWith('task-123'); expect(alertsClientParams.invalidateAPIKey).toHaveBeenCalledWith({ id: '123' }); expect(encryptedSavedObjects.getDecryptedAsInternalUser).toHaveBeenCalledWith('alert', '1', { namespace: 'default', }); - expect(savedObjectsClient.get).not.toHaveBeenCalled(); + expect(unsecuredSavedObjectsClient.get).not.toHaveBeenCalled(); }); test('falls back to SOC.get when getDecryptedAsInternalUser throws an error', async () => { @@ -1784,10 +2560,10 @@ describe('delete()', () => { const result = await alertsClient.delete({ id: '1' }); expect(result).toEqual({ success: true }); - expect(savedObjectsClient.delete).toHaveBeenCalledWith('alert', '1'); + expect(unsecuredSavedObjectsClient.delete).toHaveBeenCalledWith('alert', '1'); expect(taskManager.remove).toHaveBeenCalledWith('task-123'); expect(alertsClientParams.invalidateAPIKey).not.toHaveBeenCalled(); - expect(savedObjectsClient.get).toHaveBeenCalledWith('alert', '1'); + expect(unsecuredSavedObjectsClient.get).toHaveBeenCalledWith('alert', '1'); expect(alertsClientParams.logger.error).toHaveBeenCalledWith( 'delete(): Failed to load API key to invalidate on alert 1: Fail' ); @@ -1839,9 +2615,9 @@ describe('delete()', () => { ); }); - test('throws error when savedObjectsClient.get throws an error', async () => { + test('throws error when unsecuredSavedObjectsClient.get throws an error', async () => { encryptedSavedObjects.getDecryptedAsInternalUser.mockRejectedValue(new Error('Fail')); - savedObjectsClient.get.mockRejectedValue(new Error('SOC Fail')); + unsecuredSavedObjectsClient.get.mockRejectedValue(new Error('SOC Fail')); await expect(alertsClient.delete({ id: '1' })).rejects.toThrowErrorMatchingInlineSnapshot( `"SOC Fail"` @@ -1855,6 +2631,26 @@ describe('delete()', () => { `"TM Fail"` ); }); + + describe('authorization', () => { + test('ensures user is authorised to delete this type of alert under the consumer', async () => { + await alertsClient.delete({ id: '1' }); + + expect(authorization.ensureAuthorized).toHaveBeenCalledWith('myType', 'myApp', 'delete'); + }); + + test('throws when user is not authorised to delete this type of alert', async () => { + authorization.ensureAuthorized.mockRejectedValue( + new Error(`Unauthorized to delete a "myType" alert for "myApp"`) + ); + + await expect(alertsClient.delete({ id: '1' })).rejects.toMatchInlineSnapshot( + `[Error: Unauthorized to delete a "myType" alert for "myApp"]` + ); + + expect(authorization.ensureAuthorized).toHaveBeenCalledWith('myType', 'myApp', 'delete'); + }); + }); }); describe('update()', () => { @@ -1864,8 +2660,20 @@ describe('update()', () => { type: 'alert', attributes: { enabled: true, - alertTypeId: '123', + alertTypeId: 'myType', + consumer: 'myApp', scheduledTaskId: 'task-123', + actions: [ + { + group: 'default', + id: '1', + actionTypeId: '1', + actionRef: '1', + params: { + foo: true, + }, + }, + ], }, references: [], version: '123', @@ -1880,7 +2688,7 @@ describe('update()', () => { beforeEach(() => { alertsClient = new AlertsClient(alertsClientParams); - savedObjectsClient.get.mockResolvedValue(existingAlert); + unsecuredSavedObjectsClient.get.mockResolvedValue(existingAlert); encryptedSavedObjects.getDecryptedAsInternalUser.mockResolvedValue(existingDecryptedAlert); alertTypeRegistry.get.mockReturnValue({ id: '123', @@ -1888,12 +2696,12 @@ describe('update()', () => { actionGroups: [{ id: 'default', name: 'Default' }], defaultActionGroupId: 'default', async executor() {}, - producer: 'alerting', + producer: 'alerts', }); }); test('updates given parameters', async () => { - savedObjectsClient.update.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.update.mockResolvedValueOnce({ id: '1', type: 'alert', attributes: { @@ -2029,12 +2837,12 @@ describe('update()', () => { expect(encryptedSavedObjects.getDecryptedAsInternalUser).toHaveBeenCalledWith('alert', '1', { namespace: 'default', }); - expect(savedObjectsClient.get).not.toHaveBeenCalled(); - expect(savedObjectsClient.update).toHaveBeenCalledTimes(1); - expect(savedObjectsClient.update.mock.calls[0]).toHaveLength(4); - expect(savedObjectsClient.update.mock.calls[0][0]).toEqual('alert'); - expect(savedObjectsClient.update.mock.calls[0][1]).toEqual('1'); - expect(savedObjectsClient.update.mock.calls[0][2]).toMatchInlineSnapshot(` + expect(unsecuredSavedObjectsClient.get).not.toHaveBeenCalled(); + expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledTimes(1); + expect(unsecuredSavedObjectsClient.update.mock.calls[0]).toHaveLength(4); + expect(unsecuredSavedObjectsClient.update.mock.calls[0][0]).toEqual('alert'); + expect(unsecuredSavedObjectsClient.update.mock.calls[0][1]).toEqual('1'); + expect(unsecuredSavedObjectsClient.update.mock.calls[0][2]).toMatchInlineSnapshot(` Object { "actions": Array [ Object { @@ -2062,9 +2870,10 @@ describe('update()', () => { }, }, ], - "alertTypeId": "123", + "alertTypeId": "myType", "apiKey": null, "apiKeyOwner": null, + "consumer": "myApp", "enabled": true, "name": "abc", "params": Object { @@ -2081,7 +2890,7 @@ describe('update()', () => { "updatedBy": "elastic", } `); - expect(savedObjectsClient.update.mock.calls[0][3]).toMatchInlineSnapshot(` + expect(unsecuredSavedObjectsClient.update.mock.calls[0][3]).toMatchInlineSnapshot(` Object { "references": Array [ Object { @@ -2106,12 +2915,13 @@ describe('update()', () => { }); it('calls the createApiKey function', async () => { - savedObjectsClient.bulkGet.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.bulkGet.mockResolvedValueOnce({ saved_objects: [ { id: '1', type: 'action', attributes: { + actions: [], actionTypeId: 'test', }, references: [], @@ -2120,9 +2930,9 @@ describe('update()', () => { }); alertsClientParams.createAPIKey.mockResolvedValueOnce({ apiKeysEnabled: true, - result: { id: '123', api_key: 'abc' }, + result: { id: '123', name: '123', api_key: 'abc' }, }); - savedObjectsClient.update.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.update.mockResolvedValueOnce({ id: '1', type: 'alert', attributes: { @@ -2201,11 +3011,11 @@ describe('update()', () => { "updatedAt": 2019-02-12T21:01:22.479Z, } `); - expect(savedObjectsClient.update).toHaveBeenCalledTimes(1); - expect(savedObjectsClient.update.mock.calls[0]).toHaveLength(4); - expect(savedObjectsClient.update.mock.calls[0][0]).toEqual('alert'); - expect(savedObjectsClient.update.mock.calls[0][1]).toEqual('1'); - expect(savedObjectsClient.update.mock.calls[0][2]).toMatchInlineSnapshot(` + expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledTimes(1); + expect(unsecuredSavedObjectsClient.update.mock.calls[0]).toHaveLength(4); + expect(unsecuredSavedObjectsClient.update.mock.calls[0][0]).toEqual('alert'); + expect(unsecuredSavedObjectsClient.update.mock.calls[0][1]).toEqual('1'); + expect(unsecuredSavedObjectsClient.update.mock.calls[0][2]).toMatchInlineSnapshot(` Object { "actions": Array [ Object { @@ -2217,9 +3027,10 @@ describe('update()', () => { }, }, ], - "alertTypeId": "123", + "alertTypeId": "myType", "apiKey": "MTIzOmFiYw==", "apiKeyOwner": "elastic", + "consumer": "myApp", "enabled": true, "name": "abc", "params": Object { @@ -2236,7 +3047,7 @@ describe('update()', () => { "updatedBy": "elastic", } `); - expect(savedObjectsClient.update.mock.calls[0][3]).toMatchInlineSnapshot(` + expect(unsecuredSavedObjectsClient.update.mock.calls[0][3]).toMatchInlineSnapshot(` Object { "references": Array [ Object { @@ -2258,19 +3069,20 @@ describe('update()', () => { enabled: false, }, }); - savedObjectsClient.bulkGet.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.bulkGet.mockResolvedValueOnce({ saved_objects: [ { id: '1', type: 'action', attributes: { + actions: [], actionTypeId: 'test', }, references: [], }, ], }); - savedObjectsClient.update.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.update.mockResolvedValueOnce({ id: '1', type: 'alert', attributes: { @@ -2350,11 +3162,11 @@ describe('update()', () => { "updatedAt": 2019-02-12T21:01:22.479Z, } `); - expect(savedObjectsClient.update).toHaveBeenCalledTimes(1); - expect(savedObjectsClient.update.mock.calls[0]).toHaveLength(4); - expect(savedObjectsClient.update.mock.calls[0][0]).toEqual('alert'); - expect(savedObjectsClient.update.mock.calls[0][1]).toEqual('1'); - expect(savedObjectsClient.update.mock.calls[0][2]).toMatchInlineSnapshot(` + expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledTimes(1); + expect(unsecuredSavedObjectsClient.update.mock.calls[0]).toHaveLength(4); + expect(unsecuredSavedObjectsClient.update.mock.calls[0][0]).toEqual('alert'); + expect(unsecuredSavedObjectsClient.update.mock.calls[0][1]).toEqual('1'); + expect(unsecuredSavedObjectsClient.update.mock.calls[0][2]).toMatchInlineSnapshot(` Object { "actions": Array [ Object { @@ -2366,9 +3178,10 @@ describe('update()', () => { }, }, ], - "alertTypeId": "123", + "alertTypeId": "myType", "apiKey": null, "apiKeyOwner": null, + "consumer": "myApp", "enabled": false, "name": "abc", "params": Object { @@ -2385,7 +3198,7 @@ describe('update()', () => { "updatedBy": "elastic", } `); - expect(savedObjectsClient.update.mock.calls[0][3]).toMatchInlineSnapshot(` + expect(unsecuredSavedObjectsClient.update.mock.calls[0][3]).toMatchInlineSnapshot(` Object { "references": Array [ Object { @@ -2411,7 +3224,7 @@ describe('update()', () => { }), }, async executor() {}, - producer: 'alerting', + producer: 'alerts', }); await expect( alertsClient.update({ @@ -2442,19 +3255,20 @@ describe('update()', () => { it('swallows error when invalidate API key throws', async () => { alertsClientParams.invalidateAPIKey.mockRejectedValueOnce(new Error('Fail')); - savedObjectsClient.bulkGet.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.bulkGet.mockResolvedValueOnce({ saved_objects: [ { id: '1', type: 'action', attributes: { + actions: [], actionTypeId: 'test', }, references: [], }, ], }); - savedObjectsClient.update.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.update.mockResolvedValueOnce({ id: '1', type: 'alert', attributes: { @@ -2511,12 +3325,13 @@ describe('update()', () => { it('swallows error when getDecryptedAsInternalUser throws', async () => { encryptedSavedObjects.getDecryptedAsInternalUser.mockRejectedValue(new Error('Fail')); - savedObjectsClient.bulkGet.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.bulkGet.mockResolvedValueOnce({ saved_objects: [ { id: '1', type: 'action', attributes: { + actions: [], actionTypeId: 'test', }, references: [], @@ -2525,13 +3340,14 @@ describe('update()', () => { id: '2', type: 'action', attributes: { + actions: [], actionTypeId: 'test2', }, references: [], }, ], }); - savedObjectsClient.update.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.update.mockResolvedValueOnce({ id: '1', type: 'alert', attributes: { @@ -2623,7 +3439,7 @@ describe('update()', () => { ], }, }); - expect(savedObjectsClient.get).toHaveBeenCalledWith('alert', '1'); + expect(unsecuredSavedObjectsClient.get).toHaveBeenCalledWith('alert', '1'); expect(alertsClientParams.logger.error).toHaveBeenCalledWith( 'update(): Failed to load API key to invalidate on alert 1: Fail' ); @@ -2643,14 +3459,15 @@ describe('update()', () => { actionGroups: [{ id: 'default', name: 'Default' }], defaultActionGroupId: 'default', async executor() {}, - producer: 'alerting', + producer: 'alerts', }); - savedObjectsClient.bulkGet.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.bulkGet.mockResolvedValueOnce({ saved_objects: [ { id: '1', type: 'action', attributes: { + actions: [], actionTypeId: 'test', }, references: [], @@ -2661,6 +3478,7 @@ describe('update()', () => { id: alertId, type: 'alert', attributes: { + actions: [], enabled: true, alertTypeId: '123', schedule: currentSchedule, @@ -2683,7 +3501,7 @@ describe('update()', () => { params: {}, ownerId: null, }); - savedObjectsClient.update.mockResolvedValueOnce({ + unsecuredSavedObjectsClient.update.mockResolvedValueOnce({ id: alertId, type: 'alert', attributes: { @@ -2852,6 +3670,73 @@ describe('update()', () => { ); }); }); + + describe('authorization', () => { + beforeEach(() => { + unsecuredSavedObjectsClient.update.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + alertTypeId: 'myType', + consumer: 'myApp', + enabled: true, + schedule: { interval: '10s' }, + params: { + bar: true, + }, + actions: [], + scheduledTaskId: 'task-123', + createdAt: new Date().toISOString(), + }, + updated_at: new Date().toISOString(), + references: [], + }); + }); + + test('ensures user is authorised to update this type of alert under the consumer', async () => { + await alertsClient.update({ + id: '1', + data: { + schedule: { interval: '10s' }, + name: 'abc', + tags: ['foo'], + params: { + bar: true, + }, + throttle: null, + actions: [], + }, + }); + + expect(authorization.ensureAuthorized).toHaveBeenCalledWith('myType', 'myApp', 'update'); + }); + + test('throws when user is not authorised to update this type of alert', async () => { + authorization.ensureAuthorized.mockRejectedValue( + new Error(`Unauthorized to update a "myType" alert for "myApp"`) + ); + + await expect( + alertsClient.update({ + id: '1', + data: { + schedule: { interval: '10s' }, + name: 'abc', + tags: ['foo'], + params: { + bar: true, + }, + throttle: null, + actions: [], + }, + }) + ).rejects.toMatchInlineSnapshot( + `[Error: Unauthorized to update a "myType" alert for "myApp"]` + ); + + expect(authorization.ensureAuthorized).toHaveBeenCalledWith('myType', 'myApp', 'update'); + }); + }); }); describe('updateApiKey()', () => { @@ -2861,8 +3746,20 @@ describe('updateApiKey()', () => { type: 'alert', attributes: { schedule: { interval: '10s' }, - alertTypeId: '2', + alertTypeId: 'myType', + consumer: 'myApp', enabled: true, + actions: [ + { + group: 'default', + id: '1', + actionTypeId: '1', + actionRef: '1', + params: { + foo: true, + }, + }, + ], }, version: '123', references: [], @@ -2877,30 +3774,42 @@ describe('updateApiKey()', () => { beforeEach(() => { alertsClient = new AlertsClient(alertsClientParams); - savedObjectsClient.get.mockResolvedValue(existingAlert); + unsecuredSavedObjectsClient.get.mockResolvedValue(existingAlert); encryptedSavedObjects.getDecryptedAsInternalUser.mockResolvedValue(existingEncryptedAlert); alertsClientParams.createAPIKey.mockResolvedValueOnce({ apiKeysEnabled: true, - result: { id: '234', api_key: 'abc' }, + result: { id: '234', name: '123', api_key: 'abc' }, }); }); test('updates the API key for the alert', async () => { await alertsClient.updateApiKey({ id: '1' }); - expect(savedObjectsClient.get).not.toHaveBeenCalled(); + expect(unsecuredSavedObjectsClient.get).not.toHaveBeenCalled(); expect(encryptedSavedObjects.getDecryptedAsInternalUser).toHaveBeenCalledWith('alert', '1', { namespace: 'default', }); - expect(savedObjectsClient.update).toHaveBeenCalledWith( + expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledWith( 'alert', '1', { schedule: { interval: '10s' }, - alertTypeId: '2', + alertTypeId: 'myType', + consumer: 'myApp', enabled: true, apiKey: Buffer.from('234:abc').toString('base64'), apiKeyOwner: 'elastic', updatedBy: 'elastic', + actions: [ + { + group: 'default', + id: '1', + actionTypeId: '1', + actionRef: '1', + params: { + foo: true, + }, + }, + ], }, { version: '123' } ); @@ -2911,20 +3820,32 @@ describe('updateApiKey()', () => { encryptedSavedObjects.getDecryptedAsInternalUser.mockRejectedValueOnce(new Error('Fail')); await alertsClient.updateApiKey({ id: '1' }); - expect(savedObjectsClient.get).toHaveBeenCalledWith('alert', '1'); + expect(unsecuredSavedObjectsClient.get).toHaveBeenCalledWith('alert', '1'); expect(encryptedSavedObjects.getDecryptedAsInternalUser).toHaveBeenCalledWith('alert', '1', { namespace: 'default', }); - expect(savedObjectsClient.update).toHaveBeenCalledWith( + expect(unsecuredSavedObjectsClient.update).toHaveBeenCalledWith( 'alert', '1', { schedule: { interval: '10s' }, - alertTypeId: '2', + alertTypeId: 'myType', + consumer: 'myApp', enabled: true, apiKey: Buffer.from('234:abc').toString('base64'), apiKeyOwner: 'elastic', updatedBy: 'elastic', + actions: [ + { + group: 'default', + id: '1', + actionTypeId: '1', + actionRef: '1', + params: { + foo: true, + }, + }, + ], }, { version: '123' } ); @@ -2938,7 +3859,7 @@ describe('updateApiKey()', () => { expect(alertsClientParams.logger.error).toHaveBeenCalledWith( 'Failed to invalidate API Key: Fail' ); - expect(savedObjectsClient.update).toHaveBeenCalled(); + expect(unsecuredSavedObjectsClient.update).toHaveBeenCalled(); }); test('swallows error when getting decrypted object throws', async () => { @@ -2948,16 +3869,133 @@ describe('updateApiKey()', () => { expect(alertsClientParams.logger.error).toHaveBeenCalledWith( 'updateApiKey(): Failed to load API key to invalidate on alert 1: Fail' ); - expect(savedObjectsClient.update).toHaveBeenCalled(); + expect(unsecuredSavedObjectsClient.update).toHaveBeenCalled(); expect(alertsClientParams.invalidateAPIKey).not.toHaveBeenCalled(); }); - test('throws when savedObjectsClient update fails', async () => { - savedObjectsClient.update.mockRejectedValueOnce(new Error('Fail')); + test('throws when unsecuredSavedObjectsClient update fails', async () => { + unsecuredSavedObjectsClient.update.mockRejectedValueOnce(new Error('Fail')); await expect(alertsClient.updateApiKey({ id: '1' })).rejects.toThrowErrorMatchingInlineSnapshot( `"Fail"` ); expect(alertsClientParams.invalidateAPIKey).not.toHaveBeenCalled(); }); + + describe('authorization', () => { + test('ensures user is authorised to updateApiKey this type of alert under the consumer', async () => { + await alertsClient.updateApiKey({ id: '1' }); + + expect(actionsAuthorization.ensureAuthorized).toHaveBeenCalledWith('execute'); + expect(authorization.ensureAuthorized).toHaveBeenCalledWith( + 'myType', + 'myApp', + 'updateApiKey' + ); + }); + + test('throws when user is not authorised to updateApiKey this type of alert', async () => { + authorization.ensureAuthorized.mockRejectedValue( + new Error(`Unauthorized to updateApiKey a "myType" alert for "myApp"`) + ); + + await expect(alertsClient.updateApiKey({ id: '1' })).rejects.toMatchInlineSnapshot( + `[Error: Unauthorized to updateApiKey a "myType" alert for "myApp"]` + ); + + expect(authorization.ensureAuthorized).toHaveBeenCalledWith( + 'myType', + 'myApp', + 'updateApiKey' + ); + }); + }); +}); + +describe('listAlertTypes', () => { + let alertsClient: AlertsClient; + const alertingAlertType = { + actionGroups: [], + actionVariables: undefined, + defaultActionGroupId: 'default', + id: 'alertingAlertType', + name: 'alertingAlertType', + producer: 'alerts', + }; + const myAppAlertType = { + actionGroups: [], + actionVariables: undefined, + defaultActionGroupId: 'default', + id: 'myAppAlertType', + name: 'myAppAlertType', + producer: 'myApp', + }; + const setOfAlertTypes = new Set([myAppAlertType, alertingAlertType]); + + const authorizedConsumers = { + alerts: { read: true, all: true }, + myApp: { read: true, all: true }, + myOtherApp: { read: true, all: true }, + }; + + beforeEach(() => { + alertsClient = new AlertsClient(alertsClientParams); + }); + + test('should return a list of AlertTypes that exist in the registry', async () => { + alertTypeRegistry.list.mockReturnValue(setOfAlertTypes); + authorization.filterByAlertTypeAuthorization.mockResolvedValue( + new Set([ + { ...myAppAlertType, authorizedConsumers }, + { ...alertingAlertType, authorizedConsumers }, + ]) + ); + expect(await alertsClient.listAlertTypes()).toEqual( + new Set([ + { ...myAppAlertType, authorizedConsumers }, + { ...alertingAlertType, authorizedConsumers }, + ]) + ); + }); + + describe('authorization', () => { + const listedTypes = new Set([ + { + actionGroups: [], + actionVariables: undefined, + defaultActionGroupId: 'default', + id: 'myType', + name: 'myType', + producer: 'myApp', + }, + { + id: 'myOtherType', + name: 'Test', + actionGroups: [{ id: 'default', name: 'Default' }], + defaultActionGroupId: 'default', + producer: 'alerts', + }, + ]); + beforeEach(() => { + alertTypeRegistry.list.mockReturnValue(listedTypes); + }); + + test('should return a list of AlertTypes that exist in the registry only if the user is authorised to get them', async () => { + const authorizedTypes = new Set([ + { + id: 'myType', + name: 'Test', + actionGroups: [{ id: 'default', name: 'Default' }], + defaultActionGroupId: 'default', + producer: 'alerts', + authorizedConsumers: { + myApp: { read: true, all: true }, + }, + }, + ]); + authorization.filterByAlertTypeAuthorization.mockResolvedValue(authorizedTypes); + + expect(await alertsClient.listAlertTypes()).toEqual(authorizedTypes); + }); + }); }); diff --git a/x-pack/plugins/alerts/server/alerts_client.ts b/x-pack/plugins/alerts/server/alerts_client.ts index e49745b186bb..eec60f924bf3 100644 --- a/x-pack/plugins/alerts/server/alerts_client.ts +++ b/x-pack/plugins/alerts/server/alerts_client.ts @@ -5,7 +5,7 @@ */ import Boom from 'boom'; -import { omit, isEqual, map, truncate } from 'lodash'; +import { omit, isEqual, map, uniq, pick, truncate } from 'lodash'; import { i18n } from '@kbn/i18n'; import { Logger, @@ -13,7 +13,7 @@ import { SavedObjectReference, SavedObject, } from 'src/core/server'; -import { ActionsClient } from '../../actions/server'; +import { ActionsClient, ActionsAuthorization } from '../../actions/server'; import { Alert, PartialAlert, @@ -35,7 +35,16 @@ import { EncryptedSavedObjectsClient } from '../../encrypted_saved_objects/serve import { TaskManagerStartContract } from '../../task_manager/server'; import { taskInstanceToAlertTaskInstance } from './task_runner/alert_task_instance'; import { deleteTaskIfItExists } from './lib/delete_task_if_it_exists'; +import { RegistryAlertType } from './alert_type_registry'; +import { + AlertsAuthorization, + WriteOperations, + ReadOperations, +} from './authorization/alerts_authorization'; +export interface RegistryAlertTypeWithAuth extends RegistryAlertType { + authorizedConsumers: string[]; +} type NormalizedAlertAction = Omit; export type CreateAPIKeyResult = | { apiKeysEnabled: false } @@ -44,10 +53,12 @@ export type InvalidateAPIKeyResult = | { apiKeysEnabled: false } | { apiKeysEnabled: true; result: SecurityPluginInvalidateAPIKeyResult }; -interface ConstructorOptions { +export interface ConstructorOptions { logger: Logger; taskManager: TaskManagerStartContract; - savedObjectsClient: SavedObjectsClientContract; + unsecuredSavedObjectsClient: SavedObjectsClientContract; + authorization: AlertsAuthorization; + actionsAuthorization: ActionsAuthorization; alertTypeRegistry: AlertTypeRegistry; encryptedSavedObjectsClient: EncryptedSavedObjectsClient; spaceId?: string; @@ -127,18 +138,21 @@ export class AlertsClient { private readonly spaceId?: string; private readonly namespace?: string; private readonly taskManager: TaskManagerStartContract; - private readonly savedObjectsClient: SavedObjectsClientContract; + private readonly unsecuredSavedObjectsClient: SavedObjectsClientContract; + private readonly authorization: AlertsAuthorization; private readonly alertTypeRegistry: AlertTypeRegistry; private readonly createAPIKey: (name: string) => Promise; private readonly invalidateAPIKey: ( params: InvalidateAPIKeyParams ) => Promise; private readonly getActionsClient: () => Promise; + private readonly actionsAuthorization: ActionsAuthorization; encryptedSavedObjectsClient: EncryptedSavedObjectsClient; constructor({ alertTypeRegistry, - savedObjectsClient, + unsecuredSavedObjectsClient, + authorization, taskManager, logger, spaceId, @@ -148,6 +162,7 @@ export class AlertsClient { invalidateAPIKey, encryptedSavedObjectsClient, getActionsClient, + actionsAuthorization, }: ConstructorOptions) { this.logger = logger; this.getUserName = getUserName; @@ -155,16 +170,25 @@ export class AlertsClient { this.namespace = namespace; this.taskManager = taskManager; this.alertTypeRegistry = alertTypeRegistry; - this.savedObjectsClient = savedObjectsClient; + this.unsecuredSavedObjectsClient = unsecuredSavedObjectsClient; + this.authorization = authorization; this.createAPIKey = createAPIKey; this.invalidateAPIKey = invalidateAPIKey; this.encryptedSavedObjectsClient = encryptedSavedObjectsClient; this.getActionsClient = getActionsClient; + this.actionsAuthorization = actionsAuthorization; } public async create({ data, options }: CreateOptions): Promise { + await this.authorization.ensureAuthorized( + data.alertTypeId, + data.consumer, + WriteOperations.Create + ); + // Throws an error if alert type isn't registered const alertType = this.alertTypeRegistry.get(data.alertTypeId); + const validatedAlertTypeParams = validateAlertTypeParams(alertType, data.params); const username = await this.getUserName(); @@ -186,7 +210,7 @@ export class AlertsClient { muteAll: false, mutedInstanceIds: [], }; - const createdAlert = await this.savedObjectsClient.create('alert', rawAlert, { + const createdAlert = await this.unsecuredSavedObjectsClient.create('alert', rawAlert, { ...options, references, }); @@ -197,7 +221,7 @@ export class AlertsClient { } catch (e) { // Cleanup data, something went wrong scheduling the task try { - await this.savedObjectsClient.delete('alert', createdAlert.id); + await this.unsecuredSavedObjectsClient.delete('alert', createdAlert.id); } catch (err) { // Skip the cleanup error and throw the task manager error to avoid confusion this.logger.error( @@ -206,7 +230,7 @@ export class AlertsClient { } throw e; } - await this.savedObjectsClient.update('alert', createdAlert.id, { + await this.unsecuredSavedObjectsClient.update('alert', createdAlert.id, { scheduledTaskId: scheduledTask.id, }); createdAlert.attributes.scheduledTaskId = scheduledTask.id; @@ -220,12 +244,22 @@ export class AlertsClient { } public async get({ id }: { id: string }): Promise { - const result = await this.savedObjectsClient.get('alert', id); + const result = await this.unsecuredSavedObjectsClient.get('alert', id); + await this.authorization.ensureAuthorized( + result.attributes.alertTypeId, + result.attributes.consumer, + ReadOperations.Get + ); return this.getAlertFromRaw(result.id, result.attributes, result.updated_at, result.references); } public async getAlertState({ id }: { id: string }): Promise { const alert = await this.get({ id }); + await this.authorization.ensureAuthorized( + alert.alertTypeId, + alert.consumer, + ReadOperations.GetAlertState + ); if (alert.scheduledTaskId) { const { state } = taskInstanceToAlertTaskInstance( await this.taskManager.get(alert.scheduledTaskId), @@ -235,30 +269,56 @@ export class AlertsClient { } } - public async find({ options = {} }: { options: FindOptions }): Promise { + public async find({ + options: { fields, ...options } = {}, + }: { options?: FindOptions } = {}): Promise { + const { + filter: authorizationFilter, + ensureAlertTypeIsAuthorized, + logSuccessfulAuthorization, + } = await this.authorization.getFindAuthorizationFilter(); + + if (authorizationFilter) { + options.filter = options.filter + ? `${options.filter} and ${authorizationFilter}` + : authorizationFilter; + } + const { page, per_page: perPage, total, saved_objects: data, - } = await this.savedObjectsClient.find({ + } = await this.unsecuredSavedObjectsClient.find({ ...options, + fields: fields ? this.includeFieldsRequiredForAuthentication(fields) : fields, type: 'alert', }); + const authorizedData = data.map(({ id, attributes, updated_at, references }) => { + ensureAlertTypeIsAuthorized(attributes.alertTypeId, attributes.consumer); + return this.getAlertFromRaw( + id, + fields ? (pick(attributes, fields) as RawAlert) : attributes, + updated_at, + references + ); + }); + + logSuccessfulAuthorization(); + return { page, perPage, total, - data: data.map(({ id, attributes, updated_at, references }) => - this.getAlertFromRaw(id, attributes, updated_at, references) - ), + data: authorizedData, }; } public async delete({ id }: { id: string }) { let taskIdToRemove: string | undefined; let apiKeyToInvalidate: string | null = null; + let attributes: RawAlert; try { const decryptedAlert = await this.encryptedSavedObjectsClient.getDecryptedAsInternalUser< @@ -266,17 +326,25 @@ export class AlertsClient { >('alert', id, { namespace: this.namespace }); apiKeyToInvalidate = decryptedAlert.attributes.apiKey; taskIdToRemove = decryptedAlert.attributes.scheduledTaskId; + attributes = decryptedAlert.attributes; } catch (e) { // We'll skip invalidating the API key since we failed to load the decrypted saved object this.logger.error( `delete(): Failed to load API key to invalidate on alert ${id}: ${e.message}` ); // Still attempt to load the scheduledTaskId using SOC - const alert = await this.savedObjectsClient.get('alert', id); + const alert = await this.unsecuredSavedObjectsClient.get('alert', id); taskIdToRemove = alert.attributes.scheduledTaskId; + attributes = alert.attributes; } - const removeResult = await this.savedObjectsClient.delete('alert', id); + await this.authorization.ensureAuthorized( + attributes.alertTypeId, + attributes.consumer, + WriteOperations.Delete + ); + + const removeResult = await this.unsecuredSavedObjectsClient.delete('alert', id); await Promise.all([ taskIdToRemove ? deleteTaskIfItExists(this.taskManager, taskIdToRemove) : null, @@ -299,8 +367,13 @@ export class AlertsClient { `update(): Failed to load API key to invalidate on alert ${id}: ${e.message}` ); // Still attempt to load the object using SOC - alertSavedObject = await this.savedObjectsClient.get('alert', id); + alertSavedObject = await this.unsecuredSavedObjectsClient.get('alert', id); } + await this.authorization.ensureAuthorized( + alertSavedObject.attributes.alertTypeId, + alertSavedObject.attributes.consumer, + WriteOperations.Update + ); const updateResult = await this.updateAlert({ id, data }, alertSavedObject); @@ -342,7 +415,7 @@ export class AlertsClient { : null; const apiKeyAttributes = this.apiKeyAsAlertAttributes(createdAPIKey, username); - const updatedObject = await this.savedObjectsClient.update( + const updatedObject = await this.unsecuredSavedObjectsClient.update( 'alert', id, { @@ -400,13 +473,22 @@ export class AlertsClient { `updateApiKey(): Failed to load API key to invalidate on alert ${id}: ${e.message}` ); // Still attempt to load the attributes and version using SOC - const alert = await this.savedObjectsClient.get('alert', id); + const alert = await this.unsecuredSavedObjectsClient.get('alert', id); attributes = alert.attributes; version = alert.version; } + await this.authorization.ensureAuthorized( + attributes.alertTypeId, + attributes.consumer, + WriteOperations.UpdateApiKey + ); + + if (attributes.actions.length) { + await this.actionsAuthorization.ensureAuthorized('execute'); + } const username = await this.getUserName(); - await this.savedObjectsClient.update( + await this.unsecuredSavedObjectsClient.update( 'alert', id, { @@ -459,14 +541,24 @@ export class AlertsClient { `enable(): Failed to load API key to invalidate on alert ${id}: ${e.message}` ); // Still attempt to load the attributes and version using SOC - const alert = await this.savedObjectsClient.get('alert', id); + const alert = await this.unsecuredSavedObjectsClient.get('alert', id); attributes = alert.attributes; version = alert.version; } + await this.authorization.ensureAuthorized( + attributes.alertTypeId, + attributes.consumer, + WriteOperations.Enable + ); + + if (attributes.actions.length) { + await this.actionsAuthorization.ensureAuthorized('execute'); + } + if (attributes.enabled === false) { const username = await this.getUserName(); - await this.savedObjectsClient.update( + await this.unsecuredSavedObjectsClient.update( 'alert', id, { @@ -483,7 +575,9 @@ export class AlertsClient { { version } ); const scheduledTask = await this.scheduleAlert(id, attributes.alertTypeId); - await this.savedObjectsClient.update('alert', id, { scheduledTaskId: scheduledTask.id }); + await this.unsecuredSavedObjectsClient.update('alert', id, { + scheduledTaskId: scheduledTask.id, + }); if (apiKeyToInvalidate) { await this.invalidateApiKey({ apiKey: apiKeyToInvalidate }); } @@ -508,13 +602,19 @@ export class AlertsClient { `disable(): Failed to load API key to invalidate on alert ${id}: ${e.message}` ); // Still attempt to load the attributes and version using SOC - const alert = await this.savedObjectsClient.get('alert', id); + const alert = await this.unsecuredSavedObjectsClient.get('alert', id); attributes = alert.attributes; version = alert.version; } + await this.authorization.ensureAuthorized( + attributes.alertTypeId, + attributes.consumer, + WriteOperations.Disable + ); + if (attributes.enabled === true) { - await this.savedObjectsClient.update( + await this.unsecuredSavedObjectsClient.update( 'alert', id, { @@ -538,7 +638,18 @@ export class AlertsClient { } public async muteAll({ id }: { id: string }) { - await this.savedObjectsClient.update('alert', id, { + const { attributes } = await this.unsecuredSavedObjectsClient.get('alert', id); + await this.authorization.ensureAuthorized( + attributes.alertTypeId, + attributes.consumer, + WriteOperations.MuteAll + ); + + if (attributes.actions.length) { + await this.actionsAuthorization.ensureAuthorized('execute'); + } + + await this.unsecuredSavedObjectsClient.update('alert', id, { muteAll: true, mutedInstanceIds: [], updatedBy: await this.getUserName(), @@ -546,7 +657,18 @@ export class AlertsClient { } public async unmuteAll({ id }: { id: string }) { - await this.savedObjectsClient.update('alert', id, { + const { attributes } = await this.unsecuredSavedObjectsClient.get('alert', id); + await this.authorization.ensureAuthorized( + attributes.alertTypeId, + attributes.consumer, + WriteOperations.UnmuteAll + ); + + if (attributes.actions.length) { + await this.actionsAuthorization.ensureAuthorized('execute'); + } + + await this.unsecuredSavedObjectsClient.update('alert', id, { muteAll: false, mutedInstanceIds: [], updatedBy: await this.getUserName(), @@ -554,11 +676,25 @@ export class AlertsClient { } public async muteInstance({ alertId, alertInstanceId }: MuteOptions) { - const { attributes, version } = await this.savedObjectsClient.get('alert', alertId); + const { attributes, version } = await this.unsecuredSavedObjectsClient.get( + 'alert', + alertId + ); + + await this.authorization.ensureAuthorized( + attributes.alertTypeId, + attributes.consumer, + WriteOperations.MuteInstance + ); + + if (attributes.actions.length) { + await this.actionsAuthorization.ensureAuthorized('execute'); + } + const mutedInstanceIds = attributes.mutedInstanceIds || []; if (!attributes.muteAll && !mutedInstanceIds.includes(alertInstanceId)) { mutedInstanceIds.push(alertInstanceId); - await this.savedObjectsClient.update( + await this.unsecuredSavedObjectsClient.update( 'alert', alertId, { @@ -577,10 +713,22 @@ export class AlertsClient { alertId: string; alertInstanceId: string; }) { - const { attributes, version } = await this.savedObjectsClient.get('alert', alertId); + const { attributes, version } = await this.unsecuredSavedObjectsClient.get( + 'alert', + alertId + ); + await this.authorization.ensureAuthorized( + attributes.alertTypeId, + attributes.consumer, + WriteOperations.UnmuteInstance + ); + if (attributes.actions.length) { + await this.actionsAuthorization.ensureAuthorized('execute'); + } + const mutedInstanceIds = attributes.mutedInstanceIds || []; if (!attributes.muteAll && mutedInstanceIds.includes(alertInstanceId)) { - await this.savedObjectsClient.update( + await this.unsecuredSavedObjectsClient.update( 'alert', alertId, { @@ -593,6 +741,13 @@ export class AlertsClient { } } + public async listAlertTypes() { + return await this.authorization.filterByAlertTypeAuthorization(this.alertTypeRegistry.list(), [ + ReadOperations.Get, + WriteOperations.Create, + ]); + } + private async scheduleAlert(id: string, alertTypeId: string) { return await this.taskManager.schedule({ taskType: `alerting:${alertTypeId}`, @@ -610,13 +765,14 @@ export class AlertsClient { } private injectReferencesIntoActions( + alertId: string, actions: RawAlert['actions'], references: SavedObjectReference[] ) { return actions.map((action) => { const reference = references.find((ref) => ref.name === action.actionRef); if (!reference) { - throw new Error(`Reference ${action.actionRef} not found`); + throw new Error(`Action reference "${action.actionRef}" not found in alert id: ${alertId}`); } return { ...omit(action, 'actionRef'), @@ -639,8 +795,8 @@ export class AlertsClient { private getPartialAlertFromRaw( id: string, - rawAlert: Partial, - updatedAt: SavedObject['updated_at'], + { createdAt, ...rawAlert }: Partial, + updatedAt: SavedObject['updated_at'] = createdAt, references: SavedObjectReference[] | undefined ): PartialAlert { return { @@ -649,11 +805,11 @@ export class AlertsClient { // we currently only support the Interval Schedule type // Once we support additional types, this type signature will likely change schedule: rawAlert.schedule as IntervalSchedule, - updatedAt: updatedAt ? new Date(updatedAt) : new Date(rawAlert.createdAt!), - createdAt: new Date(rawAlert.createdAt!), actions: rawAlert.actions - ? this.injectReferencesIntoActions(rawAlert.actions, references || []) + ? this.injectReferencesIntoActions(id, rawAlert.actions, references || []) : [], + ...(updatedAt ? { updatedAt: new Date(updatedAt) } : {}), + ...(createdAt ? { createdAt: new Date(createdAt) } : {}), }; } @@ -679,38 +835,45 @@ export class AlertsClient { private async denormalizeActions( alertActions: NormalizedAlertAction[] ): Promise<{ actions: RawAlert['actions']; references: SavedObjectReference[] }> { - const actionsClient = await this.getActionsClient(); - const actionIds = [...new Set(alertActions.map((alertAction) => alertAction.id))]; - const actionResults = await actionsClient.getBulk(actionIds); const references: SavedObjectReference[] = []; - const actions = alertActions.map(({ id, ...alertAction }, i) => { - const actionResultValue = actionResults.find((action) => action.id === id); - if (actionResultValue) { - const actionRef = `action_${i}`; - references.push({ - id, - name: actionRef, - type: 'action', - }); - return { - ...alertAction, - actionRef, - actionTypeId: actionResultValue.actionTypeId, - }; - } else { - return { - ...alertAction, - actionRef: '', - actionTypeId: '', - }; - } - }); + const actions: RawAlert['actions'] = []; + if (alertActions.length) { + const actionsClient = await this.getActionsClient(); + const actionIds = [...new Set(alertActions.map((alertAction) => alertAction.id))]; + const actionResults = await actionsClient.getBulk(actionIds); + alertActions.forEach(({ id, ...alertAction }, i) => { + const actionResultValue = actionResults.find((action) => action.id === id); + if (actionResultValue) { + const actionRef = `action_${i}`; + references.push({ + id, + name: actionRef, + type: 'action', + }); + actions.push({ + ...alertAction, + actionRef, + actionTypeId: actionResultValue.actionTypeId, + }); + } else { + actions.push({ + ...alertAction, + actionRef: '', + actionTypeId: '', + }); + } + }); + } return { actions, references, }; } + private includeFieldsRequiredForAuthentication(fields: string[]): string[] { + return uniq([...fields, 'alertTypeId', 'consumer']); + } + private generateAPIKeyName(alertTypeId: string, alertName: string) { return truncate(`Alerting: ${alertTypeId}/${alertName}`, { length: 256 }); } diff --git a/x-pack/plugins/alerts/server/alerts_client_factory.test.ts b/x-pack/plugins/alerts/server/alerts_client_factory.test.ts index 128d54c10b66..16b5af499bb9 100644 --- a/x-pack/plugins/alerts/server/alerts_client_factory.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client_factory.test.ts @@ -9,24 +9,39 @@ import { AlertsClientFactory, AlertsClientFactoryOpts } from './alerts_client_fa import { alertTypeRegistryMock } from './alert_type_registry.mock'; import { taskManagerMock } from '../../task_manager/server/task_manager.mock'; import { KibanaRequest } from '../../../../src/core/server'; -import { loggingSystemMock, savedObjectsClientMock } from '../../../../src/core/server/mocks'; +import { + savedObjectsClientMock, + savedObjectsServiceMock, + loggingSystemMock, +} from '../../../../src/core/server/mocks'; import { encryptedSavedObjectsMock } from '../../encrypted_saved_objects/server/mocks'; import { AuthenticatedUser } from '../../../plugins/security/common/model'; import { securityMock } from '../../security/server/mocks'; -import { actionsMock } from '../../actions/server/mocks'; +import { PluginStartContract as ActionsStartContract } from '../../actions/server'; +import { actionsMock, actionsAuthorizationMock } from '../../actions/server/mocks'; +import { featuresPluginMock } from '../../features/server/mocks'; +import { AuditLogger } from '../../security/server'; +import { ALERTS_FEATURE_ID } from '../common'; jest.mock('./alerts_client'); +jest.mock('./authorization/alerts_authorization'); +jest.mock('./authorization/audit_logger'); const savedObjectsClient = savedObjectsClientMock.create(); +const savedObjectsService = savedObjectsServiceMock.createInternalStartContract(); +const features = featuresPluginMock.createStart(); + const securityPluginSetup = securityMock.createSetup(); const alertsClientFactoryParams: jest.Mocked = { logger: loggingSystemMock.create().get(), taskManager: taskManagerMock.start(), alertTypeRegistry: alertTypeRegistryMock.create(), getSpaceId: jest.fn(), + getSpace: jest.fn(), spaceIdToNamespace: jest.fn(), encryptedSavedObjectsClient: encryptedSavedObjectsMock.createClient(), actions: actionsMock.createStart(), + features, }; const fakeRequest = ({ headers: {}, @@ -44,19 +59,101 @@ const fakeRequest = ({ getSavedObjectsClient: () => savedObjectsClient, } as unknown) as Request; +const actionsAuthorization = actionsAuthorizationMock.create(); + beforeEach(() => { jest.resetAllMocks(); + alertsClientFactoryParams.actions = actionsMock.createStart(); + (alertsClientFactoryParams.actions as jest.Mocked< + ActionsStartContract + >).getActionsAuthorizationWithRequest.mockReturnValue(actionsAuthorization); alertsClientFactoryParams.getSpaceId.mockReturnValue('default'); alertsClientFactoryParams.spaceIdToNamespace.mockReturnValue('default'); }); +test('creates an alerts client with proper constructor arguments when security is enabled', async () => { + const factory = new AlertsClientFactory(); + factory.initialize({ securityPluginSetup, ...alertsClientFactoryParams }); + const request = KibanaRequest.from(fakeRequest); + + const { AlertsAuthorizationAuditLogger } = jest.requireMock('./authorization/audit_logger'); + savedObjectsService.getScopedClient.mockReturnValue(savedObjectsClient); + + const logger = { + log: jest.fn(), + } as jest.Mocked; + securityPluginSetup.audit.getLogger.mockReturnValue(logger); + + factory.create(request, savedObjectsService); + + expect(savedObjectsService.getScopedClient).toHaveBeenCalledWith(request, { + excludedWrappers: ['security'], + includedHiddenTypes: ['alert'], + }); + + const { AlertsAuthorization } = jest.requireMock('./authorization/alerts_authorization'); + expect(AlertsAuthorization).toHaveBeenCalledWith({ + request, + authorization: securityPluginSetup.authz, + alertTypeRegistry: alertsClientFactoryParams.alertTypeRegistry, + features: alertsClientFactoryParams.features, + auditLogger: expect.any(AlertsAuthorizationAuditLogger), + getSpace: expect.any(Function), + }); + + expect(AlertsAuthorizationAuditLogger).toHaveBeenCalledWith(logger); + expect(securityPluginSetup.audit.getLogger).toHaveBeenCalledWith(ALERTS_FEATURE_ID); + + expect(alertsClientFactoryParams.actions.getActionsAuthorizationWithRequest).toHaveBeenCalledWith( + request + ); + + expect(jest.requireMock('./alerts_client').AlertsClient).toHaveBeenCalledWith({ + unsecuredSavedObjectsClient: savedObjectsClient, + authorization: expect.any(AlertsAuthorization), + actionsAuthorization, + logger: alertsClientFactoryParams.logger, + taskManager: alertsClientFactoryParams.taskManager, + alertTypeRegistry: alertsClientFactoryParams.alertTypeRegistry, + spaceId: 'default', + namespace: 'default', + getUserName: expect.any(Function), + getActionsClient: expect.any(Function), + createAPIKey: expect.any(Function), + invalidateAPIKey: expect.any(Function), + encryptedSavedObjectsClient: alertsClientFactoryParams.encryptedSavedObjectsClient, + }); +}); + test('creates an alerts client with proper constructor arguments', async () => { const factory = new AlertsClientFactory(); factory.initialize(alertsClientFactoryParams); - factory.create(KibanaRequest.from(fakeRequest), savedObjectsClient); + const request = KibanaRequest.from(fakeRequest); + + savedObjectsService.getScopedClient.mockReturnValue(savedObjectsClient); + + factory.create(request, savedObjectsService); + + expect(savedObjectsService.getScopedClient).toHaveBeenCalledWith(request, { + excludedWrappers: ['security'], + includedHiddenTypes: ['alert'], + }); + + const { AlertsAuthorization } = jest.requireMock('./authorization/alerts_authorization'); + const { AlertsAuthorizationAuditLogger } = jest.requireMock('./authorization/audit_logger'); + expect(AlertsAuthorization).toHaveBeenCalledWith({ + request, + authorization: undefined, + alertTypeRegistry: alertsClientFactoryParams.alertTypeRegistry, + features: alertsClientFactoryParams.features, + auditLogger: expect.any(AlertsAuthorizationAuditLogger), + getSpace: expect.any(Function), + }); expect(jest.requireMock('./alerts_client').AlertsClient).toHaveBeenCalledWith({ - savedObjectsClient, + unsecuredSavedObjectsClient: savedObjectsClient, + authorization: expect.any(AlertsAuthorization), + actionsAuthorization, logger: alertsClientFactoryParams.logger, taskManager: alertsClientFactoryParams.taskManager, alertTypeRegistry: alertsClientFactoryParams.alertTypeRegistry, @@ -73,7 +170,7 @@ test('creates an alerts client with proper constructor arguments', async () => { test('getUserName() returns null when security is disabled', async () => { const factory = new AlertsClientFactory(); factory.initialize(alertsClientFactoryParams); - factory.create(KibanaRequest.from(fakeRequest), savedObjectsClient); + factory.create(KibanaRequest.from(fakeRequest), savedObjectsService); const constructorCall = jest.requireMock('./alerts_client').AlertsClient.mock.calls[0][0]; const userNameResult = await constructorCall.getUserName(); @@ -86,7 +183,7 @@ test('getUserName() returns a name when security is enabled', async () => { ...alertsClientFactoryParams, securityPluginSetup, }); - factory.create(KibanaRequest.from(fakeRequest), savedObjectsClient); + factory.create(KibanaRequest.from(fakeRequest), savedObjectsService); const constructorCall = jest.requireMock('./alerts_client').AlertsClient.mock.calls[0][0]; securityPluginSetup.authc.getCurrentUser.mockReturnValueOnce(({ @@ -99,7 +196,7 @@ test('getUserName() returns a name when security is enabled', async () => { test('getActionsClient() returns ActionsClient', async () => { const factory = new AlertsClientFactory(); factory.initialize(alertsClientFactoryParams); - factory.create(KibanaRequest.from(fakeRequest), savedObjectsClient); + factory.create(KibanaRequest.from(fakeRequest), savedObjectsService); const constructorCall = jest.requireMock('./alerts_client').AlertsClient.mock.calls[0][0]; const actionsClient = await constructorCall.getActionsClient(); @@ -109,7 +206,7 @@ test('getActionsClient() returns ActionsClient', async () => { test('createAPIKey() returns { apiKeysEnabled: false } when security is disabled', async () => { const factory = new AlertsClientFactory(); factory.initialize(alertsClientFactoryParams); - factory.create(KibanaRequest.from(fakeRequest), savedObjectsClient); + factory.create(KibanaRequest.from(fakeRequest), savedObjectsService); const constructorCall = jest.requireMock('./alerts_client').AlertsClient.mock.calls[0][0]; const createAPIKeyResult = await constructorCall.createAPIKey(); @@ -119,7 +216,7 @@ test('createAPIKey() returns { apiKeysEnabled: false } when security is disabled test('createAPIKey() returns { apiKeysEnabled: false } when security is enabled but ES security is disabled', async () => { const factory = new AlertsClientFactory(); factory.initialize(alertsClientFactoryParams); - factory.create(KibanaRequest.from(fakeRequest), savedObjectsClient); + factory.create(KibanaRequest.from(fakeRequest), savedObjectsService); const constructorCall = jest.requireMock('./alerts_client').AlertsClient.mock.calls[0][0]; securityPluginSetup.authc.grantAPIKeyAsInternalUser.mockResolvedValueOnce(null); @@ -133,7 +230,7 @@ test('createAPIKey() returns an API key when security is enabled', async () => { ...alertsClientFactoryParams, securityPluginSetup, }); - factory.create(KibanaRequest.from(fakeRequest), savedObjectsClient); + factory.create(KibanaRequest.from(fakeRequest), savedObjectsService); const constructorCall = jest.requireMock('./alerts_client').AlertsClient.mock.calls[0][0]; securityPluginSetup.authc.grantAPIKeyAsInternalUser.mockResolvedValueOnce({ @@ -154,7 +251,7 @@ test('createAPIKey() throws when security plugin createAPIKey throws an error', ...alertsClientFactoryParams, securityPluginSetup, }); - factory.create(KibanaRequest.from(fakeRequest), savedObjectsClient); + factory.create(KibanaRequest.from(fakeRequest), savedObjectsService); const constructorCall = jest.requireMock('./alerts_client').AlertsClient.mock.calls[0][0]; securityPluginSetup.authc.grantAPIKeyAsInternalUser.mockRejectedValueOnce( diff --git a/x-pack/plugins/alerts/server/alerts_client_factory.ts b/x-pack/plugins/alerts/server/alerts_client_factory.ts index 30fcd1b949f2..79b0ccaf1f0b 100644 --- a/x-pack/plugins/alerts/server/alerts_client_factory.ts +++ b/x-pack/plugins/alerts/server/alerts_client_factory.ts @@ -6,11 +6,16 @@ import { PluginStartContract as ActionsPluginStartContract } from '../../actions/server'; import { AlertsClient } from './alerts_client'; +import { ALERTS_FEATURE_ID } from '../common'; import { AlertTypeRegistry, SpaceIdToNamespaceFunction } from './types'; -import { KibanaRequest, Logger, SavedObjectsClientContract } from '../../../../src/core/server'; +import { KibanaRequest, Logger, SavedObjectsServiceStart } from '../../../../src/core/server'; import { InvalidateAPIKeyParams, SecurityPluginSetup } from '../../security/server'; import { EncryptedSavedObjectsClient } from '../../encrypted_saved_objects/server'; import { TaskManagerStartContract } from '../../task_manager/server'; +import { PluginStartContract as FeaturesPluginStart } from '../../features/server'; +import { AlertsAuthorization } from './authorization/alerts_authorization'; +import { AlertsAuthorizationAuditLogger } from './authorization/audit_logger'; +import { Space } from '../../spaces/server'; export interface AlertsClientFactoryOpts { logger: Logger; @@ -18,9 +23,11 @@ export interface AlertsClientFactoryOpts { alertTypeRegistry: AlertTypeRegistry; securityPluginSetup?: SecurityPluginSetup; getSpaceId: (request: KibanaRequest) => string | undefined; + getSpace: (request: KibanaRequest) => Promise; spaceIdToNamespace: SpaceIdToNamespaceFunction; encryptedSavedObjectsClient: EncryptedSavedObjectsClient; actions: ActionsPluginStartContract; + features: FeaturesPluginStart; } export class AlertsClientFactory { @@ -30,9 +37,11 @@ export class AlertsClientFactory { private alertTypeRegistry!: AlertTypeRegistry; private securityPluginSetup?: SecurityPluginSetup; private getSpaceId!: (request: KibanaRequest) => string | undefined; + private getSpace!: (request: KibanaRequest) => Promise; private spaceIdToNamespace!: SpaceIdToNamespaceFunction; private encryptedSavedObjectsClient!: EncryptedSavedObjectsClient; private actions!: ActionsPluginStartContract; + private features!: FeaturesPluginStart; public initialize(options: AlertsClientFactoryOpts) { if (this.isInitialized) { @@ -41,26 +50,41 @@ export class AlertsClientFactory { this.isInitialized = true; this.logger = options.logger; this.getSpaceId = options.getSpaceId; + this.getSpace = options.getSpace; this.taskManager = options.taskManager; this.alertTypeRegistry = options.alertTypeRegistry; this.securityPluginSetup = options.securityPluginSetup; this.spaceIdToNamespace = options.spaceIdToNamespace; this.encryptedSavedObjectsClient = options.encryptedSavedObjectsClient; this.actions = options.actions; + this.features = options.features; } - public create( - request: KibanaRequest, - savedObjectsClient: SavedObjectsClientContract - ): AlertsClient { - const { securityPluginSetup, actions } = this; + public create(request: KibanaRequest, savedObjects: SavedObjectsServiceStart): AlertsClient { + const { securityPluginSetup, actions, features } = this; const spaceId = this.getSpaceId(request); + const authorization = new AlertsAuthorization({ + authorization: securityPluginSetup?.authz, + request, + getSpace: this.getSpace, + alertTypeRegistry: this.alertTypeRegistry, + features: features!, + auditLogger: new AlertsAuthorizationAuditLogger( + securityPluginSetup?.audit.getLogger(ALERTS_FEATURE_ID) + ), + }); + return new AlertsClient({ spaceId, logger: this.logger, taskManager: this.taskManager, alertTypeRegistry: this.alertTypeRegistry, - savedObjectsClient, + unsecuredSavedObjectsClient: savedObjects.getScopedClient(request, { + excludedWrappers: ['security'], + includedHiddenTypes: ['alert'], + }), + authorization, + actionsAuthorization: actions.getActionsAuthorizationWithRequest(request), namespace: this.spaceIdToNamespace(spaceId), encryptedSavedObjectsClient: this.encryptedSavedObjectsClient, async getUserName() { diff --git a/x-pack/plugins/alerts/server/authorization/alerts_authorization.mock.ts b/x-pack/plugins/alerts/server/authorization/alerts_authorization.mock.ts new file mode 100644 index 000000000000..d7705f834ad4 --- /dev/null +++ b/x-pack/plugins/alerts/server/authorization/alerts_authorization.mock.ts @@ -0,0 +1,25 @@ +/* + * 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 { AlertsAuthorization } from './alerts_authorization'; + +type Schema = PublicMethodsOf; +export type AlertsAuthorizationMock = jest.Mocked; + +const createAlertsAuthorizationMock = () => { + const mocked: AlertsAuthorizationMock = { + ensureAuthorized: jest.fn(), + filterByAlertTypeAuthorization: jest.fn(), + getFindAuthorizationFilter: jest.fn(), + }; + return mocked; +}; + +export const alertsAuthorizationMock: { + create: () => AlertsAuthorizationMock; +} = { + create: createAlertsAuthorizationMock, +}; diff --git a/x-pack/plugins/alerts/server/authorization/alerts_authorization.test.ts b/x-pack/plugins/alerts/server/authorization/alerts_authorization.test.ts new file mode 100644 index 000000000000..b164d27ded64 --- /dev/null +++ b/x-pack/plugins/alerts/server/authorization/alerts_authorization.test.ts @@ -0,0 +1,1256 @@ +/* + * 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 { KibanaRequest } from 'kibana/server'; +import { alertTypeRegistryMock } from '../alert_type_registry.mock'; +import { securityMock } from '../../../../plugins/security/server/mocks'; +import { PluginStartContract as FeaturesStartContract, Feature } from '../../../features/server'; +import { featuresPluginMock } from '../../../features/server/mocks'; +import { + AlertsAuthorization, + ensureFieldIsSafeForQuery, + WriteOperations, + ReadOperations, +} from './alerts_authorization'; +import { alertsAuthorizationAuditLoggerMock } from './audit_logger.mock'; +import { AlertsAuthorizationAuditLogger, AuthorizationResult } from './audit_logger'; +import uuid from 'uuid'; + +const alertTypeRegistry = alertTypeRegistryMock.create(); +const features: jest.Mocked = featuresPluginMock.createStart(); +const request = {} as KibanaRequest; + +const auditLogger = alertsAuthorizationAuditLoggerMock.create(); +const realAuditLogger = new AlertsAuthorizationAuditLogger(); + +const getSpace = jest.fn(); + +const mockAuthorizationAction = (type: string, app: string, operation: string) => + `${type}/${app}/${operation}`; +function mockSecurity() { + const security = securityMock.createSetup(); + const authorization = security.authz; + // typescript is having trouble inferring jest's automocking + (authorization.actions.alerting.get as jest.MockedFunction< + typeof authorization.actions.alerting.get + >).mockImplementation(mockAuthorizationAction); + authorization.mode.useRbacForRequest.mockReturnValue(true); + return { authorization }; +} + +function mockFeature(appName: string, typeName?: string) { + return new Feature({ + id: appName, + name: appName, + app: [], + ...(typeName + ? { + alerting: [typeName], + } + : {}), + privileges: { + all: { + ...(typeName + ? { + alerting: { + all: [typeName], + }, + } + : {}), + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + read: { + ...(typeName + ? { + alerting: { + read: [typeName], + }, + } + : {}), + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + }, + }); +} + +function mockFeatureWithSubFeature(appName: string, typeName: string) { + return new Feature({ + id: appName, + name: appName, + app: [], + ...(typeName + ? { + alerting: [typeName], + } + : {}), + privileges: { + all: { + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + read: { + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + }, + subFeatures: [ + { + name: appName, + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'doSomethingAlertRelated', + name: 'sub feature alert', + includeIn: 'all', + alerting: { + all: [typeName], + }, + savedObject: { + all: [], + read: [], + }, + ui: ['doSomethingAlertRelated'], + }, + { + id: 'doSomethingAlertRelated', + name: 'sub feature alert', + includeIn: 'read', + alerting: { + read: [typeName], + }, + savedObject: { + all: [], + read: [], + }, + ui: ['doSomethingAlertRelated'], + }, + ], + }, + ], + }, + ], + }); +} + +const myAppFeature = mockFeature('myApp', 'myType'); +const myOtherAppFeature = mockFeature('myOtherApp', 'myType'); +const myAppWithSubFeature = mockFeatureWithSubFeature('myAppWithSubFeature', 'myType'); +const myFeatureWithoutAlerting = mockFeature('myOtherApp'); + +beforeEach(() => { + jest.resetAllMocks(); + auditLogger.alertsAuthorizationFailure.mockImplementation((username, ...args) => + realAuditLogger.getAuthorizationMessage(AuthorizationResult.Unauthorized, ...args) + ); + auditLogger.alertsAuthorizationSuccess.mockImplementation((username, ...args) => + realAuditLogger.getAuthorizationMessage(AuthorizationResult.Authorized, ...args) + ); + auditLogger.alertsUnscopedAuthorizationFailure.mockImplementation( + (username, operation) => `Unauthorized ${username}/${operation}` + ); + alertTypeRegistry.get.mockImplementation((id) => ({ + id, + name: 'My Alert Type', + actionGroups: [{ id: 'default', name: 'Default' }], + defaultActionGroupId: 'default', + async executor() {}, + producer: 'myApp', + })); + features.getFeatures.mockReturnValue([ + myAppFeature, + myOtherAppFeature, + myAppWithSubFeature, + myFeatureWithoutAlerting, + ]); + getSpace.mockResolvedValue(undefined); +}); + +describe('AlertsAuthorization', () => { + describe('constructor', () => { + test(`fetches the user's current space`, async () => { + const space = { + id: uuid.v4(), + name: uuid.v4(), + disabledFeatures: [], + }; + getSpace.mockResolvedValue(space); + + new AlertsAuthorization({ + request, + alertTypeRegistry, + features, + auditLogger, + getSpace, + }); + + expect(getSpace).toHaveBeenCalledWith(request); + }); + }); + + describe('ensureAuthorized', () => { + test('is a no-op when there is no authorization api', async () => { + const alertAuthorization = new AlertsAuthorization({ + request, + alertTypeRegistry, + features, + auditLogger, + getSpace, + }); + + await alertAuthorization.ensureAuthorized('myType', 'myApp', WriteOperations.Create); + + expect(alertTypeRegistry.get).toHaveBeenCalledTimes(0); + }); + + test('is a no-op when the security license is disabled', async () => { + const { authorization } = mockSecurity(); + authorization.mode.useRbacForRequest.mockReturnValue(false); + const alertAuthorization = new AlertsAuthorization({ + request, + alertTypeRegistry, + authorization, + features, + auditLogger, + getSpace, + }); + + await alertAuthorization.ensureAuthorized('myType', 'myApp', WriteOperations.Create); + + expect(alertTypeRegistry.get).toHaveBeenCalledTimes(0); + }); + + test('ensures the user has privileges to execute the specified type, operation and consumer', async () => { + const { authorization } = mockSecurity(); + const checkPrivileges: jest.MockedFunction> = jest.fn(); + authorization.checkPrivilegesDynamicallyWithRequest.mockReturnValue(checkPrivileges); + const alertAuthorization = new AlertsAuthorization({ + request, + authorization, + alertTypeRegistry, + features, + auditLogger, + getSpace, + }); + + checkPrivileges.mockResolvedValueOnce({ + username: 'some-user', + hasAllRequested: true, + privileges: [], + }); + + await alertAuthorization.ensureAuthorized('myType', 'myApp', WriteOperations.Create); + + expect(alertTypeRegistry.get).toHaveBeenCalledWith('myType'); + + expect(authorization.actions.alerting.get).toHaveBeenCalledWith('myType', 'myApp', 'create'); + expect(checkPrivileges).toHaveBeenCalledWith([ + mockAuthorizationAction('myType', 'myApp', 'create'), + ]); + + expect(auditLogger.alertsAuthorizationSuccess).toHaveBeenCalledTimes(1); + expect(auditLogger.alertsAuthorizationFailure).not.toHaveBeenCalled(); + expect(auditLogger.alertsAuthorizationSuccess.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "some-user", + "myType", + 0, + "myApp", + "create", + ] + `); + }); + + test('ensures the user has privileges to execute the specified type and operation without consumer when consumer is alerts', async () => { + const { authorization } = mockSecurity(); + const checkPrivileges: jest.MockedFunction> = jest.fn(); + authorization.checkPrivilegesDynamicallyWithRequest.mockReturnValue(checkPrivileges); + const alertAuthorization = new AlertsAuthorization({ + request, + authorization, + alertTypeRegistry, + features, + auditLogger, + getSpace, + }); + + checkPrivileges.mockResolvedValueOnce({ + username: 'some-user', + hasAllRequested: true, + privileges: [], + }); + + await alertAuthorization.ensureAuthorized('myType', 'alerts', WriteOperations.Create); + + expect(alertTypeRegistry.get).toHaveBeenCalledWith('myType'); + + expect(authorization.actions.alerting.get).toHaveBeenCalledWith('myType', 'myApp', 'create'); + expect(checkPrivileges).toHaveBeenCalledWith([ + mockAuthorizationAction('myType', 'myApp', 'create'), + ]); + + expect(auditLogger.alertsAuthorizationSuccess).toHaveBeenCalledTimes(1); + expect(auditLogger.alertsAuthorizationFailure).not.toHaveBeenCalled(); + expect(auditLogger.alertsAuthorizationSuccess.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "some-user", + "myType", + 0, + "alerts", + "create", + ] + `); + }); + + test('ensures the user has privileges to execute the specified type, operation and producer when producer is different from consumer', async () => { + const { authorization } = mockSecurity(); + const checkPrivileges: jest.MockedFunction> = jest.fn(); + authorization.checkPrivilegesDynamicallyWithRequest.mockReturnValue(checkPrivileges); + checkPrivileges.mockResolvedValueOnce({ + username: 'some-user', + hasAllRequested: true, + privileges: [], + }); + + const alertAuthorization = new AlertsAuthorization({ + request, + authorization, + alertTypeRegistry, + features, + auditLogger, + getSpace, + }); + + await alertAuthorization.ensureAuthorized('myType', 'myOtherApp', WriteOperations.Create); + + expect(alertTypeRegistry.get).toHaveBeenCalledWith('myType'); + + expect(authorization.actions.alerting.get).toHaveBeenCalledWith('myType', 'myApp', 'create'); + expect(authorization.actions.alerting.get).toHaveBeenCalledWith( + 'myType', + 'myOtherApp', + 'create' + ); + expect(checkPrivileges).toHaveBeenCalledWith([ + mockAuthorizationAction('myType', 'myOtherApp', 'create'), + mockAuthorizationAction('myType', 'myApp', 'create'), + ]); + + expect(auditLogger.alertsAuthorizationSuccess).toHaveBeenCalledTimes(1); + expect(auditLogger.alertsAuthorizationFailure).not.toHaveBeenCalled(); + expect(auditLogger.alertsAuthorizationSuccess.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "some-user", + "myType", + 0, + "myOtherApp", + "create", + ] + `); + }); + + test('throws if user lacks the required privieleges for the consumer', async () => { + const { authorization } = mockSecurity(); + const checkPrivileges: jest.MockedFunction> = jest.fn(); + authorization.checkPrivilegesDynamicallyWithRequest.mockReturnValue(checkPrivileges); + const alertAuthorization = new AlertsAuthorization({ + request, + authorization, + alertTypeRegistry, + features, + auditLogger, + getSpace, + }); + + checkPrivileges.mockResolvedValueOnce({ + username: 'some-user', + hasAllRequested: false, + privileges: [ + { + privilege: mockAuthorizationAction('myType', 'myOtherApp', 'create'), + authorized: false, + }, + { + privilege: mockAuthorizationAction('myType', 'myApp', 'create'), + authorized: true, + }, + ], + }); + + await expect( + alertAuthorization.ensureAuthorized('myType', 'myOtherApp', WriteOperations.Create) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Unauthorized to create a \\"myType\\" alert for \\"myOtherApp\\""` + ); + + expect(auditLogger.alertsAuthorizationSuccess).not.toHaveBeenCalled(); + expect(auditLogger.alertsAuthorizationFailure).toHaveBeenCalledTimes(1); + expect(auditLogger.alertsAuthorizationFailure.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "some-user", + "myType", + 0, + "myOtherApp", + "create", + ] + `); + }); + + test('throws if user lacks the required privieleges for the producer', async () => { + const { authorization } = mockSecurity(); + const checkPrivileges: jest.MockedFunction> = jest.fn(); + authorization.checkPrivilegesDynamicallyWithRequest.mockReturnValue(checkPrivileges); + const alertAuthorization = new AlertsAuthorization({ + request, + authorization, + alertTypeRegistry, + features, + auditLogger, + getSpace, + }); + + checkPrivileges.mockResolvedValueOnce({ + username: 'some-user', + hasAllRequested: false, + privileges: [ + { + privilege: mockAuthorizationAction('myType', 'myOtherApp', 'create'), + authorized: true, + }, + { + privilege: mockAuthorizationAction('myType', 'myApp', 'create'), + authorized: false, + }, + ], + }); + + await expect( + alertAuthorization.ensureAuthorized('myType', 'myOtherApp', WriteOperations.Create) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Unauthorized to create a \\"myType\\" alert by \\"myApp\\""` + ); + + expect(auditLogger.alertsAuthorizationSuccess).not.toHaveBeenCalled(); + expect(auditLogger.alertsAuthorizationFailure).toHaveBeenCalledTimes(1); + expect(auditLogger.alertsAuthorizationFailure.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "some-user", + "myType", + 1, + "myApp", + "create", + ] + `); + }); + + test('throws if user lacks the required privieleges for both consumer and producer', async () => { + const { authorization } = mockSecurity(); + const checkPrivileges: jest.MockedFunction> = jest.fn(); + authorization.checkPrivilegesDynamicallyWithRequest.mockReturnValue(checkPrivileges); + const alertAuthorization = new AlertsAuthorization({ + request, + authorization, + alertTypeRegistry, + features, + auditLogger, + getSpace, + }); + + checkPrivileges.mockResolvedValueOnce({ + username: 'some-user', + hasAllRequested: false, + privileges: [ + { + privilege: mockAuthorizationAction('myType', 'myOtherApp', 'create'), + authorized: false, + }, + { + privilege: mockAuthorizationAction('myType', 'myApp', 'create'), + authorized: false, + }, + ], + }); + + await expect( + alertAuthorization.ensureAuthorized('myType', 'myOtherApp', WriteOperations.Create) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Unauthorized to create a \\"myType\\" alert for \\"myOtherApp\\""` + ); + + expect(auditLogger.alertsAuthorizationSuccess).not.toHaveBeenCalled(); + expect(auditLogger.alertsAuthorizationFailure).toHaveBeenCalledTimes(1); + expect(auditLogger.alertsAuthorizationFailure.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "some-user", + "myType", + 0, + "myOtherApp", + "create", + ] + `); + }); + }); + + describe('getFindAuthorizationFilter', () => { + const myOtherAppAlertType = { + actionGroups: [], + actionVariables: undefined, + defaultActionGroupId: 'default', + id: 'myOtherAppAlertType', + name: 'myOtherAppAlertType', + producer: 'alerts', + }; + const myAppAlertType = { + actionGroups: [], + actionVariables: undefined, + defaultActionGroupId: 'default', + id: 'myAppAlertType', + name: 'myAppAlertType', + producer: 'myApp', + }; + const mySecondAppAlertType = { + actionGroups: [], + actionVariables: undefined, + defaultActionGroupId: 'default', + id: 'mySecondAppAlertType', + name: 'mySecondAppAlertType', + producer: 'myApp', + }; + const setOfAlertTypes = new Set([myAppAlertType, myOtherAppAlertType, mySecondAppAlertType]); + + test('omits filter when there is no authorization api', async () => { + const alertAuthorization = new AlertsAuthorization({ + request, + alertTypeRegistry, + features, + auditLogger, + getSpace, + }); + + const { + filter, + ensureAlertTypeIsAuthorized, + } = await alertAuthorization.getFindAuthorizationFilter(); + + expect(() => ensureAlertTypeIsAuthorized('someMadeUpType', 'myApp')).not.toThrow(); + + expect(filter).toEqual(undefined); + }); + + test('ensureAlertTypeIsAuthorized is no-op when there is no authorization api', async () => { + const alertAuthorization = new AlertsAuthorization({ + request, + alertTypeRegistry, + features, + auditLogger, + getSpace, + }); + + const { ensureAlertTypeIsAuthorized } = await alertAuthorization.getFindAuthorizationFilter(); + + ensureAlertTypeIsAuthorized('someMadeUpType', 'myApp'); + + expect(auditLogger.alertsAuthorizationSuccess).not.toHaveBeenCalled(); + expect(auditLogger.alertsAuthorizationFailure).not.toHaveBeenCalled(); + }); + + test('creates a filter based on the privileged types', async () => { + const { authorization } = mockSecurity(); + const checkPrivileges: jest.MockedFunction> = jest.fn(); + authorization.checkPrivilegesDynamicallyWithRequest.mockReturnValue(checkPrivileges); + checkPrivileges.mockResolvedValueOnce({ + username: 'some-user', + hasAllRequested: true, + privileges: [], + }); + + const alertAuthorization = new AlertsAuthorization({ + request, + authorization, + alertTypeRegistry, + features, + auditLogger, + getSpace, + }); + alertTypeRegistry.list.mockReturnValue(setOfAlertTypes); + + expect((await alertAuthorization.getFindAuthorizationFilter()).filter).toMatchInlineSnapshot( + `"((alert.attributes.alertTypeId:myAppAlertType and alert.attributes.consumer:(alerts or myApp or myOtherApp or myAppWithSubFeature)) or (alert.attributes.alertTypeId:myOtherAppAlertType and alert.attributes.consumer:(alerts or myApp or myOtherApp or myAppWithSubFeature)) or (alert.attributes.alertTypeId:mySecondAppAlertType and alert.attributes.consumer:(alerts or myApp or myOtherApp or myAppWithSubFeature)))"` + ); + + expect(auditLogger.alertsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test('creates an `ensureAlertTypeIsAuthorized` function which throws if type is unauthorized', async () => { + const { authorization } = mockSecurity(); + const checkPrivileges: jest.MockedFunction> = jest.fn(); + authorization.checkPrivilegesDynamicallyWithRequest.mockReturnValue(checkPrivileges); + checkPrivileges.mockResolvedValueOnce({ + username: 'some-user', + hasAllRequested: false, + privileges: [ + { + privilege: mockAuthorizationAction('myOtherAppAlertType', 'myApp', 'find'), + authorized: true, + }, + { + privilege: mockAuthorizationAction('myOtherAppAlertType', 'myOtherApp', 'find'), + authorized: false, + }, + { + privilege: mockAuthorizationAction('myAppAlertType', 'myApp', 'find'), + authorized: true, + }, + { + privilege: mockAuthorizationAction('myAppAlertType', 'myOtherApp', 'find'), + authorized: false, + }, + ], + }); + + const alertAuthorization = new AlertsAuthorization({ + request, + authorization, + alertTypeRegistry, + features, + auditLogger, + getSpace, + }); + alertTypeRegistry.list.mockReturnValue(setOfAlertTypes); + + const { ensureAlertTypeIsAuthorized } = await alertAuthorization.getFindAuthorizationFilter(); + expect(() => { + ensureAlertTypeIsAuthorized('myAppAlertType', 'myOtherApp'); + }).toThrowErrorMatchingInlineSnapshot( + `"Unauthorized to find a \\"myAppAlertType\\" alert for \\"myOtherApp\\""` + ); + + expect(auditLogger.alertsAuthorizationSuccess).not.toHaveBeenCalled(); + expect(auditLogger.alertsAuthorizationFailure).toHaveBeenCalledTimes(1); + expect(auditLogger.alertsAuthorizationFailure.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "some-user", + "myAppAlertType", + 0, + "myOtherApp", + "find", + ] + `); + }); + + test('creates an `ensureAlertTypeIsAuthorized` function which is no-op if type is authorized', async () => { + const { authorization } = mockSecurity(); + const checkPrivileges: jest.MockedFunction> = jest.fn(); + authorization.checkPrivilegesDynamicallyWithRequest.mockReturnValue(checkPrivileges); + checkPrivileges.mockResolvedValueOnce({ + username: 'some-user', + hasAllRequested: false, + privileges: [ + { + privilege: mockAuthorizationAction('myOtherAppAlertType', 'myApp', 'find'), + authorized: true, + }, + { + privilege: mockAuthorizationAction('myOtherAppAlertType', 'myOtherApp', 'find'), + authorized: false, + }, + { + privilege: mockAuthorizationAction('myAppAlertType', 'myApp', 'find'), + authorized: true, + }, + { + privilege: mockAuthorizationAction('myAppAlertType', 'myOtherApp', 'find'), + authorized: true, + }, + ], + }); + + const alertAuthorization = new AlertsAuthorization({ + request, + authorization, + alertTypeRegistry, + features, + auditLogger, + getSpace, + }); + alertTypeRegistry.list.mockReturnValue(setOfAlertTypes); + + const { ensureAlertTypeIsAuthorized } = await alertAuthorization.getFindAuthorizationFilter(); + expect(() => { + ensureAlertTypeIsAuthorized('myAppAlertType', 'myOtherApp'); + }).not.toThrow(); + + expect(auditLogger.alertsAuthorizationSuccess).not.toHaveBeenCalled(); + expect(auditLogger.alertsAuthorizationFailure).not.toHaveBeenCalled(); + }); + + test('creates an `logSuccessfulAuthorization` function which logs every authorized type', async () => { + const { authorization } = mockSecurity(); + const checkPrivileges: jest.MockedFunction> = jest.fn(); + authorization.checkPrivilegesDynamicallyWithRequest.mockReturnValue(checkPrivileges); + checkPrivileges.mockResolvedValueOnce({ + username: 'some-user', + hasAllRequested: false, + privileges: [ + { + privilege: mockAuthorizationAction('myOtherAppAlertType', 'myApp', 'find'), + authorized: true, + }, + { + privilege: mockAuthorizationAction('myOtherAppAlertType', 'myOtherApp', 'find'), + authorized: false, + }, + { + privilege: mockAuthorizationAction('myAppAlertType', 'myApp', 'find'), + authorized: true, + }, + { + privilege: mockAuthorizationAction('myAppAlertType', 'myOtherApp', 'find'), + authorized: true, + }, + { + privilege: mockAuthorizationAction('mySecondAppAlertType', 'myApp', 'find'), + authorized: true, + }, + { + privilege: mockAuthorizationAction('mySecondAppAlertType', 'myOtherApp', 'find'), + authorized: true, + }, + ], + }); + + const alertAuthorization = new AlertsAuthorization({ + request, + authorization, + alertTypeRegistry, + features, + auditLogger, + getSpace, + }); + alertTypeRegistry.list.mockReturnValue(setOfAlertTypes); + + const { + ensureAlertTypeIsAuthorized, + logSuccessfulAuthorization, + } = await alertAuthorization.getFindAuthorizationFilter(); + expect(() => { + ensureAlertTypeIsAuthorized('myAppAlertType', 'myOtherApp'); + ensureAlertTypeIsAuthorized('mySecondAppAlertType', 'myOtherApp'); + ensureAlertTypeIsAuthorized('myAppAlertType', 'myOtherApp'); + }).not.toThrow(); + + expect(auditLogger.alertsAuthorizationSuccess).not.toHaveBeenCalled(); + expect(auditLogger.alertsAuthorizationFailure).not.toHaveBeenCalled(); + + logSuccessfulAuthorization(); + + expect(auditLogger.alertsBulkAuthorizationSuccess).toHaveBeenCalledTimes(1); + expect(auditLogger.alertsBulkAuthorizationSuccess.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "some-user", + Array [ + Array [ + "myAppAlertType", + "myOtherApp", + ], + Array [ + "mySecondAppAlertType", + "myOtherApp", + ], + ], + 0, + "find", + ] + `); + }); + }); + + describe('filterByAlertTypeAuthorization', () => { + const myOtherAppAlertType = { + actionGroups: [], + actionVariables: undefined, + defaultActionGroupId: 'default', + id: 'myOtherAppAlertType', + name: 'myOtherAppAlertType', + producer: 'myOtherApp', + }; + const myAppAlertType = { + actionGroups: [], + actionVariables: undefined, + defaultActionGroupId: 'default', + id: 'myAppAlertType', + name: 'myAppAlertType', + producer: 'myApp', + }; + const setOfAlertTypes = new Set([myAppAlertType, myOtherAppAlertType]); + + test('augments a list of types with all features when there is no authorization api', async () => { + const alertAuthorization = new AlertsAuthorization({ + request, + alertTypeRegistry, + features, + auditLogger, + getSpace, + }); + alertTypeRegistry.list.mockReturnValue(setOfAlertTypes); + + await expect( + alertAuthorization.filterByAlertTypeAuthorization( + new Set([myAppAlertType, myOtherAppAlertType]), + [WriteOperations.Create] + ) + ).resolves.toMatchInlineSnapshot(` + Set { + Object { + "actionGroups": Array [], + "actionVariables": undefined, + "authorizedConsumers": Object { + "alerts": Object { + "all": true, + "read": true, + }, + "myApp": Object { + "all": true, + "read": true, + }, + "myAppWithSubFeature": Object { + "all": true, + "read": true, + }, + "myOtherApp": Object { + "all": true, + "read": true, + }, + }, + "defaultActionGroupId": "default", + "id": "myAppAlertType", + "name": "myAppAlertType", + "producer": "myApp", + }, + Object { + "actionGroups": Array [], + "actionVariables": undefined, + "authorizedConsumers": Object { + "alerts": Object { + "all": true, + "read": true, + }, + "myApp": Object { + "all": true, + "read": true, + }, + "myAppWithSubFeature": Object { + "all": true, + "read": true, + }, + "myOtherApp": Object { + "all": true, + "read": true, + }, + }, + "defaultActionGroupId": "default", + "id": "myOtherAppAlertType", + "name": "myOtherAppAlertType", + "producer": "myOtherApp", + }, + } + `); + }); + + test('augments a list of types with consumers under which the operation is authorized', async () => { + const { authorization } = mockSecurity(); + const checkPrivileges: jest.MockedFunction> = jest.fn(); + authorization.checkPrivilegesDynamicallyWithRequest.mockReturnValue(checkPrivileges); + checkPrivileges.mockResolvedValueOnce({ + username: 'some-user', + hasAllRequested: false, + privileges: [ + { + privilege: mockAuthorizationAction('myOtherAppAlertType', 'myApp', 'create'), + authorized: true, + }, + { + privilege: mockAuthorizationAction('myOtherAppAlertType', 'myOtherApp', 'create'), + authorized: false, + }, + { + privilege: mockAuthorizationAction('myAppAlertType', 'myApp', 'create'), + authorized: true, + }, + { + privilege: mockAuthorizationAction('myAppAlertType', 'myOtherApp', 'create'), + authorized: true, + }, + ], + }); + + const alertAuthorization = new AlertsAuthorization({ + request, + authorization, + alertTypeRegistry, + features, + auditLogger, + getSpace, + }); + alertTypeRegistry.list.mockReturnValue(setOfAlertTypes); + + await expect( + alertAuthorization.filterByAlertTypeAuthorization( + new Set([myAppAlertType, myOtherAppAlertType]), + [WriteOperations.Create] + ) + ).resolves.toMatchInlineSnapshot(` + Set { + Object { + "actionGroups": Array [], + "actionVariables": undefined, + "authorizedConsumers": Object { + "myApp": Object { + "all": true, + "read": true, + }, + }, + "defaultActionGroupId": "default", + "id": "myOtherAppAlertType", + "name": "myOtherAppAlertType", + "producer": "myOtherApp", + }, + Object { + "actionGroups": Array [], + "actionVariables": undefined, + "authorizedConsumers": Object { + "alerts": Object { + "all": true, + "read": true, + }, + "myApp": Object { + "all": true, + "read": true, + }, + "myOtherApp": Object { + "all": true, + "read": true, + }, + }, + "defaultActionGroupId": "default", + "id": "myAppAlertType", + "name": "myAppAlertType", + "producer": "myApp", + }, + } + `); + }); + + test('authorizes user under the Alerts consumer when they are authorized by the producer', async () => { + const { authorization } = mockSecurity(); + const checkPrivileges: jest.MockedFunction> = jest.fn(); + authorization.checkPrivilegesDynamicallyWithRequest.mockReturnValue(checkPrivileges); + checkPrivileges.mockResolvedValueOnce({ + username: 'some-user', + hasAllRequested: false, + privileges: [ + { + privilege: mockAuthorizationAction('myAppAlertType', 'myApp', 'create'), + authorized: true, + }, + { + privilege: mockAuthorizationAction('myAppAlertType', 'myOtherApp', 'create'), + authorized: false, + }, + ], + }); + + const alertAuthorization = new AlertsAuthorization({ + request, + authorization, + alertTypeRegistry, + features, + auditLogger, + getSpace, + }); + alertTypeRegistry.list.mockReturnValue(setOfAlertTypes); + + await expect( + alertAuthorization.filterByAlertTypeAuthorization(new Set([myAppAlertType]), [ + WriteOperations.Create, + ]) + ).resolves.toMatchInlineSnapshot(` + Set { + Object { + "actionGroups": Array [], + "actionVariables": undefined, + "authorizedConsumers": Object { + "alerts": Object { + "all": true, + "read": true, + }, + "myApp": Object { + "all": true, + "read": true, + }, + }, + "defaultActionGroupId": "default", + "id": "myAppAlertType", + "name": "myAppAlertType", + "producer": "myApp", + }, + } + `); + }); + + test('augments a list of types with consumers under which multiple operations are authorized', async () => { + const { authorization } = mockSecurity(); + const checkPrivileges: jest.MockedFunction> = jest.fn(); + authorization.checkPrivilegesDynamicallyWithRequest.mockReturnValue(checkPrivileges); + checkPrivileges.mockResolvedValueOnce({ + username: 'some-user', + hasAllRequested: false, + privileges: [ + { + privilege: mockAuthorizationAction('myOtherAppAlertType', 'myApp', 'create'), + authorized: true, + }, + { + privilege: mockAuthorizationAction('myOtherAppAlertType', 'myOtherApp', 'create'), + authorized: false, + }, + { + privilege: mockAuthorizationAction('myAppAlertType', 'myApp', 'create'), + authorized: false, + }, + { + privilege: mockAuthorizationAction('myAppAlertType', 'myOtherApp', 'create'), + authorized: false, + }, + { + privilege: mockAuthorizationAction('myOtherAppAlertType', 'myApp', 'get'), + authorized: true, + }, + { + privilege: mockAuthorizationAction('myOtherAppAlertType', 'myOtherApp', 'get'), + authorized: true, + }, + { + privilege: mockAuthorizationAction('myAppAlertType', 'myApp', 'get'), + authorized: true, + }, + { + privilege: mockAuthorizationAction('myAppAlertType', 'myOtherApp', 'get'), + authorized: true, + }, + ], + }); + + const alertAuthorization = new AlertsAuthorization({ + request, + authorization, + alertTypeRegistry, + features, + auditLogger, + getSpace, + }); + alertTypeRegistry.list.mockReturnValue(setOfAlertTypes); + + await expect( + alertAuthorization.filterByAlertTypeAuthorization( + new Set([myAppAlertType, myOtherAppAlertType]), + [WriteOperations.Create, ReadOperations.Get] + ) + ).resolves.toMatchInlineSnapshot(` + Set { + Object { + "actionGroups": Array [], + "actionVariables": undefined, + "authorizedConsumers": Object { + "alerts": Object { + "all": false, + "read": true, + }, + "myApp": Object { + "all": true, + "read": true, + }, + "myOtherApp": Object { + "all": false, + "read": true, + }, + }, + "defaultActionGroupId": "default", + "id": "myOtherAppAlertType", + "name": "myOtherAppAlertType", + "producer": "myOtherApp", + }, + Object { + "actionGroups": Array [], + "actionVariables": undefined, + "authorizedConsumers": Object { + "alerts": Object { + "all": false, + "read": true, + }, + "myApp": Object { + "all": false, + "read": true, + }, + "myOtherApp": Object { + "all": false, + "read": true, + }, + }, + "defaultActionGroupId": "default", + "id": "myAppAlertType", + "name": "myAppAlertType", + "producer": "myApp", + }, + } + `); + }); + + test('omits types which have no consumers under which the operation is authorized', async () => { + const { authorization } = mockSecurity(); + const checkPrivileges: jest.MockedFunction> = jest.fn(); + authorization.checkPrivilegesDynamicallyWithRequest.mockReturnValue(checkPrivileges); + checkPrivileges.mockResolvedValueOnce({ + username: 'some-user', + hasAllRequested: false, + privileges: [ + { + privilege: mockAuthorizationAction('myOtherAppAlertType', 'myApp', 'create'), + authorized: true, + }, + { + privilege: mockAuthorizationAction('myOtherAppAlertType', 'myOtherApp', 'create'), + authorized: true, + }, + { + privilege: mockAuthorizationAction('myAppAlertType', 'myApp', 'create'), + authorized: false, + }, + { + privilege: mockAuthorizationAction('myAppAlertType', 'myOtherApp', 'create'), + authorized: false, + }, + ], + }); + + const alertAuthorization = new AlertsAuthorization({ + request, + authorization, + alertTypeRegistry, + features, + auditLogger, + getSpace, + }); + alertTypeRegistry.list.mockReturnValue(setOfAlertTypes); + + await expect( + alertAuthorization.filterByAlertTypeAuthorization( + new Set([myAppAlertType, myOtherAppAlertType]), + [WriteOperations.Create] + ) + ).resolves.toMatchInlineSnapshot(` + Set { + Object { + "actionGroups": Array [], + "actionVariables": undefined, + "authorizedConsumers": Object { + "alerts": Object { + "all": true, + "read": true, + }, + "myApp": Object { + "all": true, + "read": true, + }, + "myOtherApp": Object { + "all": true, + "read": true, + }, + }, + "defaultActionGroupId": "default", + "id": "myOtherAppAlertType", + "name": "myOtherAppAlertType", + "producer": "myOtherApp", + }, + } + `); + }); + }); + + describe('ensureFieldIsSafeForQuery', () => { + test('throws if field contains character that isnt safe in a KQL query', () => { + expect(() => ensureFieldIsSafeForQuery('id', 'alert-*')).toThrowError( + `expected id not to include invalid character: *` + ); + + expect(() => ensureFieldIsSafeForQuery('id', '<=""')).toThrowError( + `expected id not to include invalid character: <=` + ); + + expect(() => ensureFieldIsSafeForQuery('id', '>=""')).toThrowError( + `expected id not to include invalid character: >=` + ); + + expect(() => ensureFieldIsSafeForQuery('id', '1 or alertid:123')).toThrowError( + `expected id not to include whitespace and invalid character: :` + ); + + expect(() => ensureFieldIsSafeForQuery('id', ') or alertid:123')).toThrowError( + `expected id not to include whitespace and invalid characters: ), :` + ); + + expect(() => ensureFieldIsSafeForQuery('id', 'some space')).toThrowError( + `expected id not to include whitespace` + ); + }); + + test('doesnt throws if field is safe as part of a KQL query', () => { + expect(() => ensureFieldIsSafeForQuery('id', '123-0456-678')).not.toThrow(); + }); + }); +}); diff --git a/x-pack/plugins/alerts/server/authorization/alerts_authorization.ts b/x-pack/plugins/alerts/server/authorization/alerts_authorization.ts new file mode 100644 index 000000000000..33a9a0bf0396 --- /dev/null +++ b/x-pack/plugins/alerts/server/authorization/alerts_authorization.ts @@ -0,0 +1,457 @@ +/* + * 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 Boom from 'boom'; +import { map, mapValues, remove, fromPairs, has } from 'lodash'; +import { KibanaRequest } from 'src/core/server'; +import { ALERTS_FEATURE_ID } from '../../common'; +import { AlertTypeRegistry } from '../types'; +import { SecurityPluginSetup } from '../../../security/server'; +import { RegistryAlertType } from '../alert_type_registry'; +import { PluginStartContract as FeaturesPluginStart } from '../../../features/server'; +import { AlertsAuthorizationAuditLogger, ScopeType } from './audit_logger'; +import { Space } from '../../../spaces/server'; + +export enum ReadOperations { + Get = 'get', + GetAlertState = 'getAlertState', + Find = 'find', +} + +export enum WriteOperations { + Create = 'create', + Delete = 'delete', + Update = 'update', + UpdateApiKey = 'updateApiKey', + Enable = 'enable', + Disable = 'disable', + MuteAll = 'muteAll', + UnmuteAll = 'unmuteAll', + MuteInstance = 'muteInstance', + UnmuteInstance = 'unmuteInstance', +} + +interface HasPrivileges { + read: boolean; + all: boolean; +} +type AuthorizedConsumers = Record; +export interface RegistryAlertTypeWithAuth extends RegistryAlertType { + authorizedConsumers: AuthorizedConsumers; +} + +type IsAuthorizedAtProducerLevel = boolean; + +export interface ConstructorOptions { + alertTypeRegistry: AlertTypeRegistry; + request: KibanaRequest; + features: FeaturesPluginStart; + getSpace: (request: KibanaRequest) => Promise; + auditLogger: AlertsAuthorizationAuditLogger; + authorization?: SecurityPluginSetup['authz']; +} + +export class AlertsAuthorization { + private readonly alertTypeRegistry: AlertTypeRegistry; + private readonly request: KibanaRequest; + private readonly authorization?: SecurityPluginSetup['authz']; + private readonly auditLogger: AlertsAuthorizationAuditLogger; + private readonly featuresIds: Promise>; + private readonly allPossibleConsumers: Promise; + + constructor({ + alertTypeRegistry, + request, + authorization, + features, + auditLogger, + getSpace, + }: ConstructorOptions) { + this.request = request; + this.authorization = authorization; + this.alertTypeRegistry = alertTypeRegistry; + this.auditLogger = auditLogger; + + this.featuresIds = getSpace(request) + .then((maybeSpace) => new Set(maybeSpace?.disabledFeatures ?? [])) + .then( + (disabledFeatures) => + new Set( + features + .getFeatures() + .filter( + ({ id, alerting }) => + // ignore features which are disabled in the user's space + !disabledFeatures.has(id) && + // ignore features which don't grant privileges to alerting + (alerting?.length ?? 0 > 0) + ) + .map((feature) => feature.id) + ) + ) + .catch(() => { + // failing to fetch the space means the user is likely not privileged in the + // active space at all, which means that their list of features should be empty + return new Set(); + }); + + this.allPossibleConsumers = this.featuresIds.then((featuresIds) => + featuresIds.size + ? asAuthorizedConsumers([ALERTS_FEATURE_ID, ...featuresIds], { + read: true, + all: true, + }) + : {} + ); + } + + private shouldCheckAuthorization(): boolean { + return this.authorization?.mode?.useRbacForRequest(this.request) ?? false; + } + + public async ensureAuthorized( + alertTypeId: string, + consumer: string, + operation: ReadOperations | WriteOperations + ) { + const { authorization } = this; + + const isAvailableConsumer = has(await this.allPossibleConsumers, consumer); + if (authorization && this.shouldCheckAuthorization()) { + const alertType = this.alertTypeRegistry.get(alertTypeId); + const requiredPrivilegesByScope = { + consumer: authorization.actions.alerting.get(alertTypeId, consumer, operation), + producer: authorization.actions.alerting.get(alertTypeId, alertType.producer, operation), + }; + + // We special case the Alerts Management `consumer` as we don't want to have to + // manually authorize each alert type in the management UI + const shouldAuthorizeConsumer = consumer !== ALERTS_FEATURE_ID; + + const checkPrivileges = authorization.checkPrivilegesDynamicallyWithRequest(this.request); + const { hasAllRequested, username, privileges } = await checkPrivileges( + shouldAuthorizeConsumer && consumer !== alertType.producer + ? [ + // check for access at consumer level + requiredPrivilegesByScope.consumer, + // check for access at producer level + requiredPrivilegesByScope.producer, + ] + : [ + // skip consumer privilege checks under `alerts` as all alert types can + // be created under `alerts` if you have producer level privileges + requiredPrivilegesByScope.producer, + ] + ); + + if (!isAvailableConsumer) { + /** + * Under most circumstances this would have been caught by `checkPrivileges` as + * a user can't have Privileges to an unknown consumer, but super users + * don't actually get "privilege checked" so the made up consumer *will* return + * as Privileged. + * This check will ensure we don't accidentally let these through + */ + throw Boom.forbidden( + this.auditLogger.alertsAuthorizationFailure( + username, + alertTypeId, + ScopeType.Consumer, + consumer, + operation + ) + ); + } + + if (hasAllRequested) { + this.auditLogger.alertsAuthorizationSuccess( + username, + alertTypeId, + ScopeType.Consumer, + consumer, + operation + ); + } else { + const authorizedPrivileges = map( + privileges.filter((privilege) => privilege.authorized), + 'privilege' + ); + const unauthorizedScopes = mapValues( + requiredPrivilegesByScope, + (privilege) => !authorizedPrivileges.includes(privilege) + ); + + const [unauthorizedScopeType, unauthorizedScope] = + shouldAuthorizeConsumer && unauthorizedScopes.consumer + ? [ScopeType.Consumer, consumer] + : [ScopeType.Producer, alertType.producer]; + + throw Boom.forbidden( + this.auditLogger.alertsAuthorizationFailure( + username, + alertTypeId, + unauthorizedScopeType, + unauthorizedScope, + operation + ) + ); + } + } else if (!isAvailableConsumer) { + throw Boom.forbidden( + this.auditLogger.alertsAuthorizationFailure( + '', + alertTypeId, + ScopeType.Consumer, + consumer, + operation + ) + ); + } + } + + public async getFindAuthorizationFilter(): Promise<{ + filter?: string; + ensureAlertTypeIsAuthorized: (alertTypeId: string, consumer: string) => void; + logSuccessfulAuthorization: () => void; + }> { + if (this.authorization && this.shouldCheckAuthorization()) { + const { + username, + authorizedAlertTypes, + } = await this.augmentAlertTypesWithAuthorization(this.alertTypeRegistry.list(), [ + ReadOperations.Find, + ]); + + if (!authorizedAlertTypes.size) { + throw Boom.forbidden( + this.auditLogger.alertsUnscopedAuthorizationFailure(username!, 'find') + ); + } + + const authorizedAlertTypeIdsToConsumers = new Set( + [...authorizedAlertTypes].reduce((alertTypeIdConsumerPairs, alertType) => { + for (const consumer of Object.keys(alertType.authorizedConsumers)) { + alertTypeIdConsumerPairs.push(`${alertType.id}/${consumer}`); + } + return alertTypeIdConsumerPairs; + }, []) + ); + + const authorizedEntries: Map> = new Map(); + return { + filter: `(${this.asFiltersByAlertTypeAndConsumer(authorizedAlertTypes).join(' or ')})`, + ensureAlertTypeIsAuthorized: (alertTypeId: string, consumer: string) => { + if (!authorizedAlertTypeIdsToConsumers.has(`${alertTypeId}/${consumer}`)) { + throw Boom.forbidden( + this.auditLogger.alertsAuthorizationFailure( + username!, + alertTypeId, + ScopeType.Consumer, + consumer, + 'find' + ) + ); + } else { + if (authorizedEntries.has(alertTypeId)) { + authorizedEntries.get(alertTypeId)!.add(consumer); + } else { + authorizedEntries.set(alertTypeId, new Set([consumer])); + } + } + }, + logSuccessfulAuthorization: () => { + if (authorizedEntries.size) { + this.auditLogger.alertsBulkAuthorizationSuccess( + username!, + [...authorizedEntries.entries()].reduce>( + (authorizedPairs, [alertTypeId, consumers]) => { + for (const consumer of consumers) { + authorizedPairs.push([alertTypeId, consumer]); + } + return authorizedPairs; + }, + [] + ), + ScopeType.Consumer, + 'find' + ); + } + }, + }; + } + return { + ensureAlertTypeIsAuthorized: (alertTypeId: string, consumer: string) => {}, + logSuccessfulAuthorization: () => {}, + }; + } + + public async filterByAlertTypeAuthorization( + alertTypes: Set, + operations: Array + ): Promise> { + const { authorizedAlertTypes } = await this.augmentAlertTypesWithAuthorization( + alertTypes, + operations + ); + return authorizedAlertTypes; + } + + private async augmentAlertTypesWithAuthorization( + alertTypes: Set, + operations: Array + ): Promise<{ + username?: string; + hasAllRequested: boolean; + authorizedAlertTypes: Set; + }> { + const featuresIds = await this.featuresIds; + if (this.authorization && this.shouldCheckAuthorization()) { + const checkPrivileges = this.authorization.checkPrivilegesDynamicallyWithRequest( + this.request + ); + + // add an empty `authorizedConsumers` array on each alertType + const alertTypesWithAuthorization = this.augmentWithAuthorizedConsumers(alertTypes, {}); + + // map from privilege to alertType which we can refer back to when analyzing the result + // of checkPrivileges + const privilegeToAlertType = new Map< + string, + [RegistryAlertTypeWithAuth, string, HasPrivileges, IsAuthorizedAtProducerLevel] + >(); + // as we can't ask ES for the user's individual privileges we need to ask for each feature + // and alertType in the system whether this user has this privilege + for (const alertType of alertTypesWithAuthorization) { + for (const feature of featuresIds) { + for (const operation of operations) { + privilegeToAlertType.set( + this.authorization!.actions.alerting.get(alertType.id, feature, operation), + [ + alertType, + feature, + hasPrivilegeByOperation(operation), + alertType.producer === feature, + ] + ); + } + } + } + + const { username, hasAllRequested, privileges } = await checkPrivileges([ + ...privilegeToAlertType.keys(), + ]); + + return { + username, + hasAllRequested, + authorizedAlertTypes: hasAllRequested + ? // has access to all features + this.augmentWithAuthorizedConsumers(alertTypes, await this.allPossibleConsumers) + : // only has some of the required privileges + privileges.reduce((authorizedAlertTypes, { authorized, privilege }) => { + if (authorized && privilegeToAlertType.has(privilege)) { + const [ + alertType, + feature, + hasPrivileges, + isAuthorizedAtProducerLevel, + ] = privilegeToAlertType.get(privilege)!; + alertType.authorizedConsumers[feature] = mergeHasPrivileges( + hasPrivileges, + alertType.authorizedConsumers[feature] + ); + + if (isAuthorizedAtProducerLevel) { + // granting privileges under the producer automatically authorized the Alerts Management UI as well + alertType.authorizedConsumers[ALERTS_FEATURE_ID] = mergeHasPrivileges( + hasPrivileges, + alertType.authorizedConsumers[ALERTS_FEATURE_ID] + ); + } + authorizedAlertTypes.add(alertType); + } + return authorizedAlertTypes; + }, new Set()), + }; + } else { + return { + hasAllRequested: true, + authorizedAlertTypes: this.augmentWithAuthorizedConsumers( + new Set([...alertTypes].filter((alertType) => featuresIds.has(alertType.producer))), + await this.allPossibleConsumers + ), + }; + } + } + + private augmentWithAuthorizedConsumers( + alertTypes: Set, + authorizedConsumers: AuthorizedConsumers + ): Set { + return new Set( + Array.from(alertTypes).map((alertType) => ({ + ...alertType, + authorizedConsumers: { ...authorizedConsumers }, + })) + ); + } + + private asFiltersByAlertTypeAndConsumer(alertTypes: Set): string[] { + return Array.from(alertTypes).reduce((filters, { id, authorizedConsumers }) => { + ensureFieldIsSafeForQuery('alertTypeId', id); + filters.push( + `(alert.attributes.alertTypeId:${id} and alert.attributes.consumer:(${Object.keys( + authorizedConsumers + ) + .map((consumer) => { + ensureFieldIsSafeForQuery('alertTypeId', id); + return consumer; + }) + .join(' or ')}))` + ); + return filters; + }, []); + } +} + +export function ensureFieldIsSafeForQuery(field: string, value: string): boolean { + const invalid = value.match(/([>=<\*:()]+|\s+)/g); + if (invalid) { + const whitespace = remove(invalid, (chars) => chars.trim().length === 0); + const errors = []; + if (whitespace.length) { + errors.push(`whitespace`); + } + if (invalid.length) { + errors.push(`invalid character${invalid.length > 1 ? `s` : ``}: ${invalid?.join(`, `)}`); + } + throw new Error(`expected ${field} not to include ${errors.join(' and ')}`); + } + return true; +} + +function mergeHasPrivileges(left: HasPrivileges, right?: HasPrivileges): HasPrivileges { + return { + read: (left.read || right?.read) ?? false, + all: (left.all || right?.all) ?? false, + }; +} + +function hasPrivilegeByOperation(operation: ReadOperations | WriteOperations): HasPrivileges { + const read = Object.values(ReadOperations).includes((operation as unknown) as ReadOperations); + const all = Object.values(WriteOperations).includes((operation as unknown) as WriteOperations); + return { + read: read || all, + all, + }; +} + +function asAuthorizedConsumers( + consumers: string[], + hasPrivileges: HasPrivileges +): AuthorizedConsumers { + return fromPairs(consumers.map((feature) => [feature, hasPrivileges])); +} diff --git a/x-pack/plugins/alerts/server/authorization/audit_logger.mock.ts b/x-pack/plugins/alerts/server/authorization/audit_logger.mock.ts new file mode 100644 index 000000000000..ca6a35b24bca --- /dev/null +++ b/x-pack/plugins/alerts/server/authorization/audit_logger.mock.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { AlertsAuthorizationAuditLogger } from './audit_logger'; + +const createAlertsAuthorizationAuditLoggerMock = () => { + const mocked = ({ + getAuthorizationMessage: jest.fn(), + alertsAuthorizationFailure: jest.fn(), + alertsUnscopedAuthorizationFailure: jest.fn(), + alertsAuthorizationSuccess: jest.fn(), + alertsBulkAuthorizationSuccess: jest.fn(), + } as unknown) as jest.Mocked; + return mocked; +}; + +export const alertsAuthorizationAuditLoggerMock: { + create: () => jest.Mocked; +} = { + create: createAlertsAuthorizationAuditLoggerMock, +}; diff --git a/x-pack/plugins/alerts/server/authorization/audit_logger.test.ts b/x-pack/plugins/alerts/server/authorization/audit_logger.test.ts new file mode 100644 index 000000000000..40973a3a67a5 --- /dev/null +++ b/x-pack/plugins/alerts/server/authorization/audit_logger.test.ts @@ -0,0 +1,311 @@ +/* + * 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 { AlertsAuthorizationAuditLogger, ScopeType } from './audit_logger'; + +const createMockAuditLogger = () => { + return { + log: jest.fn(), + }; +}; + +describe(`#constructor`, () => { + test('initializes a noop auditLogger if security logger is unavailable', () => { + const alertsAuditLogger = new AlertsAuthorizationAuditLogger(undefined); + + const username = 'foo-user'; + const alertTypeId = 'alert-type-id'; + const scopeType = ScopeType.Consumer; + const scope = 'myApp'; + const operation = 'create'; + expect(() => { + alertsAuditLogger.alertsAuthorizationFailure( + username, + alertTypeId, + scopeType, + scope, + operation + ); + + alertsAuditLogger.alertsAuthorizationSuccess( + username, + alertTypeId, + scopeType, + scope, + operation + ); + }).not.toThrow(); + }); +}); + +describe(`#alertsUnscopedAuthorizationFailure`, () => { + test('logs auth failure of operation', () => { + const auditLogger = createMockAuditLogger(); + const alertsAuditLogger = new AlertsAuthorizationAuditLogger(auditLogger); + const username = 'foo-user'; + const operation = 'create'; + + alertsAuditLogger.alertsUnscopedAuthorizationFailure(username, operation); + + expect(auditLogger.log.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "alerts_unscoped_authorization_failure", + "foo-user Unauthorized to create any alert types", + Object { + "operation": "create", + "username": "foo-user", + }, + ] + `); + }); + + test('logs auth failure with producer scope', () => { + const auditLogger = createMockAuditLogger(); + const alertsAuditLogger = new AlertsAuthorizationAuditLogger(auditLogger); + const username = 'foo-user'; + const alertTypeId = 'alert-type-id'; + const scopeType = ScopeType.Producer; + const scope = 'myOtherApp'; + const operation = 'create'; + + alertsAuditLogger.alertsAuthorizationFailure( + username, + alertTypeId, + scopeType, + scope, + operation + ); + + expect(auditLogger.log.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "alerts_authorization_failure", + "foo-user Unauthorized to create a \\"alert-type-id\\" alert by \\"myOtherApp\\"", + Object { + "alertTypeId": "alert-type-id", + "operation": "create", + "scope": "myOtherApp", + "scopeType": 1, + "username": "foo-user", + }, + ] + `); + }); +}); + +describe(`#alertsAuthorizationFailure`, () => { + test('logs auth failure with consumer scope', () => { + const auditLogger = createMockAuditLogger(); + const alertsAuditLogger = new AlertsAuthorizationAuditLogger(auditLogger); + const username = 'foo-user'; + const alertTypeId = 'alert-type-id'; + const scopeType = ScopeType.Consumer; + const scope = 'myApp'; + const operation = 'create'; + + alertsAuditLogger.alertsAuthorizationFailure( + username, + alertTypeId, + scopeType, + scope, + operation + ); + + expect(auditLogger.log.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "alerts_authorization_failure", + "foo-user Unauthorized to create a \\"alert-type-id\\" alert for \\"myApp\\"", + Object { + "alertTypeId": "alert-type-id", + "operation": "create", + "scope": "myApp", + "scopeType": 0, + "username": "foo-user", + }, + ] + `); + }); + + test('logs auth failure with producer scope', () => { + const auditLogger = createMockAuditLogger(); + const alertsAuditLogger = new AlertsAuthorizationAuditLogger(auditLogger); + const username = 'foo-user'; + const alertTypeId = 'alert-type-id'; + const scopeType = ScopeType.Producer; + const scope = 'myOtherApp'; + const operation = 'create'; + + alertsAuditLogger.alertsAuthorizationFailure( + username, + alertTypeId, + scopeType, + scope, + operation + ); + + expect(auditLogger.log.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "alerts_authorization_failure", + "foo-user Unauthorized to create a \\"alert-type-id\\" alert by \\"myOtherApp\\"", + Object { + "alertTypeId": "alert-type-id", + "operation": "create", + "scope": "myOtherApp", + "scopeType": 1, + "username": "foo-user", + }, + ] + `); + }); +}); + +describe(`#alertsBulkAuthorizationSuccess`, () => { + test('logs auth success with consumer scope', () => { + const auditLogger = createMockAuditLogger(); + const alertsAuditLogger = new AlertsAuthorizationAuditLogger(auditLogger); + const username = 'foo-user'; + const scopeType = ScopeType.Consumer; + const authorizedEntries: Array<[string, string]> = [ + ['alert-type-id', 'myApp'], + ['other-alert-type-id', 'myOtherApp'], + ]; + const operation = 'create'; + + alertsAuditLogger.alertsBulkAuthorizationSuccess( + username, + authorizedEntries, + scopeType, + operation + ); + + expect(auditLogger.log.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "alerts_authorization_success", + "foo-user Authorized to create: \\"alert-type-id\\" alert for \\"myApp\\", \\"other-alert-type-id\\" alert for \\"myOtherApp\\"", + Object { + "authorizedEntries": Array [ + Array [ + "alert-type-id", + "myApp", + ], + Array [ + "other-alert-type-id", + "myOtherApp", + ], + ], + "operation": "create", + "scopeType": 0, + "username": "foo-user", + }, + ] + `); + }); + + test('logs auth success with producer scope', () => { + const auditLogger = createMockAuditLogger(); + const alertsAuditLogger = new AlertsAuthorizationAuditLogger(auditLogger); + const username = 'foo-user'; + const scopeType = ScopeType.Producer; + const authorizedEntries: Array<[string, string]> = [ + ['alert-type-id', 'myApp'], + ['other-alert-type-id', 'myOtherApp'], + ]; + const operation = 'create'; + + alertsAuditLogger.alertsBulkAuthorizationSuccess( + username, + authorizedEntries, + scopeType, + operation + ); + + expect(auditLogger.log.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "alerts_authorization_success", + "foo-user Authorized to create: \\"alert-type-id\\" alert by \\"myApp\\", \\"other-alert-type-id\\" alert by \\"myOtherApp\\"", + Object { + "authorizedEntries": Array [ + Array [ + "alert-type-id", + "myApp", + ], + Array [ + "other-alert-type-id", + "myOtherApp", + ], + ], + "operation": "create", + "scopeType": 1, + "username": "foo-user", + }, + ] + `); + }); +}); + +describe(`#savedObjectsAuthorizationSuccess`, () => { + test('logs auth success with consumer scope', () => { + const auditLogger = createMockAuditLogger(); + const alertsAuditLogger = new AlertsAuthorizationAuditLogger(auditLogger); + const username = 'foo-user'; + const alertTypeId = 'alert-type-id'; + const scopeType = ScopeType.Consumer; + const scope = 'myApp'; + const operation = 'create'; + + alertsAuditLogger.alertsAuthorizationSuccess( + username, + alertTypeId, + scopeType, + scope, + operation + ); + + expect(auditLogger.log.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "alerts_authorization_success", + "foo-user Authorized to create a \\"alert-type-id\\" alert for \\"myApp\\"", + Object { + "alertTypeId": "alert-type-id", + "operation": "create", + "scope": "myApp", + "scopeType": 0, + "username": "foo-user", + }, + ] + `); + }); + + test('logs auth success with producer scope', () => { + const auditLogger = createMockAuditLogger(); + const alertsAuditLogger = new AlertsAuthorizationAuditLogger(auditLogger); + const username = 'foo-user'; + const alertTypeId = 'alert-type-id'; + const scopeType = ScopeType.Producer; + const scope = 'myOtherApp'; + const operation = 'create'; + + alertsAuditLogger.alertsAuthorizationSuccess( + username, + alertTypeId, + scopeType, + scope, + operation + ); + + expect(auditLogger.log.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "alerts_authorization_success", + "foo-user Authorized to create a \\"alert-type-id\\" alert by \\"myOtherApp\\"", + Object { + "alertTypeId": "alert-type-id", + "operation": "create", + "scope": "myOtherApp", + "scopeType": 1, + "username": "foo-user", + }, + ] + `); + }); +}); diff --git a/x-pack/plugins/alerts/server/authorization/audit_logger.ts b/x-pack/plugins/alerts/server/authorization/audit_logger.ts new file mode 100644 index 000000000000..f930da2ce428 --- /dev/null +++ b/x-pack/plugins/alerts/server/authorization/audit_logger.ts @@ -0,0 +1,117 @@ +/* + * 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 { AuditLogger } from '../../../security/server'; + +export enum ScopeType { + Consumer, + Producer, +} + +export enum AuthorizationResult { + Unauthorized = 'Unauthorized', + Authorized = 'Authorized', +} + +export class AlertsAuthorizationAuditLogger { + private readonly auditLogger: AuditLogger; + + constructor(auditLogger: AuditLogger = { log() {} }) { + this.auditLogger = auditLogger; + } + + public getAuthorizationMessage( + authorizationResult: AuthorizationResult, + alertTypeId: string, + scopeType: ScopeType, + scope: string, + operation: string + ): string { + return `${authorizationResult} to ${operation} a "${alertTypeId}" alert ${ + scopeType === ScopeType.Consumer ? `for "${scope}"` : `by "${scope}"` + }`; + } + + public alertsAuthorizationFailure( + username: string, + alertTypeId: string, + scopeType: ScopeType, + scope: string, + operation: string + ): string { + const message = this.getAuthorizationMessage( + AuthorizationResult.Unauthorized, + alertTypeId, + scopeType, + scope, + operation + ); + this.auditLogger.log('alerts_authorization_failure', `${username} ${message}`, { + username, + alertTypeId, + scopeType, + scope, + operation, + }); + return message; + } + + public alertsUnscopedAuthorizationFailure(username: string, operation: string): string { + const message = `Unauthorized to ${operation} any alert types`; + this.auditLogger.log('alerts_unscoped_authorization_failure', `${username} ${message}`, { + username, + operation, + }); + return message; + } + + public alertsAuthorizationSuccess( + username: string, + alertTypeId: string, + scopeType: ScopeType, + scope: string, + operation: string + ): string { + const message = this.getAuthorizationMessage( + AuthorizationResult.Authorized, + alertTypeId, + scopeType, + scope, + operation + ); + this.auditLogger.log('alerts_authorization_success', `${username} ${message}`, { + username, + alertTypeId, + scopeType, + scope, + operation, + }); + return message; + } + + public alertsBulkAuthorizationSuccess( + username: string, + authorizedEntries: Array<[string, string]>, + scopeType: ScopeType, + operation: string + ): string { + const message = `${AuthorizationResult.Authorized} to ${operation}: ${authorizedEntries + .map( + ([alertTypeId, scope]) => + `"${alertTypeId}" alert ${ + scopeType === ScopeType.Consumer ? `for "${scope}"` : `by "${scope}"` + }` + ) + .join(', ')}`; + this.auditLogger.log('alerts_authorization_success', `${username} ${message}`, { + username, + scopeType, + authorizedEntries, + operation, + }); + return message; + } +} diff --git a/x-pack/plugins/alerts/server/index.ts b/x-pack/plugins/alerts/server/index.ts index 727e38d9ba56..515de771e7d6 100644 --- a/x-pack/plugins/alerts/server/index.ts +++ b/x-pack/plugins/alerts/server/index.ts @@ -21,7 +21,7 @@ export { PartialAlert, } from './types'; export { PluginSetupContract, PluginStartContract } from './plugin'; -export { FindOptions, FindResult } from './alerts_client'; +export { FindResult } from './alerts_client'; export { AlertInstance } from './alert_instance'; export { parseDuration } from './lib'; diff --git a/x-pack/plugins/alerts/server/lib/validate_alert_type_params.test.ts b/x-pack/plugins/alerts/server/lib/validate_alert_type_params.test.ts index d31b15030fd3..1e6c26c02e65 100644 --- a/x-pack/plugins/alerts/server/lib/validate_alert_type_params.test.ts +++ b/x-pack/plugins/alerts/server/lib/validate_alert_type_params.test.ts @@ -20,7 +20,7 @@ test('should return passed in params when validation not defined', () => { ], defaultActionGroupId: 'default', async executor() {}, - producer: 'alerting', + producer: 'alerts', }, { foo: true, @@ -48,7 +48,7 @@ test('should validate and apply defaults when params is valid', () => { }), }, async executor() {}, - producer: 'alerting', + producer: 'alerts', }, { param1: 'value' } ); @@ -77,7 +77,7 @@ test('should validate and throw error when params is invalid', () => { }), }, async executor() {}, - producer: 'alerting', + producer: 'alerts', }, {} ) diff --git a/x-pack/plugins/alerts/server/plugin.test.ts b/x-pack/plugins/alerts/server/plugin.test.ts index 008a9bb804c5..e65d19529025 100644 --- a/x-pack/plugins/alerts/server/plugin.test.ts +++ b/x-pack/plugins/alerts/server/plugin.test.ts @@ -11,6 +11,8 @@ import { encryptedSavedObjectsMock } from '../../encrypted_saved_objects/server/ import { taskManagerMock } from '../../task_manager/server/mocks'; import { eventLogServiceMock } from '../../event_log/server/event_log_service.mock'; import { KibanaRequest, CoreSetup } from 'kibana/server'; +import { featuresPluginMock } from '../../features/server/mocks'; +import { Feature } from '../../features/server'; describe('Alerting Plugin', () => { describe('setup()', () => { @@ -80,8 +82,10 @@ describe('Alerting Plugin', () => { actions: { execute: jest.fn(), getActionsClientWithRequest: jest.fn(), + getActionsAuthorizationWithRequest: jest.fn(), }, encryptedSavedObjects: encryptedSavedObjectsMock.createStart(), + features: mockFeatures(), } as unknown) as AlertingPluginsStart ); @@ -124,9 +128,11 @@ describe('Alerting Plugin', () => { actions: { execute: jest.fn(), getActionsClientWithRequest: jest.fn(), + getActionsAuthorizationWithRequest: jest.fn(), }, spaces: () => null, encryptedSavedObjects: encryptedSavedObjectsMock.createStart(), + features: mockFeatures(), } as unknown) as AlertingPluginsStart ); @@ -150,3 +156,31 @@ describe('Alerting Plugin', () => { }); }); }); + +function mockFeatures() { + const features = featuresPluginMock.createSetup(); + features.getFeatures.mockReturnValue([ + new Feature({ + id: 'appName', + name: 'appName', + app: [], + privileges: { + all: { + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + read: { + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + }, + }), + ]); + return features; +} diff --git a/x-pack/plugins/alerts/server/plugin.ts b/x-pack/plugins/alerts/server/plugin.ts index 07ed021d8ca8..cf6e1c9aebba 100644 --- a/x-pack/plugins/alerts/server/plugin.ts +++ b/x-pack/plugins/alerts/server/plugin.ts @@ -58,6 +58,7 @@ import { Services } from './types'; import { registerAlertsUsageCollector } from './usage'; import { initializeAlertingTelemetry, scheduleAlertingTelemetry } from './usage/task'; import { IEventLogger, IEventLogService } from '../../event_log/server'; +import { PluginStartContract as FeaturesPluginStart } from '../../features/server'; import { setupSavedObjects } from './saved_objects'; const EVENT_LOG_PROVIDER = 'alerting'; @@ -90,6 +91,7 @@ export interface AlertingPluginsStart { actions: ActionsPluginStartContract; taskManager: TaskManagerStartContract; encryptedSavedObjects: EncryptedSavedObjectsPluginStart; + features: FeaturesPluginStart; } export class AlertingPlugin { @@ -216,12 +218,26 @@ export class AlertingPlugin { getSpaceId(request: KibanaRequest) { return spaces?.getSpaceId(request); }, + async getSpace(request: KibanaRequest) { + return spaces?.getActiveSpace(request); + }, actions: plugins.actions, + features: plugins.features, }); + const getAlertsClientWithRequest = (request: KibanaRequest) => { + if (isESOUsingEphemeralEncryptionKey === true) { + throw new Error( + `Unable to create alerts client due to the Encrypted Saved Objects plugin using an ephemeral encryption key. Please set xpack.encryptedSavedObjects.encryptionKey in kibana.yml` + ); + } + return alertsClientFactory!.create(request, core.savedObjects); + }; + taskRunnerFactory.initialize({ logger, getServices: this.getServicesFactory(core.savedObjects, core.elasticsearch), + getAlertsClientWithRequest, spaceIdToNamespace: this.spaceIdToNamespace, actionsPlugin: plugins.actions, encryptedSavedObjectsClient, @@ -233,18 +249,7 @@ export class AlertingPlugin { return { listTypes: alertTypeRegistry!.list.bind(this.alertTypeRegistry!), - // Ability to get an alerts client from legacy code - getAlertsClientWithRequest: (request: KibanaRequest) => { - if (isESOUsingEphemeralEncryptionKey === true) { - throw new Error( - `Unable to create alerts client due to the Encrypted Saved Objects plugin using an ephemeral encryption key. Please set xpack.encryptedSavedObjects.encryptionKey in kibana.yml` - ); - } - return alertsClientFactory!.create( - request, - this.getScopedClientWithAlertSavedObjectType(core.savedObjects, request) - ); - }, + getAlertsClientWithRequest, }; } @@ -252,14 +257,11 @@ export class AlertingPlugin { core: CoreSetup ): IContextProvider, 'alerting'> => { const { alertTypeRegistry, alertsClientFactory } = this; - return async (context, request) => { + return async function alertsRouteHandlerContext(context, request) { const [{ savedObjects }] = await core.getStartServices(); return { getAlertsClient: () => { - return alertsClientFactory!.create( - request, - this.getScopedClientWithAlertSavedObjectType(savedObjects, request) - ); + return alertsClientFactory!.create(request, savedObjects); }, listTypes: alertTypeRegistry!.list.bind(alertTypeRegistry!), }; diff --git a/x-pack/plugins/alerts/server/routes/create.test.ts b/x-pack/plugins/alerts/server/routes/create.test.ts index 9e941903eeae..274acaf01c47 100644 --- a/x-pack/plugins/alerts/server/routes/create.test.ts +++ b/x-pack/plugins/alerts/server/routes/create.test.ts @@ -75,13 +75,6 @@ describe('createAlertRoute', () => { const [config, handler] = router.post.mock.calls[0]; expect(config.path).toMatchInlineSnapshot(`"/api/alerts/alert"`); - expect(config.options).toMatchInlineSnapshot(` - Object { - "tags": Array [ - "access:alerting-all", - ], - } - `); alertsClient.create.mockResolvedValueOnce(createResult); diff --git a/x-pack/plugins/alerts/server/routes/create.ts b/x-pack/plugins/alerts/server/routes/create.ts index 6238fca024e5..91a81f6d84b7 100644 --- a/x-pack/plugins/alerts/server/routes/create.ts +++ b/x-pack/plugins/alerts/server/routes/create.ts @@ -47,9 +47,6 @@ export const createAlertRoute = (router: IRouter, licenseState: LicenseState) => validate: { body: bodySchema, }, - options: { - tags: ['access:alerting-all'], - }, }, handleDisabledApiKeysError( router.handleLegacyErrors(async function ( diff --git a/x-pack/plugins/alerts/server/routes/delete.test.ts b/x-pack/plugins/alerts/server/routes/delete.test.ts index 9ba4e20312e1..d9c5aa2d59c8 100644 --- a/x-pack/plugins/alerts/server/routes/delete.test.ts +++ b/x-pack/plugins/alerts/server/routes/delete.test.ts @@ -30,13 +30,6 @@ describe('deleteAlertRoute', () => { const [config, handler] = router.delete.mock.calls[0]; expect(config.path).toMatchInlineSnapshot(`"/api/alerts/alert/{id}"`); - expect(config.options).toMatchInlineSnapshot(` - Object { - "tags": Array [ - "access:alerting-all", - ], - } - `); alertsClient.delete.mockResolvedValueOnce({}); diff --git a/x-pack/plugins/alerts/server/routes/delete.ts b/x-pack/plugins/alerts/server/routes/delete.ts index 2034bd21fbed..b073c5914917 100644 --- a/x-pack/plugins/alerts/server/routes/delete.ts +++ b/x-pack/plugins/alerts/server/routes/delete.ts @@ -27,9 +27,6 @@ export const deleteAlertRoute = (router: IRouter, licenseState: LicenseState) => validate: { params: paramSchema, }, - options: { - tags: ['access:alerting-all'], - }, }, router.handleLegacyErrors(async function ( context: RequestHandlerContext, diff --git a/x-pack/plugins/alerts/server/routes/disable.test.ts b/x-pack/plugins/alerts/server/routes/disable.test.ts index a82d09854a60..74f7b2eb8a57 100644 --- a/x-pack/plugins/alerts/server/routes/disable.test.ts +++ b/x-pack/plugins/alerts/server/routes/disable.test.ts @@ -30,13 +30,6 @@ describe('disableAlertRoute', () => { const [config, handler] = router.post.mock.calls[0]; expect(config.path).toMatchInlineSnapshot(`"/api/alerts/alert/{id}/_disable"`); - expect(config.options).toMatchInlineSnapshot(` - Object { - "tags": Array [ - "access:alerting-all", - ], - } - `); alertsClient.disable.mockResolvedValueOnce(); diff --git a/x-pack/plugins/alerts/server/routes/disable.ts b/x-pack/plugins/alerts/server/routes/disable.ts index dfc5dfbdd5aa..234f8ed959a5 100644 --- a/x-pack/plugins/alerts/server/routes/disable.ts +++ b/x-pack/plugins/alerts/server/routes/disable.ts @@ -27,9 +27,6 @@ export const disableAlertRoute = (router: IRouter, licenseState: LicenseState) = validate: { params: paramSchema, }, - options: { - tags: ['access:alerting-all'], - }, }, router.handleLegacyErrors(async function ( context: RequestHandlerContext, diff --git a/x-pack/plugins/alerts/server/routes/enable.test.ts b/x-pack/plugins/alerts/server/routes/enable.test.ts index 4ee3a12a59dc..c9575ef87f76 100644 --- a/x-pack/plugins/alerts/server/routes/enable.test.ts +++ b/x-pack/plugins/alerts/server/routes/enable.test.ts @@ -29,13 +29,6 @@ describe('enableAlertRoute', () => { const [config, handler] = router.post.mock.calls[0]; expect(config.path).toMatchInlineSnapshot(`"/api/alerts/alert/{id}/_enable"`); - expect(config.options).toMatchInlineSnapshot(` - Object { - "tags": Array [ - "access:alerting-all", - ], - } - `); alertsClient.enable.mockResolvedValueOnce(); diff --git a/x-pack/plugins/alerts/server/routes/enable.ts b/x-pack/plugins/alerts/server/routes/enable.ts index b6f86b97d6a3..c162b4a9844b 100644 --- a/x-pack/plugins/alerts/server/routes/enable.ts +++ b/x-pack/plugins/alerts/server/routes/enable.ts @@ -28,9 +28,6 @@ export const enableAlertRoute = (router: IRouter, licenseState: LicenseState) => validate: { params: paramSchema, }, - options: { - tags: ['access:alerting-all'], - }, }, handleDisabledApiKeysError( router.handleLegacyErrors(async function ( diff --git a/x-pack/plugins/alerts/server/routes/find.test.ts b/x-pack/plugins/alerts/server/routes/find.test.ts index f20ee0a54dcd..46702f96a2e1 100644 --- a/x-pack/plugins/alerts/server/routes/find.test.ts +++ b/x-pack/plugins/alerts/server/routes/find.test.ts @@ -31,13 +31,6 @@ describe('findAlertRoute', () => { const [config, handler] = router.get.mock.calls[0]; expect(config.path).toMatchInlineSnapshot(`"/api/alerts/_find"`); - expect(config.options).toMatchInlineSnapshot(` - Object { - "tags": Array [ - "access:alerting-read", - ], - } - `); const findResult = { page: 1, diff --git a/x-pack/plugins/alerts/server/routes/find.ts b/x-pack/plugins/alerts/server/routes/find.ts index 80c9c20eec7d..ef3b16dc9e51 100644 --- a/x-pack/plugins/alerts/server/routes/find.ts +++ b/x-pack/plugins/alerts/server/routes/find.ts @@ -16,7 +16,7 @@ import { LicenseState } from '../lib/license_state'; import { verifyApiAccess } from '../lib/license_api_access'; import { BASE_ALERT_API_PATH } from '../../common'; import { renameKeys } from './lib/rename_keys'; -import { FindOptions } from '..'; +import { FindOptions } from '../alerts_client'; // config definition const querySchema = schema.object({ @@ -50,9 +50,6 @@ export const findAlertRoute = (router: IRouter, licenseState: LicenseState) => { validate: { query: querySchema, }, - options: { - tags: ['access:alerting-read'], - }, }, router.handleLegacyErrors(async function ( context: RequestHandlerContext, diff --git a/x-pack/plugins/alerts/server/routes/get.test.ts b/x-pack/plugins/alerts/server/routes/get.test.ts index b11224ff4794..8c4b06adf70f 100644 --- a/x-pack/plugins/alerts/server/routes/get.test.ts +++ b/x-pack/plugins/alerts/server/routes/get.test.ts @@ -61,13 +61,6 @@ describe('getAlertRoute', () => { const [config, handler] = router.get.mock.calls[0]; expect(config.path).toMatchInlineSnapshot(`"/api/alerts/alert/{id}"`); - expect(config.options).toMatchInlineSnapshot(` - Object { - "tags": Array [ - "access:alerting-read", - ], - } - `); alertsClient.get.mockResolvedValueOnce(mockedAlert); diff --git a/x-pack/plugins/alerts/server/routes/get.ts b/x-pack/plugins/alerts/server/routes/get.ts index ae9ebe129937..0f3fc4b2f3e4 100644 --- a/x-pack/plugins/alerts/server/routes/get.ts +++ b/x-pack/plugins/alerts/server/routes/get.ts @@ -27,9 +27,6 @@ export const getAlertRoute = (router: IRouter, licenseState: LicenseState) => { validate: { params: paramSchema, }, - options: { - tags: ['access:alerting-read'], - }, }, router.handleLegacyErrors(async function ( context: RequestHandlerContext, diff --git a/x-pack/plugins/alerts/server/routes/get_alert_state.test.ts b/x-pack/plugins/alerts/server/routes/get_alert_state.test.ts index 8c9051093f85..d5bf9737d39a 100644 --- a/x-pack/plugins/alerts/server/routes/get_alert_state.test.ts +++ b/x-pack/plugins/alerts/server/routes/get_alert_state.test.ts @@ -48,13 +48,6 @@ describe('getAlertStateRoute', () => { const [config, handler] = router.get.mock.calls[0]; expect(config.path).toMatchInlineSnapshot(`"/api/alerts/alert/{id}/state"`); - expect(config.options).toMatchInlineSnapshot(` - Object { - "tags": Array [ - "access:alerting-read", - ], - } - `); alertsClient.getAlertState.mockResolvedValueOnce(mockedAlertState); @@ -91,13 +84,6 @@ describe('getAlertStateRoute', () => { const [config, handler] = router.get.mock.calls[0]; expect(config.path).toMatchInlineSnapshot(`"/api/alerts/alert/{id}/state"`); - expect(config.options).toMatchInlineSnapshot(` - Object { - "tags": Array [ - "access:alerting-read", - ], - } - `); alertsClient.getAlertState.mockResolvedValueOnce(undefined); @@ -134,13 +120,6 @@ describe('getAlertStateRoute', () => { const [config, handler] = router.get.mock.calls[0]; expect(config.path).toMatchInlineSnapshot(`"/api/alerts/alert/{id}/state"`); - expect(config.options).toMatchInlineSnapshot(` - Object { - "tags": Array [ - "access:alerting-read", - ], - } - `); alertsClient.getAlertState = jest .fn() diff --git a/x-pack/plugins/alerts/server/routes/get_alert_state.ts b/x-pack/plugins/alerts/server/routes/get_alert_state.ts index b27ae3758e1b..089fc80fca35 100644 --- a/x-pack/plugins/alerts/server/routes/get_alert_state.ts +++ b/x-pack/plugins/alerts/server/routes/get_alert_state.ts @@ -27,9 +27,6 @@ export const getAlertStateRoute = (router: IRouter, licenseState: LicenseState) validate: { params: paramSchema, }, - options: { - tags: ['access:alerting-read'], - }, }, router.handleLegacyErrors(async function ( context: RequestHandlerContext, diff --git a/x-pack/plugins/alerts/server/routes/list_alert_types.test.ts b/x-pack/plugins/alerts/server/routes/list_alert_types.test.ts index 3192154f6664..af20dd6e202b 100644 --- a/x-pack/plugins/alerts/server/routes/list_alert_types.test.ts +++ b/x-pack/plugins/alerts/server/routes/list_alert_types.test.ts @@ -9,6 +9,9 @@ import { httpServiceMock } from 'src/core/server/mocks'; import { mockLicenseState } from '../lib/license_state.mock'; import { verifyApiAccess } from '../lib/license_api_access'; import { mockHandlerArguments } from './_mock_handler_arguments'; +import { alertsClientMock } from '../alerts_client.mock'; + +const alertsClient = alertsClientMock.create(); jest.mock('../lib/license_api_access.ts', () => ({ verifyApiAccess: jest.fn(), @@ -28,13 +31,6 @@ describe('listAlertTypesRoute', () => { const [config, handler] = router.get.mock.calls[0]; expect(config.path).toMatchInlineSnapshot(`"/api/alerts/list_alert_types"`); - expect(config.options).toMatchInlineSnapshot(` - Object { - "tags": Array [ - "access:alerting-read", - ], - } - `); const listTypes = [ { @@ -47,12 +43,17 @@ describe('listAlertTypesRoute', () => { }, ], defaultActionGroupId: 'default', - actionVariables: [], + authorizedConsumers: {}, + actionVariables: { + context: [], + state: [], + }, producer: 'test', }, ]; + alertsClient.listAlertTypes.mockResolvedValueOnce(new Set(listTypes)); - const [context, req, res] = mockHandlerArguments({ listTypes }, {}, ['ok']); + const [context, req, res] = mockHandlerArguments({ alertsClient }, {}, ['ok']); expect(await handler(context, req, res)).toMatchInlineSnapshot(` Object { @@ -64,7 +65,11 @@ describe('listAlertTypesRoute', () => { "name": "Default", }, ], - "actionVariables": Array [], + "actionVariables": Object { + "context": Array [], + "state": Array [], + }, + "authorizedConsumers": Object {}, "defaultActionGroupId": "default", "id": "1", "name": "name", @@ -74,7 +79,7 @@ describe('listAlertTypesRoute', () => { } `); - expect(context.alerting!.listTypes).toHaveBeenCalledTimes(1); + expect(alertsClient.listAlertTypes).toHaveBeenCalledTimes(1); expect(res.ok).toHaveBeenCalledWith({ body: listTypes, @@ -90,19 +95,11 @@ describe('listAlertTypesRoute', () => { const [config, handler] = router.get.mock.calls[0]; expect(config.path).toMatchInlineSnapshot(`"/api/alerts/list_alert_types"`); - expect(config.options).toMatchInlineSnapshot(` - Object { - "tags": Array [ - "access:alerting-read", - ], - } - `); const listTypes = [ { id: '1', name: 'name', - enabled: true, actionGroups: [ { id: 'default', @@ -110,13 +107,19 @@ describe('listAlertTypesRoute', () => { }, ], defaultActionGroupId: 'default', - actionVariables: [], - producer: 'alerting', + authorizedConsumers: {}, + actionVariables: { + context: [], + state: [], + }, + producer: 'alerts', }, ]; + alertsClient.listAlertTypes.mockResolvedValueOnce(new Set(listTypes)); + const [context, req, res] = mockHandlerArguments( - { listTypes }, + { alertsClient }, { params: { id: '1' }, }, @@ -141,13 +144,6 @@ describe('listAlertTypesRoute', () => { const [config, handler] = router.get.mock.calls[0]; expect(config.path).toMatchInlineSnapshot(`"/api/alerts/list_alert_types"`); - expect(config.options).toMatchInlineSnapshot(` - Object { - "tags": Array [ - "access:alerting-read", - ], - } - `); const listTypes = [ { @@ -160,13 +156,19 @@ describe('listAlertTypesRoute', () => { }, ], defaultActionGroupId: 'default', - actionVariables: [], - producer: 'alerting', + authorizedConsumers: {}, + actionVariables: { + context: [], + state: [], + }, + producer: 'alerts', }, ]; + alertsClient.listAlertTypes.mockResolvedValueOnce(new Set(listTypes)); + const [context, req, res] = mockHandlerArguments( - { listTypes }, + { alertsClient }, { params: { id: '1' }, }, diff --git a/x-pack/plugins/alerts/server/routes/list_alert_types.ts b/x-pack/plugins/alerts/server/routes/list_alert_types.ts index 51a4558108e2..bf516120fbe9 100644 --- a/x-pack/plugins/alerts/server/routes/list_alert_types.ts +++ b/x-pack/plugins/alerts/server/routes/list_alert_types.ts @@ -20,9 +20,6 @@ export const listAlertTypesRoute = (router: IRouter, licenseState: LicenseState) { path: `${BASE_ALERT_API_PATH}/list_alert_types`, validate: {}, - options: { - tags: ['access:alerting-read'], - }, }, router.handleLegacyErrors(async function ( context: RequestHandlerContext, @@ -34,7 +31,7 @@ export const listAlertTypesRoute = (router: IRouter, licenseState: LicenseState) return res.badRequest({ body: 'RouteHandlerContext is not registered for alerting' }); } return res.ok({ - body: context.alerting.listTypes(), + body: Array.from(await context.alerting.getAlertsClient().listAlertTypes()), }); }) ); diff --git a/x-pack/plugins/alerts/server/routes/mute_all.test.ts b/x-pack/plugins/alerts/server/routes/mute_all.test.ts index bcdb8cbd022a..efa3cdebad8f 100644 --- a/x-pack/plugins/alerts/server/routes/mute_all.test.ts +++ b/x-pack/plugins/alerts/server/routes/mute_all.test.ts @@ -29,13 +29,6 @@ describe('muteAllAlertRoute', () => { const [config, handler] = router.post.mock.calls[0]; expect(config.path).toMatchInlineSnapshot(`"/api/alerts/alert/{id}/_mute_all"`); - expect(config.options).toMatchInlineSnapshot(` - Object { - "tags": Array [ - "access:alerting-all", - ], - } - `); alertsClient.muteAll.mockResolvedValueOnce(); diff --git a/x-pack/plugins/alerts/server/routes/mute_all.ts b/x-pack/plugins/alerts/server/routes/mute_all.ts index 5b05d7231c38..6735121d4edb 100644 --- a/x-pack/plugins/alerts/server/routes/mute_all.ts +++ b/x-pack/plugins/alerts/server/routes/mute_all.ts @@ -27,9 +27,6 @@ export const muteAllAlertRoute = (router: IRouter, licenseState: LicenseState) = validate: { params: paramSchema, }, - options: { - tags: ['access:alerting-all'], - }, }, router.handleLegacyErrors(async function ( context: RequestHandlerContext, diff --git a/x-pack/plugins/alerts/server/routes/mute_instance.test.ts b/x-pack/plugins/alerts/server/routes/mute_instance.test.ts index c382c12de21c..6e700e4e3fd4 100644 --- a/x-pack/plugins/alerts/server/routes/mute_instance.test.ts +++ b/x-pack/plugins/alerts/server/routes/mute_instance.test.ts @@ -31,13 +31,6 @@ describe('muteAlertInstanceRoute', () => { expect(config.path).toMatchInlineSnapshot( `"/api/alerts/alert/{alert_id}/alert_instance/{alert_instance_id}/_mute"` ); - expect(config.options).toMatchInlineSnapshot(` - Object { - "tags": Array [ - "access:alerting-all", - ], - } - `); alertsClient.muteInstance.mockResolvedValueOnce(); diff --git a/x-pack/plugins/alerts/server/routes/mute_instance.ts b/x-pack/plugins/alerts/server/routes/mute_instance.ts index 00550f4af341..5e2ffc7d519e 100644 --- a/x-pack/plugins/alerts/server/routes/mute_instance.ts +++ b/x-pack/plugins/alerts/server/routes/mute_instance.ts @@ -30,9 +30,6 @@ export const muteAlertInstanceRoute = (router: IRouter, licenseState: LicenseSta validate: { params: paramSchema, }, - options: { - tags: ['access:alerting-all'], - }, }, router.handleLegacyErrors(async function ( context: RequestHandlerContext, diff --git a/x-pack/plugins/alerts/server/routes/unmute_all.test.ts b/x-pack/plugins/alerts/server/routes/unmute_all.test.ts index e13af38fe4cb..81fdc5bb4dd7 100644 --- a/x-pack/plugins/alerts/server/routes/unmute_all.test.ts +++ b/x-pack/plugins/alerts/server/routes/unmute_all.test.ts @@ -28,13 +28,6 @@ describe('unmuteAllAlertRoute', () => { const [config, handler] = router.post.mock.calls[0]; expect(config.path).toMatchInlineSnapshot(`"/api/alerts/alert/{id}/_unmute_all"`); - expect(config.options).toMatchInlineSnapshot(` - Object { - "tags": Array [ - "access:alerting-all", - ], - } - `); alertsClient.unmuteAll.mockResolvedValueOnce(); diff --git a/x-pack/plugins/alerts/server/routes/unmute_all.ts b/x-pack/plugins/alerts/server/routes/unmute_all.ts index 1efc9ed40054..a98738054169 100644 --- a/x-pack/plugins/alerts/server/routes/unmute_all.ts +++ b/x-pack/plugins/alerts/server/routes/unmute_all.ts @@ -27,9 +27,6 @@ export const unmuteAllAlertRoute = (router: IRouter, licenseState: LicenseState) validate: { params: paramSchema, }, - options: { - tags: ['access:alerting-all'], - }, }, router.handleLegacyErrors(async function ( context: RequestHandlerContext, diff --git a/x-pack/plugins/alerts/server/routes/unmute_instance.test.ts b/x-pack/plugins/alerts/server/routes/unmute_instance.test.ts index b2e2f24e91de..04e97dbe5e53 100644 --- a/x-pack/plugins/alerts/server/routes/unmute_instance.test.ts +++ b/x-pack/plugins/alerts/server/routes/unmute_instance.test.ts @@ -31,13 +31,6 @@ describe('unmuteAlertInstanceRoute', () => { expect(config.path).toMatchInlineSnapshot( `"/api/alerts/alert/{alertId}/alert_instance/{alertInstanceId}/_unmute"` ); - expect(config.options).toMatchInlineSnapshot(` - Object { - "tags": Array [ - "access:alerting-all", - ], - } - `); alertsClient.unmuteInstance.mockResolvedValueOnce(); diff --git a/x-pack/plugins/alerts/server/routes/unmute_instance.ts b/x-pack/plugins/alerts/server/routes/unmute_instance.ts index 967f9f890c9f..15b882e58580 100644 --- a/x-pack/plugins/alerts/server/routes/unmute_instance.ts +++ b/x-pack/plugins/alerts/server/routes/unmute_instance.ts @@ -28,9 +28,6 @@ export const unmuteAlertInstanceRoute = (router: IRouter, licenseState: LicenseS validate: { params: paramSchema, }, - options: { - tags: ['access:alerting-all'], - }, }, router.handleLegacyErrors(async function ( context: RequestHandlerContext, diff --git a/x-pack/plugins/alerts/server/routes/update.test.ts b/x-pack/plugins/alerts/server/routes/update.test.ts index c7d23f2670b4..dedb08a9972c 100644 --- a/x-pack/plugins/alerts/server/routes/update.test.ts +++ b/x-pack/plugins/alerts/server/routes/update.test.ts @@ -52,13 +52,6 @@ describe('updateAlertRoute', () => { const [config, handler] = router.put.mock.calls[0]; expect(config.path).toMatchInlineSnapshot(`"/api/alerts/alert/{id}"`); - expect(config.options).toMatchInlineSnapshot(` - Object { - "tags": Array [ - "access:alerting-all", - ], - } - `); alertsClient.update.mockResolvedValueOnce(mockedResponse); diff --git a/x-pack/plugins/alerts/server/routes/update.ts b/x-pack/plugins/alerts/server/routes/update.ts index 99b81dfc5b56..9b2fe9a43810 100644 --- a/x-pack/plugins/alerts/server/routes/update.ts +++ b/x-pack/plugins/alerts/server/routes/update.ts @@ -49,9 +49,6 @@ export const updateAlertRoute = (router: IRouter, licenseState: LicenseState) => body: bodySchema, params: paramSchema, }, - options: { - tags: ['access:alerting-all'], - }, }, handleDisabledApiKeysError( router.handleLegacyErrors(async function ( diff --git a/x-pack/plugins/alerts/server/routes/update_api_key.test.ts b/x-pack/plugins/alerts/server/routes/update_api_key.test.ts index babae59553b5..5aa91d215be9 100644 --- a/x-pack/plugins/alerts/server/routes/update_api_key.test.ts +++ b/x-pack/plugins/alerts/server/routes/update_api_key.test.ts @@ -29,13 +29,6 @@ describe('updateApiKeyRoute', () => { const [config, handler] = router.post.mock.calls[0]; expect(config.path).toMatchInlineSnapshot(`"/api/alerts/alert/{id}/_update_api_key"`); - expect(config.options).toMatchInlineSnapshot(` - Object { - "tags": Array [ - "access:alerting-all", - ], - } - `); alertsClient.updateApiKey.mockResolvedValueOnce(); diff --git a/x-pack/plugins/alerts/server/routes/update_api_key.ts b/x-pack/plugins/alerts/server/routes/update_api_key.ts index 4736351a25cb..d44649b05b92 100644 --- a/x-pack/plugins/alerts/server/routes/update_api_key.ts +++ b/x-pack/plugins/alerts/server/routes/update_api_key.ts @@ -28,9 +28,6 @@ export const updateApiKeyRoute = (router: IRouter, licenseState: LicenseState) = validate: { params: paramSchema, }, - options: { - tags: ['access:alerting-all'], - }, }, handleDisabledApiKeysError( router.handleLegacyErrors(async function ( diff --git a/x-pack/plugins/alerts/server/saved_objects/migrations.test.ts b/x-pack/plugins/alerts/server/saved_objects/migrations.test.ts index 38cda5a9a0f7..19f4e918b786 100644 --- a/x-pack/plugins/alerts/server/saved_objects/migrations.test.ts +++ b/x-pack/plugins/alerts/server/saved_objects/migrations.test.ts @@ -47,6 +47,40 @@ describe('7.9.0', () => { }); }); +describe('7.10.0', () => { + beforeEach(() => { + jest.resetAllMocks(); + encryptedSavedObjectsSetup.createMigration.mockImplementation( + (shouldMigrateWhenPredicate, migration) => migration + ); + }); + + test('changes nothing on alerts by other plugins', () => { + const migration710 = getMigrations(encryptedSavedObjectsSetup)['7.10.0']; + const alert = getMockData({}); + expect(migration710(alert, { log })).toMatchObject(alert); + + expect(encryptedSavedObjectsSetup.createMigration).toHaveBeenCalledWith( + expect.any(Function), + expect.any(Function) + ); + }); + + test('migrates the consumer for metrics', () => { + const migration710 = getMigrations(encryptedSavedObjectsSetup)['7.10.0']; + const alert = getMockData({ + consumer: 'metrics', + }); + expect(migration710(alert, { log })).toMatchObject({ + ...alert, + attributes: { + ...alert.attributes, + consumer: 'infrastructure', + }, + }); + }); +}); + function getMockData( overwrites: Record = {} ): SavedObjectUnsanitizedDoc { diff --git a/x-pack/plugins/alerts/server/saved_objects/migrations.ts b/x-pack/plugins/alerts/server/saved_objects/migrations.ts index 142102dd711c..57a400588709 100644 --- a/x-pack/plugins/alerts/server/saved_objects/migrations.ts +++ b/x-pack/plugins/alerts/server/saved_objects/migrations.ts @@ -15,23 +15,27 @@ export function getMigrations( encryptedSavedObjects: EncryptedSavedObjectsPluginSetup ): SavedObjectMigrationMap { return { - '7.9.0': changeAlertingConsumer(encryptedSavedObjects), + /** + * In v7.9.0 we changed the Alerting plugin so it uses the `consumer` value of `alerts` + * prior to that we were using `alerting` and we need to keep these in sync + */ + '7.9.0': changeAlertingConsumer(encryptedSavedObjects, 'alerting', 'alerts'), + /** + * In v7.10.0 we changed the Matrics plugin so it uses the `consumer` value of `infrastructure` + * prior to that we were using `metrics` and we need to keep these in sync + */ + '7.10.0': changeAlertingConsumer(encryptedSavedObjects, 'metrics', 'infrastructure'), }; } -/** - * In v7.9.0 we changed the Alerting plugin so it uses the `consumer` value of `alerts` - * prior to that we were using `alerting` and we need to keep these in sync - */ function changeAlertingConsumer( - encryptedSavedObjects: EncryptedSavedObjectsPluginSetup + encryptedSavedObjects: EncryptedSavedObjectsPluginSetup, + from: string, + to: string ): SavedObjectMigrationFn { - const consumerMigration = new Map(); - consumerMigration.set('alerting', 'alerts'); - return encryptedSavedObjects.createMigration( function shouldBeMigrated(doc): doc is SavedObjectUnsanitizedDoc { - return consumerMigration.has(doc.attributes.consumer); + return doc.attributes.consumer === from; }, (doc: SavedObjectUnsanitizedDoc): SavedObjectUnsanitizedDoc => { const { @@ -41,7 +45,7 @@ function changeAlertingConsumer( ...doc, attributes: { ...doc.attributes, - consumer: consumerMigration.get(consumer) ?? consumer, + consumer: consumer === from ? to : consumer, }, }; } diff --git a/x-pack/plugins/alerts/server/task_runner/create_execution_handler.test.ts b/x-pack/plugins/alerts/server/task_runner/create_execution_handler.test.ts index 3b1948c5e7ad..3ea40fe4c308 100644 --- a/x-pack/plugins/alerts/server/task_runner/create_execution_handler.test.ts +++ b/x-pack/plugins/alerts/server/task_runner/create_execution_handler.test.ts @@ -20,7 +20,7 @@ const alertType: AlertType = { ], defaultActionGroupId: 'default', executor: jest.fn(), - producer: 'alerting', + producer: 'alerts', }; const actionsClient = actionsClientMock.create(); diff --git a/x-pack/plugins/alerts/server/task_runner/task_runner.test.ts b/x-pack/plugins/alerts/server/task_runner/task_runner.test.ts index 7a031c6671fd..4abe58de5a90 100644 --- a/x-pack/plugins/alerts/server/task_runner/task_runner.test.ts +++ b/x-pack/plugins/alerts/server/task_runner/task_runner.test.ts @@ -14,7 +14,7 @@ import { encryptedSavedObjectsMock } from '../../../encrypted_saved_objects/serv import { loggingSystemMock } from '../../../../../src/core/server/mocks'; import { PluginStartContract as ActionsPluginStart } from '../../../actions/server'; import { actionsMock, actionsClientMock } from '../../../actions/server/mocks'; -import { alertsMock } from '../mocks'; +import { alertsMock, alertsClientMock } from '../mocks'; import { eventLoggerMock } from '../../../event_log/server/event_logger.mock'; import { IEventLogger } from '../../../event_log/server'; import { SavedObjectsErrorHelpers } from '../../../../../src/core/server'; @@ -25,7 +25,7 @@ const alertType = { actionGroups: [{ id: 'default', name: 'Default' }], defaultActionGroupId: 'default', executor: jest.fn(), - producer: 'alerting', + producer: 'alerts', }; let fakeTimer: sinon.SinonFakeTimers; @@ -56,8 +56,8 @@ describe('Task Runner', () => { const encryptedSavedObjectsClient = encryptedSavedObjectsMock.createClient(); const services = alertsMock.createAlertServices(); - const savedObjectsClient = services.savedObjectsClient; const actionsClient = actionsClientMock.create(); + const alertsClient = alertsClientMock.create(); const taskRunnerFactoryInitializerParams: jest.Mocked & { actionsPlugin: jest.Mocked; @@ -65,6 +65,7 @@ describe('Task Runner', () => { } = { getServices: jest.fn().mockReturnValue(services), actionsPlugin: actionsMock.createStart(), + getAlertsClientWithRequest: jest.fn().mockReturnValue(alertsClient), encryptedSavedObjectsClient, logger: loggingSystemMock.create().get(), spaceIdToNamespace: jest.fn().mockReturnValue(undefined), @@ -74,34 +75,31 @@ describe('Task Runner', () => { const mockedAlertTypeSavedObject = { id: '1', - type: 'alert', - attributes: { - enabled: true, - alertTypeId: '123', - schedule: { interval: '10s' }, - name: 'alert-name', - tags: ['alert-', '-tags'], - createdBy: 'alert-creator', - updatedBy: 'alert-updater', - mutedInstanceIds: [], - params: { - bar: true, - }, - actions: [ - { - group: 'default', - actionRef: 'action_0', - params: { - foo: true, - }, - }, - ], + consumer: 'bar', + createdAt: new Date('2019-02-12T21:01:22.479Z'), + updatedAt: new Date('2019-02-12T21:01:22.479Z'), + throttle: null, + muteAll: false, + enabled: true, + alertTypeId: '123', + apiKeyOwner: 'elastic', + schedule: { interval: '10s' }, + name: 'alert-name', + tags: ['alert-', '-tags'], + createdBy: 'alert-creator', + updatedBy: 'alert-updater', + mutedInstanceIds: [], + params: { + bar: true, }, - references: [ + actions: [ { - name: 'action_0', - type: 'action', + group: 'default', id: '1', + actionTypeId: 'action', + params: { + foo: true, + }, }, ], }; @@ -109,6 +107,7 @@ describe('Task Runner', () => { beforeEach(() => { jest.resetAllMocks(); taskRunnerFactoryInitializerParams.getServices.mockReturnValue(services); + taskRunnerFactoryInitializerParams.getAlertsClientWithRequest.mockReturnValue(alertsClient); taskRunnerFactoryInitializerParams.actionsPlugin.getActionsClientWithRequest.mockResolvedValue( actionsClient ); @@ -126,7 +125,7 @@ describe('Task Runner', () => { }, taskRunnerFactoryInitializerParams ); - savedObjectsClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); + alertsClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce({ id: '1', type: 'alert', @@ -200,7 +199,7 @@ describe('Task Runner', () => { mockedTaskInstance, taskRunnerFactoryInitializerParams ); - savedObjectsClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); + alertsClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce({ id: '1', type: 'alert', @@ -285,7 +284,7 @@ describe('Task Runner', () => { ], }, message: - "alert: test:1: 'alert-name' instanceId: '1' scheduled actionGroup: 'default' action: undefined:1", + "alert: test:1: 'alert-name' instanceId: '1' scheduled actionGroup: 'default' action: action:1", }); }); @@ -302,7 +301,7 @@ describe('Task Runner', () => { mockedTaskInstance, taskRunnerFactoryInitializerParams ); - savedObjectsClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); + alertsClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce({ id: '1', type: 'alert', @@ -412,7 +411,7 @@ describe('Task Runner', () => { }, ], }, - "message": "alert: test:1: 'alert-name' instanceId: '1' scheduled actionGroup: 'default' action: undefined:1", + "message": "alert: test:1: 'alert-name' instanceId: '1' scheduled actionGroup: 'default' action: action:1", }, ], ] @@ -439,7 +438,7 @@ describe('Task Runner', () => { }, taskRunnerFactoryInitializerParams ); - savedObjectsClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); + alertsClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce({ id: '1', type: 'alert', @@ -526,7 +525,7 @@ describe('Task Runner', () => { mockedTaskInstance, taskRunnerFactoryInitializerParams ); - savedObjectsClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); + alertsClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce({ id: '1', type: 'alert', @@ -548,44 +547,13 @@ describe('Task Runner', () => { ); }); - test('throws error if reference not found', async () => { - const taskRunner = new TaskRunner( - alertType, - mockedTaskInstance, - taskRunnerFactoryInitializerParams - ); - savedObjectsClient.get.mockResolvedValueOnce({ - ...mockedAlertTypeSavedObject, - references: [], - }); - encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce({ - id: '1', - type: 'alert', - attributes: { - apiKey: Buffer.from('123:abc').toString('base64'), - }, - references: [], - }); - expect(await taskRunner.run()).toMatchInlineSnapshot(` - Object { - "runAt": 1970-01-01T00:00:10.000Z, - "state": Object { - "previousStartedAt": 1970-01-01T00:00:00.000Z, - }, - } - `); - expect(taskRunnerFactoryInitializerParams.logger.error).toHaveBeenCalledWith( - `Executing Alert \"1\" has resulted in Error: Action reference \"action_0\" not found in alert id: 1` - ); - }); - test('uses API key when provided', async () => { const taskRunner = new TaskRunner( alertType, mockedTaskInstance, taskRunnerFactoryInitializerParams ); - savedObjectsClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); + alertsClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce({ id: '1', type: 'alert', @@ -621,7 +589,7 @@ describe('Task Runner', () => { mockedTaskInstance, taskRunnerFactoryInitializerParams ); - savedObjectsClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); + alertsClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce({ id: '1', type: 'alert', @@ -660,7 +628,7 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams ); - savedObjectsClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); + alertsClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce({ id: '1', type: 'alert', @@ -722,7 +690,7 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams ); - savedObjectsClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); + alertsClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); const runnerResult = await taskRunner.run(); @@ -747,7 +715,7 @@ describe('Task Runner', () => { taskRunnerFactoryInitializerParams ); - savedObjectsClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); + alertsClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce({ id: '1', type: 'alert', @@ -770,7 +738,7 @@ describe('Task Runner', () => { }); test('recovers gracefully when the Alert Task Runner throws an exception when fetching attributes', async () => { - savedObjectsClient.get.mockImplementation(() => { + alertsClient.get.mockImplementation(() => { throw new Error('OMG'); }); @@ -802,7 +770,7 @@ describe('Task Runner', () => { }); test('avoids rescheduling a failed Alert Task Runner when it throws due to failing to fetch the alert', async () => { - savedObjectsClient.get.mockImplementation(() => { + alertsClient.get.mockImplementation(() => { throw SavedObjectsErrorHelpers.createGenericNotFoundError('task', '1'); }); diff --git a/x-pack/plugins/alerts/server/task_runner/task_runner.ts b/x-pack/plugins/alerts/server/task_runner/task_runner.ts index 3c66b57bb941..e4d04a005c98 100644 --- a/x-pack/plugins/alerts/server/task_runner/task_runner.ts +++ b/x-pack/plugins/alerts/server/task_runner/task_runner.ts @@ -5,7 +5,7 @@ */ import { pickBy, mapValues, omit, without } from 'lodash'; -import { Logger, SavedObject, KibanaRequest } from '../../../../../src/core/server'; +import { Logger, KibanaRequest } from '../../../../../src/core/server'; import { TaskRunnerContext } from './task_runner_factory'; import { ConcreteTaskInstance } from '../../../task_manager/server'; import { createExecutionHandler } from './create_execution_handler'; @@ -17,15 +17,18 @@ import { RawAlert, IntervalSchedule, Services, - AlertInfoParams, - AlertTaskState, RawAlertInstance, + AlertTaskState, + Alert, + AlertExecutorOptions, + SanitizedAlert, } from '../types'; import { promiseResult, map, Resultable, asOk, asErr, resolveErr } from '../lib/result_type'; import { taskInstanceToAlertTaskInstance } from './alert_task_instance'; import { EVENT_LOG_ACTIONS } from '../plugin'; import { IEvent, IEventLogger, SAVED_OBJECT_REL_PRIMARY } from '../../../event_log/server'; import { isAlertSavedObjectNotFoundError } from '../lib/is_alert_not_found_error'; +import { AlertsClient } from '../alerts_client'; const FALLBACK_RETRY_INTERVAL: IntervalSchedule = { interval: '5m' }; @@ -93,8 +96,12 @@ export class TaskRunner { } as unknown) as KibanaRequest; } - async getServicesWithSpaceLevelPermissions(spaceId: string, apiKey: string | null) { - return this.context.getServices(this.getFakeKibanaRequest(spaceId, apiKey)); + private getServicesWithSpaceLevelPermissions( + spaceId: string, + apiKey: string | null + ): [Services, PublicMethodsOf] { + const request = this.getFakeKibanaRequest(spaceId, apiKey); + return [this.context.getServices(request), this.context.getAlertsClientWithRequest(request)]; } private getExecutionHandler( @@ -103,21 +110,8 @@ export class TaskRunner { tags: string[] | undefined, spaceId: string, apiKey: string | null, - actions: RawAlert['actions'], - references: SavedObject['references'] + actions: Alert['actions'] ) { - // Inject ids into actions - const actionsWithIds = actions.map((action) => { - const actionReference = references.find((obj) => obj.name === action.actionRef); - if (!actionReference) { - throw new Error(`Action reference "${action.actionRef}" not found in alert id: ${alertId}`); - } - return { - ...action, - id: actionReference.id, - }; - }); - return createExecutionHandler({ alertId, alertName, @@ -125,7 +119,7 @@ export class TaskRunner { logger: this.logger, actionsPlugin: this.context.actionsPlugin, apiKey, - actions: actionsWithIds, + actions, spaceId, alertType: this.alertType, eventLogger: this.context.eventLogger, @@ -146,20 +140,12 @@ export class TaskRunner { async executeAlertInstances( services: Services, - alertInfoParams: AlertInfoParams, + alert: SanitizedAlert, + params: AlertExecutorOptions['params'], executionHandler: ReturnType, spaceId: string ): Promise { - const { - params, - throttle, - muteAll, - mutedInstanceIds, - name, - tags, - createdBy, - updatedBy, - } = alertInfoParams; + const { throttle, muteAll, mutedInstanceIds, name, tags, createdBy, updatedBy } = alert; const { params: { alertId }, state: { alertInstances: alertRawInstances = {}, alertTypeState = {}, previousStartedAt }, @@ -262,33 +248,22 @@ export class TaskRunner { }; } - async validateAndExecuteAlert( - services: Services, - apiKey: string | null, - attributes: RawAlert, - references: SavedObject['references'] - ) { + async validateAndExecuteAlert(services: Services, apiKey: string | null, alert: SanitizedAlert) { const { params: { alertId, spaceId }, } = this.taskInstance; // Validate - const params = validateAlertTypeParams(this.alertType, attributes.params); + const validatedParams = validateAlertTypeParams(this.alertType, alert.params); const executionHandler = this.getExecutionHandler( alertId, - attributes.name, - attributes.tags, + alert.name, + alert.tags, spaceId, apiKey, - attributes.actions, - references - ); - return this.executeAlertInstances( - services, - { ...attributes, params }, - executionHandler, - spaceId + alert.actions ); + return this.executeAlertInstances(services, alert, validatedParams, executionHandler, spaceId); } async loadAlertAttributesAndRun(): Promise> { @@ -297,17 +272,17 @@ export class TaskRunner { } = this.taskInstance; const apiKey = await this.getApiKeyForAlertPermissions(alertId, spaceId); - const services = await this.getServicesWithSpaceLevelPermissions(spaceId, apiKey); + const [services, alertsClient] = await this.getServicesWithSpaceLevelPermissions( + spaceId, + apiKey + ); // Ensure API key is still valid and user has access - const { attributes, references } = await services.savedObjectsClient.get( - 'alert', - alertId - ); + const alert = await alertsClient.get({ id: alertId }); return { state: await promiseResult( - this.validateAndExecuteAlert(services, apiKey, attributes, references) + this.validateAndExecuteAlert(services, apiKey, alert) ), runAt: asOk( getNextRunAt( @@ -315,7 +290,7 @@ export class TaskRunner { // we do not currently have a good way of returning the type // from SavedObjectsClient, and as we currenrtly require a schedule // and we only support `interval`, we can cast this safely - attributes.schedule as IntervalSchedule + alert.schedule ) ), }; diff --git a/x-pack/plugins/alerts/server/task_runner/task_runner_factory.test.ts b/x-pack/plugins/alerts/server/task_runner/task_runner_factory.test.ts index 8f3e44b1cf42..9af7d9ddc44e 100644 --- a/x-pack/plugins/alerts/server/task_runner/task_runner_factory.test.ts +++ b/x-pack/plugins/alerts/server/task_runner/task_runner_factory.test.ts @@ -10,7 +10,7 @@ import { TaskRunnerContext, TaskRunnerFactory } from './task_runner_factory'; import { encryptedSavedObjectsMock } from '../../../encrypted_saved_objects/server/mocks'; import { loggingSystemMock } from '../../../../../src/core/server/mocks'; import { actionsMock } from '../../../actions/server/mocks'; -import { alertsMock } from '../mocks'; +import { alertsMock, alertsClientMock } from '../mocks'; import { eventLoggerMock } from '../../../event_log/server/event_logger.mock'; const alertType = { @@ -19,7 +19,7 @@ const alertType = { actionGroups: [{ id: 'default', name: 'Default' }], defaultActionGroupId: 'default', executor: jest.fn(), - producer: 'alerting', + producer: 'alerts', }; let fakeTimer: sinon.SinonFakeTimers; @@ -52,9 +52,11 @@ describe('Task Runner Factory', () => { const encryptedSavedObjectsPlugin = encryptedSavedObjectsMock.createStart(); const services = alertsMock.createAlertServices(); + const alertsClient = alertsClientMock.create(); const taskRunnerFactoryInitializerParams: jest.Mocked = { getServices: jest.fn().mockReturnValue(services), + getAlertsClientWithRequest: jest.fn().mockReturnValue(alertsClient), actionsPlugin: actionsMock.createStart(), encryptedSavedObjectsClient: encryptedSavedObjectsPlugin.getClient(), logger: loggingSystemMock.create().get(), diff --git a/x-pack/plugins/alerts/server/task_runner/task_runner_factory.ts b/x-pack/plugins/alerts/server/task_runner/task_runner_factory.ts index ca762cf2b210..6f83e34cdbe0 100644 --- a/x-pack/plugins/alerts/server/task_runner/task_runner_factory.ts +++ b/x-pack/plugins/alerts/server/task_runner/task_runner_factory.ts @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { Logger } from '../../../../../src/core/server'; +import { Logger, KibanaRequest } from '../../../../../src/core/server'; import { RunContext } from '../../../task_manager/server'; import { EncryptedSavedObjectsClient } from '../../../encrypted_saved_objects/server'; import { PluginStartContract as ActionsPluginStartContract } from '../../../actions/server'; @@ -15,10 +15,12 @@ import { } from '../types'; import { TaskRunner } from './task_runner'; import { IEventLogger } from '../../../event_log/server'; +import { AlertsClient } from '../alerts_client'; export interface TaskRunnerContext { logger: Logger; getServices: GetServicesFunction; + getAlertsClientWithRequest(request: KibanaRequest): PublicMethodsOf; actionsPlugin: ActionsPluginStartContract; eventLogger: IEventLogger; encryptedSavedObjectsClient: EncryptedSavedObjectsClient; diff --git a/x-pack/plugins/apm/server/feature.ts b/x-pack/plugins/apm/server/feature.ts index 80f722bae086..38e75f75ad04 100644 --- a/x-pack/plugins/apm/server/feature.ts +++ b/x-pack/plugins/apm/server/feature.ts @@ -5,6 +5,7 @@ */ import { i18n } from '@kbn/i18n'; +import { AlertType } from '../common/alert_types'; export const APM_FEATURE = { id: 'apm', @@ -16,57 +17,34 @@ export const APM_FEATURE = { navLinkId: 'apm', app: ['apm', 'kibana'], catalogue: ['apm'], + alerting: Object.values(AlertType), // see x-pack/plugins/features/common/feature_kibana_privileges.ts privileges: { all: { app: ['apm', 'kibana'], - api: [ - 'apm', - 'apm_write', - 'actions-read', - 'actions-all', - 'alerting-read', - 'alerting-all', - ], + api: ['apm', 'apm_write'], catalogue: ['apm'], savedObject: { - all: ['alert', 'action', 'action_task_params'], + all: [], read: [], }, - ui: [ - 'show', - 'save', - 'alerting:show', - 'actions:show', - 'alerting:save', - 'actions:save', - 'alerting:delete', - 'actions:delete', - ], + alerting: { + all: Object.values(AlertType), + }, + ui: ['show', 'save', 'alerting:show', 'alerting:save'], }, read: { app: ['apm', 'kibana'], - api: [ - 'apm', - 'actions-read', - 'actions-all', - 'alerting-read', - 'alerting-all', - ], + api: ['apm'], catalogue: ['apm'], savedObject: { - all: ['alert', 'action', 'action_task_params'], + all: [], read: [], }, - ui: [ - 'show', - 'alerting:show', - 'actions:show', - 'alerting:save', - 'actions:save', - 'alerting:delete', - 'actions:delete', - ], + alerting: { + all: Object.values(AlertType), + }, + ui: ['show', 'alerting:show', 'alerting:save'], }, }, }; diff --git a/x-pack/plugins/features/common/feature.ts b/x-pack/plugins/features/common/feature.ts index 4a293e0c962c..1b700fb1a6ad 100644 --- a/x-pack/plugins/features/common/feature.ts +++ b/x-pack/plugins/features/common/feature.ts @@ -94,6 +94,13 @@ export interface FeatureConfig { */ catalogue?: readonly string[]; + /** + * If your feature grants access to specific Alert Types, you can specify them here to control visibility based on the current space. + * Include both Alert Types registered by the feature and external Alert Types such as built-in + * Alert Types and Alert Types provided by other features to which you wish to grant access. + */ + alerting?: readonly string[]; + /** * Feature privilege definition. * @@ -179,6 +186,10 @@ export class Feature { return this.config.privileges; } + public get alerting() { + return this.config.alerting; + } + public get excludeFromBasePrivileges() { return this.config.excludeFromBasePrivileges ?? false; } diff --git a/x-pack/plugins/features/common/feature_kibana_privileges.ts b/x-pack/plugins/features/common/feature_kibana_privileges.ts index a9ba38e36f20..c8faf75b348f 100644 --- a/x-pack/plugins/features/common/feature_kibana_privileges.ts +++ b/x-pack/plugins/features/common/feature_kibana_privileges.ts @@ -75,6 +75,34 @@ export interface FeatureKibanaPrivileges { */ app?: readonly string[]; + /** + * If your feature requires access to specific Alert Types, then specify your access needs here. + * Include both Alert Types registered by the feature and external Alert Types such as built-in + * Alert Types and Alert Types provided by other features to which you wish to grant access. + */ + alerting?: { + /** + * List of alert types which users should have full read/write access to when granted this privilege. + * @example + * ```ts + * { + * all: ['my-alert-type-within-my-feature'] + * } + * ``` + */ + all?: readonly string[]; + + /** + * List of alert types which users should have read-only access to when granted this privilege. + * @example + * ```ts + * { + * read: ['my-alert-type'] + * } + * ``` + */ + read?: readonly string[]; + }; /** * If your feature requires access to specific saved objects, then specify your access needs here. */ diff --git a/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap b/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap index fe0a13fe702e..2c98dc132f25 100644 --- a/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap +++ b/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap @@ -55,6 +55,10 @@ exports[`buildOSSFeatures returns the dashboard feature augmented with appropria Array [ Object { "privilege": Object { + "alerting": Object { + "all": Array [], + "read": Array [], + }, "api": Array [], "app": Array [ "dashboards", @@ -179,6 +183,10 @@ exports[`buildOSSFeatures returns the discover feature augmented with appropriat Array [ Object { "privilege": Object { + "alerting": Object { + "all": Array [], + "read": Array [], + }, "api": Array [], "app": Array [ "discover", @@ -403,6 +411,10 @@ exports[`buildOSSFeatures returns the visualize feature augmented with appropria Array [ Object { "privilege": Object { + "alerting": Object { + "all": Array [], + "read": Array [], + }, "api": Array [], "app": Array [ "visualize", diff --git a/x-pack/plugins/features/server/feature_registry.test.ts b/x-pack/plugins/features/server/feature_registry.test.ts index 75022922917b..f123068e4175 100644 --- a/x-pack/plugins/features/server/feature_registry.test.ts +++ b/x-pack/plugins/features/server/feature_registry.test.ts @@ -743,6 +743,168 @@ describe('FeatureRegistry', () => { ); }); + it(`prevents privileges from specifying alerting entries that don't exist at the root level`, () => { + const feature: FeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: [], + alerting: ['bar'], + privileges: { + all: { + alerting: { + all: ['foo', 'bar'], + read: ['baz'], + }, + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], + }, + read: { + alerting: { read: ['foo', 'bar', 'baz'] }, + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], + }, + }, + }; + + const featureRegistry = new FeatureRegistry(); + + expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( + `"Feature privilege test-feature.all has unknown alerting entries: foo, baz"` + ); + }); + + it(`prevents features from specifying alerting entries that don't exist at the privilege level`, () => { + const feature: FeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: [], + alerting: ['foo', 'bar', 'baz'], + privileges: { + all: { + alerting: { all: ['foo'] }, + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], + }, + read: { + alerting: { all: ['foo'] }, + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], + }, + }, + subFeatures: [ + { + name: 'my sub feature', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'cool-sub-feature-privilege', + name: 'cool privilege', + includeIn: 'none', + savedObject: { + all: [], + read: [], + }, + ui: [], + alerting: { all: ['bar'] }, + }, + ], + }, + ], + }, + ], + }; + + const featureRegistry = new FeatureRegistry(); + + expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( + `"Feature test-feature specifies alerting entries which are not granted to any privileges: baz"` + ); + }); + + it(`prevents reserved privileges from specifying alerting entries that don't exist at the root level`, () => { + const feature: FeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: [], + alerting: ['bar'], + privileges: null, + reserved: { + description: 'something', + privileges: [ + { + id: 'reserved', + privilege: { + alerting: { all: ['foo', 'bar', 'baz'] }, + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], + }, + }, + ], + }, + }; + + const featureRegistry = new FeatureRegistry(); + + expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( + `"Feature privilege test-feature.reserved has unknown alerting entries: foo, baz"` + ); + }); + + it(`prevents features from specifying alerting entries that don't exist at the reserved privilege level`, () => { + const feature: FeatureConfig = { + id: 'test-feature', + name: 'Test Feature', + app: [], + alerting: ['foo', 'bar', 'baz'], + privileges: null, + reserved: { + description: 'something', + privileges: [ + { + id: 'reserved', + privilege: { + alerting: { all: ['foo', 'bar'] }, + savedObject: { + all: [], + read: [], + }, + ui: [], + app: [], + }, + }, + ], + }, + }; + + const featureRegistry = new FeatureRegistry(); + + expect(() => featureRegistry.register(feature)).toThrowErrorMatchingInlineSnapshot( + `"Feature test-feature specifies alerting entries which are not granted to any privileges: baz"` + ); + }); + it(`prevents privileges from specifying management sections that don't exist at the root level`, () => { const feature: FeatureConfig = { id: 'test-feature', diff --git a/x-pack/plugins/features/server/feature_schema.ts b/x-pack/plugins/features/server/feature_schema.ts index c45788b511cd..95298603d706 100644 --- a/x-pack/plugins/features/server/feature_schema.ts +++ b/x-pack/plugins/features/server/feature_schema.ts @@ -26,6 +26,7 @@ const managementSchema = Joi.object().pattern( Joi.array().items(Joi.string().regex(uiCapabilitiesRegex)) ); const catalogueSchema = Joi.array().items(Joi.string().regex(uiCapabilitiesRegex)); +const alertingSchema = Joi.array().items(Joi.string()); const privilegeSchema = Joi.object({ excludeFromBasePrivileges: Joi.boolean(), @@ -33,6 +34,10 @@ const privilegeSchema = Joi.object({ catalogue: catalogueSchema, api: Joi.array().items(Joi.string()), app: Joi.array().items(Joi.string()), + alerting: Joi.object({ + all: alertingSchema, + read: alertingSchema, + }), savedObject: Joi.object({ all: Joi.array().items(Joi.string()).required(), read: Joi.array().items(Joi.string()).required(), @@ -46,6 +51,10 @@ const subFeaturePrivilegeSchema = Joi.object({ includeIn: Joi.string().allow('all', 'read', 'none').required(), management: managementSchema, catalogue: catalogueSchema, + alerting: Joi.object({ + all: alertingSchema, + read: alertingSchema, + }), api: Joi.array().items(Joi.string()), app: Joi.array().items(Joi.string()), savedObject: Joi.object({ @@ -82,6 +91,7 @@ const schema = Joi.object({ app: Joi.array().items(Joi.string()).required(), management: managementSchema, catalogue: catalogueSchema, + alerting: alertingSchema, privileges: Joi.object({ all: privilegeSchema, read: privilegeSchema, @@ -113,7 +123,7 @@ export function validateFeature(feature: FeatureConfig) { throw validateResult.error; } // the following validation can't be enforced by the Joi schema, since it'd require us looking "up" the object graph for the list of valid value, which they explicitly forbid. - const { app = [], management = {}, catalogue = [] } = feature; + const { app = [], management = {}, catalogue = [], alerting = [] } = feature; const unseenApps = new Set(app); @@ -126,6 +136,8 @@ export function validateFeature(feature: FeatureConfig) { const unseenCatalogue = new Set(catalogue); + const unseenAlertTypes = new Set(alerting); + function validateAppEntry(privilegeId: string, entry: readonly string[] = []) { entry.forEach((privilegeApp) => unseenApps.delete(privilegeApp)); @@ -152,6 +164,23 @@ export function validateFeature(feature: FeatureConfig) { } } + function validateAlertingEntry(privilegeId: string, entry: FeatureKibanaPrivileges['alerting']) { + const all = entry?.all ?? []; + const read = entry?.read ?? []; + + all.forEach((privilegeAlertTypes) => unseenAlertTypes.delete(privilegeAlertTypes)); + read.forEach((privilegeAlertTypes) => unseenAlertTypes.delete(privilegeAlertTypes)); + + const unknownAlertingEntries = difference([...all, ...read], alerting); + if (unknownAlertingEntries.length > 0) { + throw new Error( + `Feature privilege ${ + feature.id + }.${privilegeId} has unknown alerting entries: ${unknownAlertingEntries.join(', ')}` + ); + } + } + function validateManagementEntry( privilegeId: string, managementEntry: Record = {} @@ -212,6 +241,7 @@ export function validateFeature(feature: FeatureConfig) { validateCatalogueEntry(privilegeId, privilegeDefinition.catalogue); validateManagementEntry(privilegeId, privilegeDefinition.management); + validateAlertingEntry(privilegeId, privilegeDefinition.alerting); }); const subFeatureEntries = feature.subFeatures ?? []; @@ -221,6 +251,7 @@ export function validateFeature(feature: FeatureConfig) { validateAppEntry(subFeaturePrivilege.id, subFeaturePrivilege.app); validateCatalogueEntry(subFeaturePrivilege.id, subFeaturePrivilege.catalogue); validateManagementEntry(subFeaturePrivilege.id, subFeaturePrivilege.management); + validateAlertingEntry(subFeaturePrivilege.id, subFeaturePrivilege.alerting); }); }); }); @@ -261,4 +292,14 @@ export function validateFeature(feature: FeatureConfig) { )}` ); } + + if (unseenAlertTypes.size > 0) { + throw new Error( + `Feature ${ + feature.id + } specifies alerting entries which are not granted to any privileges: ${Array.from( + unseenAlertTypes.values() + ).join(',')}` + ); + } } diff --git a/x-pack/plugins/infra/public/alerting/inventory/components/alert_flyout.tsx b/x-pack/plugins/infra/public/alerting/inventory/components/alert_flyout.tsx index 7e85a2bdf7e9..804ff9602c81 100644 --- a/x-pack/plugins/infra/public/alerting/inventory/components/alert_flyout.tsx +++ b/x-pack/plugins/infra/public/alerting/inventory/components/alert_flyout.tsx @@ -44,7 +44,7 @@ export const AlertFlyout = (props: Props) => { setAddFlyoutVisibility={props.setVisible} alertTypeId={METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID} canChangeTrigger={false} - consumer={'metrics'} + consumer={'infrastructure'} /> )} diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/components/alert_flyout.tsx b/x-pack/plugins/infra/public/alerting/metric_threshold/components/alert_flyout.tsx index b0c8cdb9d419..b19a399b0e50 100644 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/components/alert_flyout.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/components/alert_flyout.tsx @@ -46,7 +46,7 @@ export const AlertFlyout = (props: Props) => { setAddFlyoutVisibility={props.setVisible} alertTypeId={METRIC_THRESHOLD_ALERT_TYPE_ID} canChangeTrigger={false} - consumer={'metrics'} + consumer={'infrastructure'} /> )} diff --git a/x-pack/plugins/infra/server/features.ts b/x-pack/plugins/infra/server/features.ts index fa228e03194a..0de431186b15 100644 --- a/x-pack/plugins/infra/server/features.ts +++ b/x-pack/plugins/infra/server/features.ts @@ -5,6 +5,9 @@ */ import { i18n } from '@kbn/i18n'; +import { LOG_DOCUMENT_COUNT_ALERT_TYPE_ID } from '../common/alerting/logs/types'; +import { METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID } from './lib/alerting/inventory_metric_threshold/types'; +import { METRIC_THRESHOLD_ALERT_TYPE_ID } from './lib/alerting/metric_threshold/types'; export const METRICS_FEATURE = { id: 'infrastructure', @@ -16,44 +19,33 @@ export const METRICS_FEATURE = { navLinkId: 'metrics', app: ['infra', 'kibana'], catalogue: ['infraops'], + alerting: [METRIC_THRESHOLD_ALERT_TYPE_ID, METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID], privileges: { all: { app: ['infra', 'kibana'], catalogue: ['infraops'], - api: ['infra', 'actions-read', 'actions-all', 'alerting-read', 'alerting-all'], + api: ['infra'], savedObject: { - all: ['infrastructure-ui-source', 'alert', 'action', 'action_task_params'], + all: ['infrastructure-ui-source'], read: ['index-pattern'], }, - ui: [ - 'show', - 'configureSource', - 'save', - 'alerting:show', - 'actions:show', - 'alerting:save', - 'actions:save', - 'alerting:delete', - 'actions:delete', - ], + alerting: { + all: [METRIC_THRESHOLD_ALERT_TYPE_ID, METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID], + }, + ui: ['show', 'configureSource', 'save', 'alerting:show'], }, read: { app: ['infra', 'kibana'], catalogue: ['infraops'], - api: ['infra', 'actions-read', 'actions-all', 'alerting-read', 'alerting-all'], + api: ['infra'], savedObject: { - all: ['alert', 'action', 'action_task_params'], + all: [], read: ['infrastructure-ui-source', 'index-pattern'], }, - ui: [ - 'show', - 'alerting:show', - 'actions:show', - 'alerting:save', - 'actions:save', - 'alerting:delete', - 'actions:delete', - ], + alerting: { + all: [METRIC_THRESHOLD_ALERT_TYPE_ID, METRIC_INVENTORY_THRESHOLD_ALERT_TYPE_ID], + }, + ui: ['show', 'alerting:show'], }, }, }; @@ -68,6 +60,7 @@ export const LOGS_FEATURE = { navLinkId: 'logs', app: ['infra', 'kibana'], catalogue: ['infralogging'], + alerting: [LOG_DOCUMENT_COUNT_ALERT_TYPE_ID], privileges: { all: { app: ['infra', 'kibana'], @@ -77,12 +70,18 @@ export const LOGS_FEATURE = { all: ['infrastructure-ui-source'], read: [], }, + alerting: { + all: [LOG_DOCUMENT_COUNT_ALERT_TYPE_ID], + }, ui: ['show', 'configureSource', 'save'], }, read: { app: ['infra', 'kibana'], catalogue: ['infralogging'], api: ['infra'], + alerting: { + all: [LOG_DOCUMENT_COUNT_ALERT_TYPE_ID], + }, savedObject: { all: [], read: ['infrastructure-ui-source'], diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/register_inventory_metric_threshold_alert_type.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/register_inventory_metric_threshold_alert_type.ts index 85b38f48d9f2..fa5277cb0998 100644 --- a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/register_inventory_metric_threshold_alert_type.ts +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/register_inventory_metric_threshold_alert_type.ts @@ -40,7 +40,7 @@ export const registerMetricInventoryThresholdAlertType = (libs: InfraBackendLibs }, defaultActionGroupId: FIRED_ACTIONS.id, actionGroups: [FIRED_ACTIONS], - producer: 'metrics', + producer: 'infrastructure', executor: createInventoryMetricThresholdExecutor(libs), actionVariables: { context: [ diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts index 529a1d176c43..51a127e9345b 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts @@ -117,6 +117,6 @@ export function registerMetricThresholdAlertType(libs: InfraBackendLibs) { { name: 'threshold', description: thresholdActionVariableDescription }, ], }, - producer: 'metrics', + producer: 'infrastructure', }; } diff --git a/x-pack/plugins/monitoring/server/plugin.ts b/x-pack/plugins/monitoring/server/plugin.ts index 39ec5fe1ffaa..86022a0e863d 100644 --- a/x-pack/plugins/monitoring/server/plugin.ts +++ b/x-pack/plugins/monitoring/server/plugin.ts @@ -25,6 +25,7 @@ import { LOGGING_TAG, KIBANA_MONITORING_LOGGING_TAG, KIBANA_STATS_TYPE_MONITORING, + ALERTS, } from '../common/constants'; import { MonitoringConfig, createConfig, configSchema } from './config'; // @ts-ignore @@ -242,6 +243,7 @@ export class Plugin { app: ['monitoring', 'kibana'], catalogue: ['monitoring'], privileges: null, + alerting: ALERTS, reserved: { description: i18n.translate('xpack.monitoring.feature.reserved.description', { defaultMessage: 'To grant users access, you should also assign the monitoring_user role.', @@ -256,6 +258,9 @@ export class Plugin { all: [], read: [], }, + alerting: { + all: ALERTS, + }, ui: [], }, }, diff --git a/x-pack/plugins/security/server/authorization/actions/__snapshots__/alerting.test.ts.snap b/x-pack/plugins/security/server/authorization/actions/__snapshots__/alerting.test.ts.snap new file mode 100644 index 000000000000..afa907fe0983 --- /dev/null +++ b/x-pack/plugins/security/server/authorization/actions/__snapshots__/alerting.test.ts.snap @@ -0,0 +1,37 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`#get alertType of "" throws error 1`] = `"alertTypeId is required and must be a string"`; + +exports[`#get alertType of {} throws error 1`] = `"alertTypeId is required and must be a string"`; + +exports[`#get alertType of 1 throws error 1`] = `"alertTypeId is required and must be a string"`; + +exports[`#get alertType of null throws error 1`] = `"alertTypeId is required and must be a string"`; + +exports[`#get alertType of true throws error 1`] = `"alertTypeId is required and must be a string"`; + +exports[`#get alertType of undefined throws error 1`] = `"alertTypeId is required and must be a string"`; + +exports[`#get consumer of "" throws error 1`] = `"consumer is required and must be a string"`; + +exports[`#get consumer of {} throws error 1`] = `"consumer is required and must be a string"`; + +exports[`#get consumer of 1 throws error 1`] = `"consumer is required and must be a string"`; + +exports[`#get consumer of null throws error 1`] = `"consumer is required and must be a string"`; + +exports[`#get consumer of true throws error 1`] = `"consumer is required and must be a string"`; + +exports[`#get consumer of undefined throws error 1`] = `"consumer is required and must be a string"`; + +exports[`#get operation of "" throws error 1`] = `"operation is required and must be a string"`; + +exports[`#get operation of {} throws error 1`] = `"operation is required and must be a string"`; + +exports[`#get operation of 1 throws error 1`] = `"operation is required and must be a string"`; + +exports[`#get operation of null throws error 1`] = `"operation is required and must be a string"`; + +exports[`#get operation of true throws error 1`] = `"operation is required and must be a string"`; + +exports[`#get operation of undefined throws error 1`] = `"operation is required and must be a string"`; diff --git a/x-pack/plugins/security/server/authorization/actions/actions.mock.ts b/x-pack/plugins/security/server/authorization/actions/actions.mock.ts new file mode 100644 index 000000000000..f41faaa3dd52 --- /dev/null +++ b/x-pack/plugins/security/server/authorization/actions/actions.mock.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { ApiActions } from './api'; +import { AppActions } from './app'; +import { SavedObjectActions } from './saved_object'; +import { SpaceActions } from './space'; +import { UIActions } from './ui'; +import { AlertingActions } from './alerting'; +import { Actions } from './actions'; + +jest.mock('./api'); +jest.mock('./app'); +jest.mock('./saved_object'); +jest.mock('./space'); +jest.mock('./ui'); +jest.mock('./alerting'); + +const create = (versionNumber: string) => { + const t = ({ + api: new ApiActions(versionNumber), + app: new AppActions(versionNumber), + login: 'login:', + savedObject: new SavedObjectActions(versionNumber), + alerting: new AlertingActions(versionNumber), + space: new SpaceActions(versionNumber), + ui: new UIActions(versionNumber), + version: `version:${versionNumber}`, + } as unknown) as jest.Mocked; + return t; +}; + +export const actionsMock = { create }; diff --git a/x-pack/plugins/security/server/authorization/actions/actions.ts b/x-pack/plugins/security/server/authorization/actions/actions.ts index 00293e88abe7..34258bdcf972 100644 --- a/x-pack/plugins/security/server/authorization/actions/actions.ts +++ b/x-pack/plugins/security/server/authorization/actions/actions.ts @@ -9,6 +9,7 @@ import { AppActions } from './app'; import { SavedObjectActions } from './saved_object'; import { SpaceActions } from './space'; import { UIActions } from './ui'; +import { AlertingActions } from './alerting'; /** Actions are used to create the "actions" that are associated with Elasticsearch's * application privileges, and are used to perform the authorization checks implemented @@ -23,6 +24,8 @@ export class Actions { public readonly savedObject = new SavedObjectActions(this.versionNumber); + public readonly alerting = new AlertingActions(this.versionNumber); + public readonly space = new SpaceActions(this.versionNumber); public readonly ui = new UIActions(this.versionNumber); diff --git a/x-pack/plugins/security/server/authorization/actions/alerting.test.ts b/x-pack/plugins/security/server/authorization/actions/alerting.test.ts new file mode 100644 index 000000000000..744543f38a91 --- /dev/null +++ b/x-pack/plugins/security/server/authorization/actions/alerting.test.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { AlertingActions } from './alerting'; + +const version = '1.0.0-zeta1'; + +describe('#get', () => { + [null, undefined, '', 1, true, {}].forEach((alertType: any) => { + test(`alertType of ${JSON.stringify(alertType)} throws error`, () => { + const alertingActions = new AlertingActions(version); + expect(() => + alertingActions.get(alertType, 'consumer', 'foo-action') + ).toThrowErrorMatchingSnapshot(); + }); + }); + + [null, undefined, '', 1, true, {}].forEach((operation: any) => { + test(`operation of ${JSON.stringify(operation)} throws error`, () => { + const alertingActions = new AlertingActions(version); + expect(() => + alertingActions.get('foo-alertType', 'consumer', operation) + ).toThrowErrorMatchingSnapshot(); + }); + }); + + [null, '', 1, true, undefined, {}].forEach((consumer: any) => { + test(`consumer of ${JSON.stringify(consumer)} throws error`, () => { + const alertingActions = new AlertingActions(version); + expect(() => + alertingActions.get('foo-alertType', consumer, 'operation') + ).toThrowErrorMatchingSnapshot(); + }); + }); + + test('returns `alerting:${alertType}/${consumer}/${operation}`', () => { + const alertingActions = new AlertingActions(version); + expect(alertingActions.get('foo-alertType', 'consumer', 'bar-operation')).toBe( + 'alerting:1.0.0-zeta1:foo-alertType/consumer/bar-operation' + ); + }); +}); diff --git a/x-pack/plugins/security/server/authorization/actions/alerting.ts b/x-pack/plugins/security/server/authorization/actions/alerting.ts new file mode 100644 index 000000000000..99d04efe6892 --- /dev/null +++ b/x-pack/plugins/security/server/authorization/actions/alerting.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { isString } from 'lodash'; + +export class AlertingActions { + private readonly prefix: string; + + constructor(versionNumber: string) { + this.prefix = `alerting:${versionNumber}:`; + } + + public get(alertTypeId: string, consumer: string, operation: string): string { + if (!alertTypeId || !isString(alertTypeId)) { + throw new Error('alertTypeId is required and must be a string'); + } + + if (!operation || !isString(operation)) { + throw new Error('operation is required and must be a string'); + } + + if (!consumer || !isString(consumer)) { + throw new Error('consumer is required and must be a string'); + } + + return `${this.prefix}${alertTypeId}/${consumer}/${operation}`; + } +} diff --git a/x-pack/plugins/security/server/authorization/disable_ui_capabilities.test.ts b/x-pack/plugins/security/server/authorization/disable_ui_capabilities.test.ts index 45f55b34baf9..4aedac0757bc 100644 --- a/x-pack/plugins/security/server/authorization/disable_ui_capabilities.test.ts +++ b/x-pack/plugins/security/server/authorization/disable_ui_capabilities.test.ts @@ -20,6 +20,9 @@ const mockRequest = httpServerMock.createKibanaRequest(); const createMockAuthz = (options: MockAuthzOptions) => { const mock = authorizationMock.create({ version: '1.0.0-zeta1' }); + // plug actual ui actions into mock Actions with + mock.actions = actions; + mock.checkPrivilegesDynamicallyWithRequest.mockImplementation((request) => { expect(request).toBe(mockRequest); diff --git a/x-pack/plugins/security/server/authorization/index.mock.ts b/x-pack/plugins/security/server/authorization/index.mock.ts index 930ede415772..62b254d132d9 100644 --- a/x-pack/plugins/security/server/authorization/index.mock.ts +++ b/x-pack/plugins/security/server/authorization/index.mock.ts @@ -3,16 +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 { Actions } from '.'; import { AuthorizationMode } from './mode'; +import { actionsMock } from './actions/actions.mock'; export const authorizationMock = { create: ({ version = 'mock-version', applicationName = 'mock-application', }: { version?: string; applicationName?: string } = {}) => ({ - actions: new Actions(version), + actions: actionsMock.create(version), checkPrivilegesWithRequest: jest.fn(), checkPrivilegesDynamicallyWithRequest: jest.fn(), checkSavedObjectsPrivilegesWithRequest: jest.fn(), diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.test.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.test.ts new file mode 100644 index 000000000000..99d69602db13 --- /dev/null +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.test.ts @@ -0,0 +1,178 @@ +/* + * 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 { Actions } from '../../actions'; +import { FeaturePrivilegeAlertingBuilder } from './alerting'; +import { Feature, FeatureKibanaPrivileges } from '../../../../../features/server'; + +const version = '1.0.0-zeta1'; + +describe(`feature_privilege_builder`, () => { + describe(`alerting`, () => { + test('grants no privileges by default', () => { + const actions = new Actions(version); + const alertingFeaturePrivileges = new FeaturePrivilegeAlertingBuilder(actions); + + const privilege: FeatureKibanaPrivileges = { + alerting: { + all: [], + read: [], + }, + + savedObject: { + all: [], + read: [], + }, + ui: [], + }; + + const feature = new Feature({ + id: 'my-feature', + name: 'my-feature', + app: [], + privileges: { + all: privilege, + read: privilege, + }, + }); + + expect(alertingFeaturePrivileges.getActions(privilege, feature)).toEqual([]); + }); + + describe(`within feature`, () => { + test('grants `read` privileges under feature consumer', () => { + const actions = new Actions(version); + const alertingFeaturePrivileges = new FeaturePrivilegeAlertingBuilder(actions); + + const privilege: FeatureKibanaPrivileges = { + alerting: { + all: [], + read: ['alert-type'], + }, + + savedObject: { + all: [], + read: [], + }, + ui: [], + }; + + const feature = new Feature({ + id: 'my-feature', + name: 'my-feature', + app: [], + privileges: { + all: privilege, + read: privilege, + }, + }); + + expect(alertingFeaturePrivileges.getActions(privilege, feature)).toMatchInlineSnapshot(` + Array [ + "alerting:1.0.0-zeta1:alert-type/my-feature/get", + "alerting:1.0.0-zeta1:alert-type/my-feature/getAlertState", + "alerting:1.0.0-zeta1:alert-type/my-feature/find", + ] + `); + }); + + test('grants `all` privileges under feature consumer', () => { + const actions = new Actions(version); + const alertingFeaturePrivileges = new FeaturePrivilegeAlertingBuilder(actions); + + const privilege: FeatureKibanaPrivileges = { + alerting: { + all: ['alert-type'], + read: [], + }, + + savedObject: { + all: [], + read: [], + }, + ui: [], + }; + + const feature = new Feature({ + id: 'my-feature', + name: 'my-feature', + app: [], + privileges: { + all: privilege, + read: privilege, + }, + }); + + expect(alertingFeaturePrivileges.getActions(privilege, feature)).toMatchInlineSnapshot(` + Array [ + "alerting:1.0.0-zeta1:alert-type/my-feature/get", + "alerting:1.0.0-zeta1:alert-type/my-feature/getAlertState", + "alerting:1.0.0-zeta1:alert-type/my-feature/find", + "alerting:1.0.0-zeta1:alert-type/my-feature/create", + "alerting:1.0.0-zeta1:alert-type/my-feature/delete", + "alerting:1.0.0-zeta1:alert-type/my-feature/update", + "alerting:1.0.0-zeta1:alert-type/my-feature/updateApiKey", + "alerting:1.0.0-zeta1:alert-type/my-feature/enable", + "alerting:1.0.0-zeta1:alert-type/my-feature/disable", + "alerting:1.0.0-zeta1:alert-type/my-feature/muteAll", + "alerting:1.0.0-zeta1:alert-type/my-feature/unmuteAll", + "alerting:1.0.0-zeta1:alert-type/my-feature/muteInstance", + "alerting:1.0.0-zeta1:alert-type/my-feature/unmuteInstance", + ] + `); + }); + + test('grants both `all` and `read` privileges under feature consumer', () => { + const actions = new Actions(version); + const alertingFeaturePrivileges = new FeaturePrivilegeAlertingBuilder(actions); + + const privilege: FeatureKibanaPrivileges = { + alerting: { + all: ['alert-type'], + read: ['readonly-alert-type'], + }, + + savedObject: { + all: [], + read: [], + }, + ui: [], + }; + + const feature = new Feature({ + id: 'my-feature', + name: 'my-feature', + app: [], + privileges: { + all: privilege, + read: privilege, + }, + }); + + expect(alertingFeaturePrivileges.getActions(privilege, feature)).toMatchInlineSnapshot(` + Array [ + "alerting:1.0.0-zeta1:alert-type/my-feature/get", + "alerting:1.0.0-zeta1:alert-type/my-feature/getAlertState", + "alerting:1.0.0-zeta1:alert-type/my-feature/find", + "alerting:1.0.0-zeta1:alert-type/my-feature/create", + "alerting:1.0.0-zeta1:alert-type/my-feature/delete", + "alerting:1.0.0-zeta1:alert-type/my-feature/update", + "alerting:1.0.0-zeta1:alert-type/my-feature/updateApiKey", + "alerting:1.0.0-zeta1:alert-type/my-feature/enable", + "alerting:1.0.0-zeta1:alert-type/my-feature/disable", + "alerting:1.0.0-zeta1:alert-type/my-feature/muteAll", + "alerting:1.0.0-zeta1:alert-type/my-feature/unmuteAll", + "alerting:1.0.0-zeta1:alert-type/my-feature/muteInstance", + "alerting:1.0.0-zeta1:alert-type/my-feature/unmuteInstance", + "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/get", + "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/getAlertState", + "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/find", + ] + `); + }); + }); + }); +}); diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.ts new file mode 100644 index 000000000000..42dd7794ba18 --- /dev/null +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.ts @@ -0,0 +1,42 @@ +/* + * 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 { uniq } from 'lodash'; +import { Feature, FeatureKibanaPrivileges } from '../../../../../features/server'; +import { BaseFeaturePrivilegeBuilder } from './feature_privilege_builder'; + +const readOperations: string[] = ['get', 'getAlertState', 'find']; +const writeOperations: string[] = [ + 'create', + 'delete', + 'update', + 'updateApiKey', + 'enable', + 'disable', + 'muteAll', + 'unmuteAll', + 'muteInstance', + 'unmuteInstance', +]; +const allOperations: string[] = [...readOperations, ...writeOperations]; + +export class FeaturePrivilegeAlertingBuilder extends BaseFeaturePrivilegeBuilder { + public getActions(privilegeDefinition: FeatureKibanaPrivileges, feature: Feature): string[] { + const getAlertingPrivilege = ( + operations: string[], + privilegedTypes: readonly string[], + consumer: string + ) => + privilegedTypes.flatMap((type) => + operations.map((operation) => this.actions.alerting.get(type, consumer, operation)) + ); + + return uniq([ + ...getAlertingPrivilege(allOperations, privilegeDefinition.alerting?.all ?? [], feature.id), + ...getAlertingPrivilege(readOperations, privilegeDefinition.alerting?.read ?? [], feature.id), + ]); + } +} diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/index.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/index.ts index 3d6dfbdac025..76b664cbbe2a 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/index.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/index.ts @@ -14,6 +14,7 @@ import { FeaturePrivilegeBuilder } from './feature_privilege_builder'; import { FeaturePrivilegeManagementBuilder } from './management'; import { FeaturePrivilegeNavlinkBuilder } from './navlink'; import { FeaturePrivilegeSavedObjectBuilder } from './saved_object'; +import { FeaturePrivilegeAlertingBuilder } from './alerting'; import { FeaturePrivilegeUIBuilder } from './ui'; export { FeaturePrivilegeBuilder }; @@ -26,6 +27,7 @@ export const featurePrivilegeBuilderFactory = (actions: Actions): FeaturePrivile new FeaturePrivilegeNavlinkBuilder(actions), new FeaturePrivilegeSavedObjectBuilder(actions), new FeaturePrivilegeUIBuilder(actions), + new FeaturePrivilegeAlertingBuilder(actions), ]; return { diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/feature_privilege_iterator.test.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/feature_privilege_iterator.test.ts index 485783253d29..bb1f0c33fdee 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/feature_privilege_iterator.test.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/feature_privilege_iterator.test.ts @@ -41,6 +41,10 @@ describe('featurePrivilegeIterator', () => { all: ['all-type'], read: ['read-type'], }, + alerting: { + all: ['alerting-all-type'], + read: ['alerting-read-type'], + }, ui: ['ui-action'], }, read: { @@ -54,6 +58,9 @@ describe('featurePrivilegeIterator', () => { all: [], read: ['read-type'], }, + alerting: { + read: ['alerting-read-type'], + }, ui: ['ui-action'], }, }, @@ -80,6 +87,10 @@ describe('featurePrivilegeIterator', () => { all: ['all-type'], read: ['read-type'], }, + alerting: { + all: ['alerting-all-type'], + read: ['alerting-read-type'], + }, ui: ['ui-action'], }, }, @@ -96,6 +107,9 @@ describe('featurePrivilegeIterator', () => { all: [], read: ['read-type'], }, + alerting: { + read: ['alerting-read-type'], + }, ui: ['ui-action'], }, }, @@ -118,6 +132,10 @@ describe('featurePrivilegeIterator', () => { all: ['all-type'], read: ['read-type'], }, + alerting: { + all: ['alerting-all-type'], + read: ['alerting-read-type'], + }, ui: ['ui-action'], }, read: { @@ -131,6 +149,9 @@ describe('featurePrivilegeIterator', () => { all: [], read: ['read-type'], }, + alerting: { + read: ['alerting-read-type'], + }, ui: ['ui-action'], }, }, @@ -158,6 +179,10 @@ describe('featurePrivilegeIterator', () => { all: ['all-type'], read: ['read-type'], }, + alerting: { + all: ['alerting-all-type'], + read: ['alerting-read-type'], + }, ui: ['ui-action'], }, }, @@ -181,6 +206,10 @@ describe('featurePrivilegeIterator', () => { all: ['all-type'], read: ['read-type'], }, + alerting: { + all: ['alerting-all-type'], + read: ['alerting-read-type'], + }, ui: ['ui-action'], }, read: { @@ -194,6 +223,9 @@ describe('featurePrivilegeIterator', () => { all: [], read: ['read-type'], }, + alerting: { + read: ['alerting-read-type'], + }, ui: ['ui-action'], }, }, @@ -218,6 +250,10 @@ describe('featurePrivilegeIterator', () => { all: ['all-sub-type'], read: ['read-sub-type'], }, + alerting: { + all: ['alerting-all-sub-type'], + read: ['alerting-read-sub-type'], + }, ui: ['ui-sub-type'], }, ], @@ -247,6 +283,10 @@ describe('featurePrivilegeIterator', () => { all: ['all-type'], read: ['read-type'], }, + alerting: { + all: ['alerting-all-type'], + read: ['alerting-read-type'], + }, ui: ['ui-action'], }, }, @@ -263,6 +303,9 @@ describe('featurePrivilegeIterator', () => { all: [], read: ['read-type'], }, + alerting: { + read: ['alerting-read-type'], + }, ui: ['ui-action'], }, }, @@ -286,6 +329,10 @@ describe('featurePrivilegeIterator', () => { all: ['all-type'], read: ['read-type'], }, + alerting: { + all: ['alerting-all-type'], + read: ['alerting-read-type'], + }, ui: ['ui-action'], }, read: { @@ -299,6 +346,9 @@ describe('featurePrivilegeIterator', () => { all: [], read: ['read-type'], }, + alerting: { + read: ['alerting-read-type'], + }, ui: ['ui-action'], }, }, @@ -323,6 +373,10 @@ describe('featurePrivilegeIterator', () => { all: ['all-sub-type'], read: ['read-sub-type'], }, + alerting: { + all: ['alerting-all-sub-type'], + read: ['alerting-read-sub-type'], + }, ui: ['ui-sub-type'], }, ], @@ -352,6 +406,10 @@ describe('featurePrivilegeIterator', () => { all: ['all-type'], read: ['read-type'], }, + alerting: { + all: ['alerting-all-type'], + read: ['alerting-read-type'], + }, ui: ['ui-action'], }, }, @@ -368,6 +426,9 @@ describe('featurePrivilegeIterator', () => { all: [], read: ['read-type'], }, + alerting: { + read: ['alerting-read-type'], + }, ui: ['ui-action'], }, }, @@ -391,6 +452,10 @@ describe('featurePrivilegeIterator', () => { all: ['all-type'], read: ['read-type'], }, + alerting: { + all: ['alerting-all-type'], + read: ['alerting-read-type'], + }, ui: ['ui-action'], }, read: { @@ -404,6 +469,9 @@ describe('featurePrivilegeIterator', () => { all: [], read: ['read-type'], }, + alerting: { + read: ['alerting-read-type'], + }, ui: ['ui-action'], }, }, @@ -429,6 +497,10 @@ describe('featurePrivilegeIterator', () => { all: ['all-sub-type'], read: ['read-sub-type'], }, + alerting: { + all: ['alerting-all-sub-type'], + read: ['alerting-read-sub-type'], + }, ui: ['ui-sub-type'], }, ], @@ -459,6 +531,10 @@ describe('featurePrivilegeIterator', () => { all: ['all-type', 'all-sub-type'], read: ['read-type', 'read-sub-type'], }, + alerting: { + all: ['alerting-all-type', 'alerting-all-sub-type'], + read: ['alerting-read-type', 'alerting-read-sub-type'], + }, ui: ['ui-action', 'ui-sub-type'], }, }, @@ -476,6 +552,10 @@ describe('featurePrivilegeIterator', () => { all: ['all-sub-type'], read: ['read-type', 'read-sub-type'], }, + alerting: { + all: ['alerting-all-sub-type'], + read: ['alerting-read-type', 'alerting-read-sub-type'], + }, ui: ['ui-action', 'ui-sub-type'], }, }, @@ -499,6 +579,10 @@ describe('featurePrivilegeIterator', () => { all: ['all-type'], read: ['read-type'], }, + alerting: { + all: ['alerting-all-type'], + read: ['alerting-read-type'], + }, ui: ['ui-action'], }, read: { @@ -512,6 +596,9 @@ describe('featurePrivilegeIterator', () => { all: [], read: ['read-type'], }, + alerting: { + read: ['alerting-read-type'], + }, ui: ['ui-action'], }, }, @@ -536,6 +623,9 @@ describe('featurePrivilegeIterator', () => { all: [], read: ['read-type'], }, + alerting: { + read: ['alerting-read-type'], + }, ui: ['ui-action'], }, ], @@ -565,6 +655,10 @@ describe('featurePrivilegeIterator', () => { all: ['all-type'], read: ['read-type'], }, + alerting: { + all: ['alerting-all-type'], + read: ['alerting-read-type'], + }, ui: ['ui-action'], }, }, @@ -581,6 +675,10 @@ describe('featurePrivilegeIterator', () => { all: [], read: ['read-type'], }, + alerting: { + all: [], + read: ['alerting-read-type'], + }, ui: ['ui-action'], }, }, @@ -604,6 +702,10 @@ describe('featurePrivilegeIterator', () => { all: ['all-type'], read: ['read-type'], }, + alerting: { + all: ['alerting-all-type'], + read: ['alerting-read-type'], + }, ui: ['ui-action'], }, read: { @@ -617,6 +719,9 @@ describe('featurePrivilegeIterator', () => { all: [], read: ['read-type'], }, + alerting: { + read: ['alerting-read-type'], + }, ui: ['ui-action'], }, }, @@ -642,6 +747,10 @@ describe('featurePrivilegeIterator', () => { all: ['all-sub-type'], read: ['read-sub-type'], }, + alerting: { + all: ['alerting-all-sub-type'], + read: ['alerting-read-sub-type'], + }, ui: ['ui-sub-type'], }, ], @@ -672,6 +781,10 @@ describe('featurePrivilegeIterator', () => { all: ['all-type', 'all-sub-type'], read: ['read-type', 'read-sub-type'], }, + alerting: { + all: ['alerting-all-type', 'alerting-all-sub-type'], + read: ['alerting-read-type', 'alerting-read-sub-type'], + }, ui: ['ui-action', 'ui-sub-type'], }, }, @@ -688,6 +801,9 @@ describe('featurePrivilegeIterator', () => { all: [], read: ['read-type'], }, + alerting: { + read: ['alerting-read-type'], + }, ui: ['ui-action'], }, }, @@ -737,6 +853,10 @@ describe('featurePrivilegeIterator', () => { all: ['all-sub-type'], read: ['read-sub-type'], }, + alerting: { + all: ['alerting-all-sub-type'], + read: ['alerting-read-sub-type'], + }, ui: ['ui-sub-type'], }, ], @@ -767,6 +887,10 @@ describe('featurePrivilegeIterator', () => { all: ['all-sub-type'], read: ['read-sub-type'], }, + alerting: { + all: ['alerting-all-sub-type'], + read: ['alerting-read-sub-type'], + }, ui: ['ui-sub-type'], }, }, @@ -784,6 +908,10 @@ describe('featurePrivilegeIterator', () => { all: ['all-sub-type'], read: ['read-sub-type'], }, + alerting: { + all: ['alerting-all-sub-type'], + read: ['alerting-read-sub-type'], + }, ui: ['ui-sub-type'], }, }, @@ -807,6 +935,10 @@ describe('featurePrivilegeIterator', () => { all: ['all-type'], read: ['read-type'], }, + alerting: { + all: ['alerting-all-type'], + read: ['alerting-read-type'], + }, ui: ['ui-action'], }, read: { @@ -820,6 +952,9 @@ describe('featurePrivilegeIterator', () => { all: [], read: ['read-type'], }, + alerting: { + read: ['alerting-read-type'], + }, ui: ['ui-action'], }, }, @@ -867,6 +1002,10 @@ describe('featurePrivilegeIterator', () => { all: ['all-type'], read: ['read-type'], }, + alerting: { + all: ['alerting-all-type'], + read: ['alerting-read-type'], + }, ui: ['ui-action'], }, }, @@ -883,6 +1022,10 @@ describe('featurePrivilegeIterator', () => { all: [], read: ['read-type'], }, + alerting: { + all: [], + read: ['alerting-read-type'], + }, ui: ['ui-action'], }, }, diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/feature_privilege_iterator.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/feature_privilege_iterator.ts index 029b2e77f781..17c9464b1475 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/feature_privilege_iterator.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_iterator/feature_privilege_iterator.ts @@ -72,6 +72,14 @@ function mergeWithSubFeatures( mergedConfig.savedObject.read, subFeaturePrivilege.savedObject.read ); + + mergedConfig.alerting = { + all: mergeArrays(mergedConfig.alerting?.all ?? [], subFeaturePrivilege.alerting?.all ?? []), + read: mergeArrays( + mergedConfig.alerting?.read ?? [], + subFeaturePrivilege.alerting?.read ?? [] + ), + }; } return mergedConfig; } diff --git a/x-pack/plugins/security/server/authorization/privileges/privileges.ts b/x-pack/plugins/security/server/authorization/privileges/privileges.ts index f9ee5fc75012..5d8ef3f376ca 100644 --- a/x-pack/plugins/security/server/authorization/privileges/privileges.ts +++ b/x-pack/plugins/security/server/authorization/privileges/privileges.ts @@ -90,7 +90,6 @@ export function privilegesFactory( delete featurePrivileges[feature.id]; } } - return { features: featurePrivileges, global: { diff --git a/x-pack/plugins/security/server/mocks.ts b/x-pack/plugins/security/server/mocks.ts index c2d99433b034..4ce0ec6e3c10 100644 --- a/x-pack/plugins/security/server/mocks.ts +++ b/x-pack/plugins/security/server/mocks.ts @@ -17,6 +17,7 @@ function createSetupMock() { authz: { actions: mockAuthz.actions, checkPrivilegesWithRequest: mockAuthz.checkPrivilegesWithRequest, + checkPrivilegesDynamicallyWithRequest: mockAuthz.checkPrivilegesDynamicallyWithRequest, mode: mockAuthz.mode, }, registerSpacesService: jest.fn(), diff --git a/x-pack/plugins/security/server/plugin.test.ts b/x-pack/plugins/security/server/plugin.test.ts index 243bad0ec3e7..db015d246f59 100644 --- a/x-pack/plugins/security/server/plugin.test.ts +++ b/x-pack/plugins/security/server/plugin.test.ts @@ -69,6 +69,9 @@ describe('Security Plugin', () => { }, "authz": Object { "actions": Actions { + "alerting": AlertingActions { + "prefix": "alerting:version:", + }, "api": ApiActions { "prefix": "api:version:", }, @@ -88,6 +91,7 @@ describe('Security Plugin', () => { "version": "version:version", "versionNumber": "version", }, + "checkPrivilegesDynamicallyWithRequest": [Function], "checkPrivilegesWithRequest": [Function], "mode": Object { "useRbacForRequest": [Function], diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts index c4b16c9eec87..1753eb7b62ed 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -51,7 +51,10 @@ export interface SecurityPluginSetup { | 'grantAPIKeyAsInternalUser' | 'invalidateAPIKeyAsInternalUser' >; - authz: Pick; + authz: Pick< + AuthorizationServiceSetup, + 'actions' | 'checkPrivilegesDynamicallyWithRequest' | 'checkPrivilegesWithRequest' | 'mode' + >; license: SecurityLicense; audit: Pick; @@ -206,6 +209,7 @@ export class Plugin { authz: { actions: authz.actions, checkPrivilegesWithRequest: authz.checkPrivilegesWithRequest, + checkPrivilegesDynamicallyWithRequest: authz.checkPrivilegesDynamicallyWithRequest, mode: authz.mode, }, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/create_notifications.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/create_notifications.ts index a472d8a4df4a..8f6826cec536 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/create_notifications.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/notifications/create_notifications.ts @@ -5,7 +5,7 @@ */ import { Alert } from '../../../../../alerts/common'; -import { APP_ID, NOTIFICATIONS_ID } from '../../../../common/constants'; +import { SERVER_APP_ID, NOTIFICATIONS_ID } from '../../../../common/constants'; import { CreateNotificationParams } from './types'; import { addTags } from './add_tags'; import { transformRuleToAlertAction } from '../../../../common/detection_engine/transform_actions'; @@ -23,7 +23,7 @@ export const createNotifications = async ({ name, tags: addTags([], ruleAlertId), alertTypeId: NOTIFICATIONS_ID, - consumer: APP_ID, + consumer: SERVER_APP_ID, params: { ruleAlertId, }, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.ts index ad4038b05dbd..0c67d9ca7714 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.ts @@ -6,7 +6,7 @@ import { transformRuleToAlertAction } from '../../../../common/detection_engine/transform_actions'; import { Alert } from '../../../../../alerts/common'; -import { APP_ID, SIGNALS_ID } from '../../../../common/constants'; +import { SERVER_APP_ID, SIGNALS_ID } from '../../../../common/constants'; import { CreateRulesOptions } from './types'; import { addTags } from './add_tags'; @@ -57,7 +57,7 @@ export const createRules = async ({ name, tags: addTags(tags, ruleId, immutable), alertTypeId: SIGNALS_ID, - consumer: APP_ID, + consumer: SERVER_APP_ID, params: { anomalyThreshold, author, diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index 611f35c6d402..06cd3138ca56 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -40,7 +40,14 @@ import { initSavedObjects, savedObjectTypes } from './saved_objects'; import { AppClientFactory } from './client'; import { createConfig$, ConfigType } from './config'; import { initUiSettings } from './ui_settings'; -import { APP_ID, APP_ICON, SERVER_APP_ID, SecurityPageName } from '../common/constants'; +import { + APP_ID, + APP_ICON, + SERVER_APP_ID, + SecurityPageName, + SIGNALS_ID, + NOTIFICATIONS_ID, +} from '../common/constants'; import { registerEndpointRoutes } from './endpoint/routes/metadata'; import { registerLimitedConcurrencyRoutes } from './endpoint/routes/limited_concurrency'; import { registerResolverRoutes } from './endpoint/routes/resolver'; @@ -167,23 +174,15 @@ export class Plugin implements IPlugin { editActionConfig={() => {}} editActionSecrets={() => {}} docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} + readOnly={false} /> ); expect(wrapper.find('[data-test-subj="emailFromInput"]').length > 0).toBeTruthy(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_connector.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_connector.tsx index 8a15320d5de1..4ef9c2e0d4d2 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_connector.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email/email_connector.tsx @@ -21,7 +21,7 @@ import { EmailActionConnector } from '../types'; export const EmailActionConnectorFields: React.FunctionComponent> = ({ action, editActionConfig, editActionSecrets, errors, docLinks }) => { +>> = ({ action, editActionConfig, editActionSecrets, errors, readOnly, docLinks }) => { const { from, host, port, secure } = action.config; const { user, password } = action.secrets; @@ -54,6 +54,7 @@ export const EmailActionConnectorFields: React.FunctionComponent 0 && from !== undefined} name="from" value={from || ''} @@ -86,6 +87,7 @@ export const EmailActionConnectorFields: React.FunctionComponent 0 && host !== undefined} name="host" value={host || ''} @@ -121,6 +123,7 @@ export const EmailActionConnectorFields: React.FunctionComponent 0 && port !== undefined} fullWidth + readOnly={readOnly} name="port" value={port || ''} data-test-subj="emailPortInput" @@ -145,6 +148,7 @@ export const EmailActionConnectorFields: React.FunctionComponent { editActionConfig('secure', e.target.checked); @@ -174,6 +178,7 @@ export const EmailActionConnectorFields: React.FunctionComponent 0} name="user" + readOnly={readOnly} value={user || ''} data-test-subj="emailUserInput" onChange={(e) => { @@ -197,6 +202,7 @@ export const EmailActionConnectorFields: React.FunctionComponent 0} name="password" value={password || ''} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.test.tsx index 4cb397927b53..f5f14cb04133 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.test.tsx @@ -88,6 +88,7 @@ describe('IndexActionConnectorFields renders', () => { editActionSecrets={() => {}} http={deps!.http} docLinks={deps!.docLinks} + readOnly={false} /> ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.tsx index 6fb078f3c808..9cfb9f1dc25b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.tsx @@ -29,7 +29,7 @@ import { const IndexActionConnectorFields: React.FunctionComponent> = ({ action, editActionConfig, errors, http, docLinks }) => { +>> = ({ action, editActionConfig, errors, http, readOnly, docLinks }) => { const { index, refresh, executionTimeField } = action.config; const [hasTimeFieldCheckbox, setTimeFieldCheckboxState] = useState( executionTimeField != null @@ -115,6 +115,7 @@ const IndexActionConnectorFields: React.FunctionComponent { editActionConfig('index', selected.length > 0 ? selected[0].value : ''); const indices = selected.map((s) => s.value as string); @@ -145,6 +146,7 @@ const IndexActionConnectorFields: React.FunctionComponent { editActionConfig('refresh', e.target.checked); }} @@ -172,6 +174,7 @@ const IndexActionConnectorFields: React.FunctionComponent { setTimeFieldCheckboxState(!hasTimeFieldCheckbox); // if changing from checked to not checked (hasTimeField === true), diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.test.tsx index 86730c0ab4ac..53e68e645369 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.test.tsx @@ -34,6 +34,7 @@ describe('PagerDutyActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} docLinks={deps!.docLinks} + readOnly={false} /> ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.tsx index 48da3f1778b4..6399e1f80984 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_connectors.tsx @@ -12,7 +12,7 @@ import { PagerDutyActionConnector } from '.././types'; const PagerDutyActionConnectorFields: React.FunctionComponent> = ({ errors, action, editActionConfig, editActionSecrets, docLinks }) => { +>> = ({ errors, action, editActionConfig, editActionSecrets, docLinks, readOnly }) => { const { apiUrl } = action.config; const { routingKey } = action.secrets; return ( @@ -31,6 +31,7 @@ const PagerDutyActionConnectorFields: React.FunctionComponent) => { editActionConfig('apiUrl', e.target.value); @@ -69,6 +70,7 @@ const PagerDutyActionConnectorFields: React.FunctionComponent 0 && routingKey !== undefined} name="routingKey" + readOnly={readOnly} value={routingKey || ''} data-test-subj="pagerdutyRoutingKeyInput" onChange={(e: React.ChangeEvent) => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.test.tsx index 25381614a6c0..216e6967833b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.test.tsx @@ -34,6 +34,7 @@ describe('ServiceNowActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} docLinks={deps!.docLinks} + readOnly={false} /> ); expect( @@ -72,6 +73,7 @@ describe('ServiceNowActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} docLinks={deps!.docLinks} + readOnly={false} consumer={'case'} /> ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx index 139ef8fa58ff..f99a276305d7 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_connectors.tsx @@ -25,7 +25,7 @@ import { FieldMapping } from './case_mappings/field_mapping'; const ServiceNowConnectorFields: React.FC> = ({ action, editActionSecrets, editActionConfig, errors, consumer, docLinks }) => { +>> = ({ action, editActionSecrets, editActionConfig, errors, consumer, readOnly, docLinks }) => { // TODO: remove incidentConfiguration later, when Case ServiceNow will move their fields to the level of action execution const { apiUrl, incidentConfiguration, isCaseOwned } = action.config; const mapping = incidentConfiguration ? incidentConfiguration.mapping : []; @@ -97,6 +97,7 @@ const ServiceNowConnectorFields: React.FC { editActionConfig={() => {}} editActionSecrets={() => {}} docLinks={deps!.docLinks} + readOnly={false} /> ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.tsx index b6efd9fa9326..aa3a1932eacd 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.tsx @@ -12,7 +12,7 @@ import { SlackActionConnector } from '../types'; const SlackActionFields: React.FunctionComponent> = ({ action, editActionSecrets, errors, docLinks }) => { +>> = ({ action, editActionSecrets, errors, readOnly, docLinks }) => { const { webhookUrl } = action.secrets; return ( @@ -44,6 +44,7 @@ const SlackActionFields: React.FunctionComponent 0 && webhookUrl !== undefined} name="webhookUrl" + readOnly={readOnly} placeholder="Example: https://hooks.slack.com/services" value={webhookUrl || ''} data-test-subj="slackWebhookUrlInput" diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.test.tsx index 3a2afff03c58..7f2bed6c41f3 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.test.tsx @@ -33,6 +33,7 @@ describe('WebhookActionConnectorFields renders', () => { editActionConfig={() => {}} editActionSecrets={() => {}} docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} + readOnly={false} /> ); expect(wrapper.find('[data-test-subj="webhookViewHeadersSwitch"]').length > 0).toBeTruthy(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.tsx index 2321d5b4b547..52160441adb5 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_connectors.tsx @@ -30,7 +30,7 @@ const HTTP_VERBS = ['post', 'put']; const WebhookActionConnectorFields: React.FunctionComponent> = ({ action, editActionConfig, editActionSecrets, errors }) => { +>> = ({ action, editActionConfig, editActionSecrets, errors, readOnly }) => { const { user, password } = action.secrets; const { method, url, headers } = action.config; @@ -128,6 +128,7 @@ const WebhookActionConnectorFields: React.FunctionComponent { @@ -153,6 +154,7 @@ const WebhookActionConnectorFields: React.FunctionComponent { @@ -222,6 +224,7 @@ const WebhookActionConnectorFields: React.FunctionComponent ({ text: verb.toUpperCase(), value: verb }))} onChange={(e) => { @@ -247,6 +250,7 @@ const WebhookActionConnectorFields: React.FunctionComponent 0 && url !== undefined} fullWidth + readOnly={readOnly} value={url || ''} placeholder="https:// or http://" data-test-subj="webhookUrlText" @@ -280,6 +284,7 @@ const WebhookActionConnectorFields: React.FunctionComponent 0 && user !== undefined} name="user" + readOnly={readOnly} value={user || ''} data-test-subj="webhookUserInput" onChange={(e) => { @@ -309,6 +314,7 @@ const WebhookActionConnectorFields: React.FunctionComponent 0 && password !== undefined} value={password || ''} data-test-subj="webhookPasswordInput" @@ -328,6 +334,7 @@ const WebhookActionConnectorFields: React.FunctionComponent jest.resetAllMocks()); @@ -183,6 +184,7 @@ function getAlertType(actionVariables: ActionVariables): AlertType { actionVariables, actionGroups: [{ id: 'default', name: 'Default' }], defaultActionGroupId: 'default', - producer: 'alerting', + authorizedConsumers: {}, + producer: ALERTS_FEATURE_ID, }; } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.test.ts index 94d9166b4090..23caf2cfb31a 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.test.ts @@ -27,6 +27,7 @@ import { health, } from './alert_api'; import uuid from 'uuid'; +import { ALERTS_FEATURE_ID } from '../../../../alerts/common'; const http = httpServiceMock.createStartContract(); @@ -42,9 +43,10 @@ describe('loadAlertTypes', () => { context: [{ name: 'var1', description: 'val1' }], state: [{ name: 'var2', description: 'val2' }], }, - producer: 'alerting', + producer: ALERTS_FEATURE_ID, actionGroups: [{ id: 'default', name: 'Default' }], defaultActionGroupId: 'default', + authorizedConsumers: {}, }, ]; http.get.mockResolvedValueOnce(resolvedValue); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/capabilities.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/capabilities.ts index 82d03be41e1a..065a782ee96a 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/capabilities.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/capabilities.ts @@ -4,6 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ +import { BUILT_IN_ALERTS_FEATURE_ID } from '../../../../alerting_builtins/common'; +import { Alert, AlertType } from '../../types'; + /** * NOTE: Applications that want to show the alerting UIs will need to add * check against their features here until we have a better solution. This @@ -12,7 +15,7 @@ type Capabilities = Record; -const apps = ['apm', 'siem', 'uptime', 'infrastructure']; +const apps = ['apm', 'siem', 'uptime', 'infrastructure', 'actions', BUILT_IN_ALERTS_FEATURE_ID]; function hasCapability(capabilities: Capabilities, capability: string) { return apps.some((app) => capabilities[app]?.[capability]); @@ -23,8 +26,17 @@ function createCapabilityCheck(capability: string) { } export const hasShowAlertsCapability = createCapabilityCheck('alerting:show'); -export const hasShowActionsCapability = createCapabilityCheck('actions:show'); -export const hasSaveAlertsCapability = createCapabilityCheck('alerting:save'); -export const hasSaveActionsCapability = createCapabilityCheck('actions:save'); -export const hasDeleteAlertsCapability = createCapabilityCheck('alerting:delete'); -export const hasDeleteActionsCapability = createCapabilityCheck('actions:delete'); + +export const hasShowActionsCapability = (capabilities: Capabilities) => capabilities?.actions?.show; +export const hasSaveActionsCapability = (capabilities: Capabilities) => capabilities?.actions?.save; +export const hasExecuteActionsCapability = (capabilities: Capabilities) => + capabilities?.actions?.execute; +export const hasDeleteActionsCapability = (capabilities: Capabilities) => + capabilities?.actions?.delete; + +export function hasAllPrivilege(alert: Alert, alertType?: AlertType): boolean { + return alertType?.authorizedConsumers[alert.consumer]?.all ?? false; +} +export function hasReadPrivilege(alert: Alert, alertType?: AlertType): boolean { + return alertType?.authorizedConsumers[alert.consumer]?.read ?? false; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.test.tsx index 17a1d929a0de..b7c9865cbd9d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.test.tsx @@ -15,10 +15,16 @@ describe('action_connector_form', () => { let deps: any; beforeAll(async () => { const mocks = coreMock.createSetup(); + const [ + { + application: { capabilities }, + }, + ] = await mocks.getStartServices(); deps = { http: mocks.http, actionTypeRegistry: actionTypeRegistry as any, docLinks: { ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' }, + capabilities, }; }); @@ -56,6 +62,7 @@ describe('action_connector_form', () => { http={deps!.http} actionTypeRegistry={deps!.actionTypeRegistry} docLinks={deps!.docLinks} + capabilities={deps!.capabilities} /> ); } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.tsx index 794f5548c4b4..ed4edb0229c2 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_connector_form.tsx @@ -18,10 +18,11 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { HttpSetup, DocLinksStart } from 'kibana/public'; +import { HttpSetup, ApplicationStart, DocLinksStart } from 'kibana/public'; import { ReducerAction } from './connector_reducer'; import { ActionConnector, IErrorObject, ActionTypeModel } from '../../../types'; import { TypeRegistry } from '../../type_registry'; +import { hasSaveActionsCapability } from '../../lib/capabilities'; export function validateBaseProperties(actionObject: ActionConnector) { const validationResult = { errors: {} }; @@ -53,6 +54,7 @@ interface ActionConnectorProps { http: HttpSetup; actionTypeRegistry: TypeRegistry; docLinks: DocLinksStart; + capabilities: ApplicationStart['capabilities']; consumer?: string; } @@ -65,8 +67,11 @@ export const ActionConnectorForm = ({ http, actionTypeRegistry, docLinks, + capabilities, consumer, }: ActionConnectorProps) => { + const canSave = hasSaveActionsCapability(capabilities); + const setActionProperty = (key: string, value: any) => { dispatch({ command: { type: 'setProperty' }, payload: { key, value } }); }; @@ -138,6 +143,7 @@ export const ActionConnectorForm = ({ > 0 && connector.name !== undefined} name="name" placeholder="Untitled" @@ -167,6 +173,7 @@ export const ActionConnectorForm = ({ { + const canSave = hasSaveActionsCapability(capabilities); + const [addModalVisible, setAddModalVisibility] = useState(false); const [activeActionItem, setActiveActionItem] = useState( undefined @@ -254,6 +257,7 @@ export const ActionForm = ({ /> } labelAppend={ + canSave && actionTypesIndex && actionTypesIndex[actionConnector.actionTypeId].enabledInConfig ? ( + ); return ( - actionItem.id === emptyId) ? ( - actionItem.id === emptyId) ? ( + noConnectorsLabel + ) : ( + + ) + } + actions={[ + { + setActiveActionItem({ actionTypeId: actionItem.actionTypeId, index }); + setAddModalVisibility(true); }} - /> - ) : ( - - ) - } - actions={[ - { - setActiveActionItem({ actionTypeId: actionItem.actionTypeId, index }); - setAddModalVisibility(true); - }} - > + > + + , + ]} + /> + ) : ( + +

- , - ]} - /> +

+
+ )}
diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx index 60ec0cfa6955..19ce653e465f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_flyout.tsx @@ -118,6 +118,7 @@ export const ConnectorAddFlyout = ({ actionTypeRegistry={actionTypeRegistry} http={http} docLinks={docLinks} + capabilities={capabilities} consumer={consumer} /> ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.test.tsx index 1b35b5636872..3d621367fc40 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.test.tsx @@ -26,10 +26,10 @@ describe('connector_add_modal', () => { http: mocks.http, capabilities: { ...capabilities, - siem: { - 'actions:show': true, - 'actions:save': true, - 'actions:delete': true, + actions: { + show: true, + save: true, + delete: true, }, }, actionTypeRegistry: actionTypeRegistry as any, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx index 67c836fc12cf..90abb986517d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx @@ -166,6 +166,7 @@ export const ConnectorAddModal = ({ actionTypeRegistry={actionTypeRegistry} docLinks={docLinks} http={http} + capabilities={capabilities} consumer={consumer} /> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx index 52425a616aad..ca75e730062a 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_edit_flyout.tsx @@ -195,6 +195,7 @@ export const ConnectorEditFlyout = ({ actionTypeRegistry={actionTypeRegistry} http={http} docLinks={docLinks} + capabilities={capabilities} consumer={consumer} /> ) : ( diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.test.tsx index 23a7223f9c21..c96e62df71ce 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.test.tsx @@ -61,10 +61,10 @@ describe('actions_connectors_list component empty', () => { navigateToApp, capabilities: { ...capabilities, - siem: { - 'actions:show': true, - 'actions:save': true, - 'actions:delete': true, + actions: { + show: true, + save: true, + delete: true, }, }, history: scopedHistoryMock.create(), @@ -168,10 +168,10 @@ describe('actions_connectors_list component with items', () => { navigateToApp, capabilities: { ...capabilities, - securitySolution: { - 'actions:show': true, - 'actions:save': true, - 'actions:delete': true, + actions: { + show: true, + save: true, + delete: true, }, }, history: scopedHistoryMock.create(), @@ -256,10 +256,10 @@ describe('actions_connectors_list component empty with show only capability', () navigateToApp, capabilities: { ...capabilities, - securitySolution: { - 'actions:show': true, - 'actions:save': false, - 'actions:delete': false, + actions: { + show: true, + save: false, + delete: false, }, }, history: scopedHistoryMock.create(), @@ -287,7 +287,7 @@ describe('actions_connectors_list component empty with show only capability', () it('renders no permissions to create connector', async () => { await setup(); - expect(wrapper.find('[defaultMessage="No permissions to create connector"]')).toHaveLength(1); + expect(wrapper.find('[defaultMessage="No permissions to create connectors"]')).toHaveLength(1); expect(wrapper.find('[data-test-subj="createActionButton"]')).toHaveLength(0); }); }); @@ -345,10 +345,10 @@ describe('actions_connectors_list with show only capability', () => { navigateToApp, capabilities: { ...capabilities, - securitySolution: { - 'actions:show': true, - 'actions:save': false, - 'actions:delete': false, + actions: { + show: true, + save: false, + delete: false, }, }, history: scopedHistoryMock.create(), @@ -446,10 +446,10 @@ describe('actions_connectors_list component with disabled items', () => { navigateToApp, capabilities: { ...capabilities, - securitySolution: { - 'actions:show': true, - 'actions:save': true, - 'actions:delete': true, + actions: { + show: true, + save: true, + delete: true, }, }, history: scopedHistoryMock.create(), diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx index 5d52896cc628..837529bfc938 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx @@ -17,6 +17,7 @@ import { EuiBetaBadge, EuiToolTip, EuiButtonIcon, + EuiEmptyPrompt, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -324,30 +325,45 @@ export const ActionsConnectorsList: React.FunctionComponent = () => { />
, ], - toolsRight: [ - setAddFlyoutVisibility(true)} - > - - , - ], + toolsRight: canSave + ? [ + setAddFlyoutVisibility(true)} + > + + , + ] + : [], }} /> ); const noPermissionPrompt = ( -

- -

+ + + + } + body={ +

+ +

+ } + /> ); return ( diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx index d8f0d0b6b20a..ccaa180de0ed 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx @@ -20,6 +20,8 @@ import { i18n } from '@kbn/i18n'; import { ViewInApp } from './view_in_app'; import { PLUGIN } from '../../../constants/plugin'; import { coreMock } from 'src/core/public/mocks'; +import { ALERTS_FEATURE_ID } from '../../../../../../alerts/common'; + const mockes = coreMock.createSetup(); jest.mock('../../../app_context', () => ({ @@ -29,8 +31,6 @@ jest.mock('../../../app_context', () => ({ get: jest.fn(() => ({})), securitySolution: { 'alerting:show': true, - 'alerting:save': true, - 'alerting:delete': true, }, }, actionTypeRegistry: jest.fn(), @@ -66,7 +66,9 @@ jest.mock('react-router-dom', () => ({ })); jest.mock('../../../lib/capabilities', () => ({ + hasAllPrivilege: jest.fn(() => true), hasSaveAlertsCapability: jest.fn(() => true), + hasExecuteActionsCapability: jest.fn(() => true), })); const mockAlertApis = { @@ -77,6 +79,10 @@ const mockAlertApis = { requestRefresh: jest.fn(), }; +const authorizedConsumers = { + [ALERTS_FEATURE_ID]: { read: true, all: true }, +}; + // const AlertDetails = withBulkAlertOperations(RawAlertDetails); describe('alert_details', () => { // mock Api handlers @@ -89,7 +95,8 @@ describe('alert_details', () => { actionGroups: [{ id: 'default', name: 'Default' }], actionVariables: { context: [], state: [] }, defaultActionGroupId: 'default', - producer: 'alerting', + producer: ALERTS_FEATURE_ID, + authorizedConsumers, }; expect( @@ -127,7 +134,8 @@ describe('alert_details', () => { actionGroups: [{ id: 'default', name: 'Default' }], actionVariables: { context: [], state: [] }, defaultActionGroupId: 'default', - producer: 'alerting', + producer: ALERTS_FEATURE_ID, + authorizedConsumers, }; expect( @@ -156,7 +164,8 @@ describe('alert_details', () => { actionGroups: [{ id: 'default', name: 'Default' }], actionVariables: { context: [], state: [] }, defaultActionGroupId: 'default', - producer: 'alerting', + producer: ALERTS_FEATURE_ID, + authorizedConsumers, }; const actionTypes: ActionType[] = [ @@ -209,7 +218,8 @@ describe('alert_details', () => { actionGroups: [{ id: 'default', name: 'Default' }], actionVariables: { context: [], state: [] }, defaultActionGroupId: 'default', - producer: 'alerting', + producer: ALERTS_FEATURE_ID, + authorizedConsumers, }; const actionTypes: ActionType[] = [ { @@ -267,7 +277,8 @@ describe('alert_details', () => { actionGroups: [{ id: 'default', name: 'Default' }], actionVariables: { context: [], state: [] }, defaultActionGroupId: 'default', - producer: 'alerting', + producer: ALERTS_FEATURE_ID, + authorizedConsumers, }; expect( @@ -286,7 +297,8 @@ describe('alert_details', () => { actionGroups: [{ id: 'default', name: 'Default' }], actionVariables: { context: [], state: [] }, defaultActionGroupId: 'default', - producer: 'alerting', + producer: ALERTS_FEATURE_ID, + authorizedConsumers, }; expect( @@ -314,7 +326,8 @@ describe('disable button', () => { actionGroups: [{ id: 'default', name: 'Default' }], actionVariables: { context: [], state: [] }, defaultActionGroupId: 'default', - producer: 'alerting', + producer: ALERTS_FEATURE_ID, + authorizedConsumers, }; const enableButton = shallow( @@ -341,7 +354,8 @@ describe('disable button', () => { actionGroups: [{ id: 'default', name: 'Default' }], actionVariables: { context: [], state: [] }, defaultActionGroupId: 'default', - producer: 'alerting', + producer: ALERTS_FEATURE_ID, + authorizedConsumers, }; const enableButton = shallow( @@ -368,7 +382,8 @@ describe('disable button', () => { actionGroups: [{ id: 'default', name: 'Default' }], actionVariables: { context: [], state: [] }, defaultActionGroupId: 'default', - producer: 'alerting', + producer: ALERTS_FEATURE_ID, + authorizedConsumers, }; const disableAlert = jest.fn(); @@ -404,7 +419,8 @@ describe('disable button', () => { actionGroups: [{ id: 'default', name: 'Default' }], actionVariables: { context: [], state: [] }, defaultActionGroupId: 'default', - producer: 'alerting', + producer: ALERTS_FEATURE_ID, + authorizedConsumers, }; const enableAlert = jest.fn(); @@ -443,7 +459,8 @@ describe('mute button', () => { actionGroups: [{ id: 'default', name: 'Default' }], actionVariables: { context: [], state: [] }, defaultActionGroupId: 'default', - producer: 'alerting', + producer: ALERTS_FEATURE_ID, + authorizedConsumers, }; const enableButton = shallow( @@ -471,7 +488,8 @@ describe('mute button', () => { actionGroups: [{ id: 'default', name: 'Default' }], actionVariables: { context: [], state: [] }, defaultActionGroupId: 'default', - producer: 'alerting', + producer: ALERTS_FEATURE_ID, + authorizedConsumers, }; const enableButton = shallow( @@ -499,7 +517,8 @@ describe('mute button', () => { actionGroups: [{ id: 'default', name: 'Default' }], actionVariables: { context: [], state: [] }, defaultActionGroupId: 'default', - producer: 'alerting', + producer: ALERTS_FEATURE_ID, + authorizedConsumers, }; const muteAlert = jest.fn(); @@ -536,7 +555,8 @@ describe('mute button', () => { actionGroups: [{ id: 'default', name: 'Default' }], actionVariables: { context: [], state: [] }, defaultActionGroupId: 'default', - producer: 'alerting', + producer: ALERTS_FEATURE_ID, + authorizedConsumers, }; const unmuteAlert = jest.fn(); @@ -573,7 +593,8 @@ describe('mute button', () => { actionGroups: [{ id: 'default', name: 'Default' }], actionVariables: { context: [], state: [] }, defaultActionGroupId: 'default', - producer: 'alerting', + producer: ALERTS_FEATURE_ID, + authorizedConsumers, }; const enableButton = shallow( @@ -590,6 +611,136 @@ describe('mute button', () => { }); }); +describe('edit button', () => { + const actionTypes: ActionType[] = [ + { + id: '.server-log', + name: 'Server log', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'basic', + }, + ]; + + it('should render an edit button when alert and actions are editable', () => { + const alert = mockAlert({ + enabled: true, + muteAll: false, + actions: [ + { + group: 'default', + id: uuid.v4(), + params: {}, + actionTypeId: '.server-log', + }, + ], + }); + + const alertType = { + id: '.noop', + name: 'No Op', + actionGroups: [{ id: 'default', name: 'Default' }], + actionVariables: { context: [], state: [] }, + defaultActionGroupId: 'default', + producer: 'alerting', + authorizedConsumers, + }; + + expect( + shallow( + + ) + .find(EuiButtonEmpty) + .find('[name="edit"]') + .first() + .exists() + ).toBeTruthy(); + }); + + it('should not render an edit button when alert editable but actions arent', () => { + const { hasExecuteActionsCapability } = jest.requireMock('../../../lib/capabilities'); + hasExecuteActionsCapability.mockReturnValue(false); + const alert = mockAlert({ + enabled: true, + muteAll: false, + actions: [ + { + group: 'default', + id: uuid.v4(), + params: {}, + actionTypeId: '.server-log', + }, + ], + }); + + const alertType = { + id: '.noop', + name: 'No Op', + actionGroups: [{ id: 'default', name: 'Default' }], + actionVariables: { context: [], state: [] }, + defaultActionGroupId: 'default', + producer: 'alerting', + authorizedConsumers, + }; + + expect( + shallow( + + ) + .find(EuiButtonEmpty) + .find('[name="edit"]') + .first() + .exists() + ).toBeFalsy(); + }); + + it('should render an edit button when alert editable but actions arent when there are no actions on the alert', () => { + const { hasExecuteActionsCapability } = jest.requireMock('../../../lib/capabilities'); + hasExecuteActionsCapability.mockReturnValue(false); + const alert = mockAlert({ + enabled: true, + muteAll: false, + actions: [], + }); + + const alertType = { + id: '.noop', + name: 'No Op', + actionGroups: [{ id: 'default', name: 'Default' }], + actionVariables: { context: [], state: [] }, + defaultActionGroupId: 'default', + producer: 'alerting', + authorizedConsumers, + }; + + expect( + shallow( + + ) + .find(EuiButtonEmpty) + .find('[name="edit"]') + .first() + .exists() + ).toBeTruthy(); + }); +}); + function mockAlert(overloads: Partial = {}): Alert { return { id: uuid.v4(), @@ -597,7 +748,7 @@ function mockAlert(overloads: Partial = {}): Alert { name: `alert-${uuid.v4()}`, tags: [], alertTypeId: '.noop', - consumer: 'consumer', + consumer: ALERTS_FEATURE_ID, schedule: { interval: '1m' }, actions: [], params: {}, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx index 66a7ac25d4a7..b1dd78ff59f3 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx @@ -28,7 +28,7 @@ import { import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { useAppDependencies } from '../../../app_context'; -import { hasSaveAlertsCapability } from '../../../lib/capabilities'; +import { hasAllPrivilege, hasExecuteActionsCapability } from '../../../lib/capabilities'; import { Alert, AlertType, ActionType } from '../../../../types'; import { ComponentOpts as BulkOperationsComponentOpts, @@ -71,12 +71,20 @@ export const AlertDetails: React.FunctionComponent = ({ dataPlugin, } = useAppDependencies(); - const canSave = hasSaveAlertsCapability(capabilities); + const canExecuteActions = hasExecuteActionsCapability(capabilities); + const canSaveAlert = + hasAllPrivilege(alert, alertType) && + // if the alert has actions, can the user save the alert's action params + (canExecuteActions || (!canExecuteActions && alert.actions.length === 0)); + const actionTypesByTypeId = keyBy(actionTypes, 'id'); const hasEditButton = - canSave && alertTypeRegistry.has(alert.alertTypeId) + // can the user save the alert + canSaveAlert && + // is this alert type editable from within Alerts Management + (alertTypeRegistry.has(alert.alertTypeId) ? !alertTypeRegistry.get(alert.alertTypeId).requiresAppContext - : false; + : false); const alertActions = alert.actions; const uniqueActions = Array.from(new Set(alertActions.map((item: any) => item.actionTypeId))); @@ -124,6 +132,7 @@ export const AlertDetails: React.FunctionComponent = ({ data-test-subj="openEditAlertFlyoutButton" iconType="pencil" onClick={() => setEditFlyoutVisibility(true)} + name="edit" > = ({ { @@ -229,7 +238,7 @@ export const AlertDetails: React.FunctionComponent = ({ { if (isMuted) { @@ -255,7 +264,11 @@ export const AlertDetails: React.FunctionComponent = ({ {alert.enabled ? ( - + ) : ( diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances.test.tsx index 2531fd2625b4..dd2ee48b7a62 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances.test.tsx @@ -52,7 +52,9 @@ describe('alert_instances', () => { ]; expect( - shallow() + shallow( + + ) .find(EuiBasicTable) .prop('items') ).toEqual(instances); @@ -68,6 +70,7 @@ describe('alert_instances', () => { durationEpoch={fake2MinutesAgo.getTime()} {...mockAPIs} alert={alert} + readOnly={false} alertState={alertState} /> ) @@ -95,6 +98,7 @@ describe('alert_instances', () => { { Promise; durationEpoch?: number; } & Pick; export const alertInstancesTableColumns = ( - onMuteAction: (instance: AlertInstanceListItem) => Promise + onMuteAction: (instance: AlertInstanceListItem) => Promise, + readOnly: boolean ) => [ { field: 'instance', @@ -90,6 +92,7 @@ export const alertInstancesTableColumns = ( showLabel={false} compressed={true} checked={alertInstance.isMuted} + disabled={readOnly} data-test-subj={`muteAlertInstanceButton_${alertInstance.instance}`} onChange={() => onMuteAction(alertInstance)} /> @@ -109,6 +112,7 @@ function durationAsString(duration: Duration): string { export function AlertInstances({ alert, + readOnly, alertState: { alertInstances = {} }, muteAlertInstance, unmuteAlertInstance, @@ -162,7 +166,7 @@ export function AlertInstances({ cellProps={() => ({ 'data-test-subj': 'cell', })} - columns={alertInstancesTableColumns(onMuteAction)} + columns={alertInstancesTableColumns(onMuteAction, readOnly)} data-test-subj="alertInstancesList" /> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.test.tsx index 9bff33e4aa69..975856beba55 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.test.tsx @@ -22,9 +22,9 @@ describe('alert_state_route', () => { const alert = mockAlert(); expect( - shallow().containsMatchingElement( - - ) + shallow( + + ).containsMatchingElement() ).toBeTruthy(); }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.tsx index a02b44523e26..d8a7d18eb87a 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.tsx @@ -18,11 +18,13 @@ import { AlertInstancesWithApi as AlertInstances } from './alert_instances'; type WithAlertStateProps = { alert: Alert; + readOnly: boolean; requestRefresh: () => Promise; } & Pick; export const AlertInstancesRoute: React.FunctionComponent = ({ alert, + readOnly, requestRefresh, loadAlertState, }) => { @@ -36,7 +38,12 @@ export const AlertInstancesRoute: React.FunctionComponent = }, [alert]); return alertState ? ( - + ) : (
({ + loadAlertTypes: jest.fn(), + health: jest.fn((async) => ({ isSufficientlySecure: true, hasPermanentEncryptionKey: true })), +})); + const actionTypeRegistry = actionTypeRegistryMock.create(); const alertTypeRegistry = alertTypeRegistryMock.create(); @@ -42,6 +48,30 @@ describe('alert_add', () => { async function setup() { const mocks = coreMock.createSetup(); + const { loadAlertTypes } = jest.requireMock('../../lib/alert_api'); + const alertTypes = [ + { + id: 'my-alert-type', + name: 'Test', + actionGroups: [ + { + id: 'testActionGroup', + name: 'Test Action Group', + }, + ], + defaultActionGroupId: 'testActionGroup', + producer: ALERTS_FEATURE_ID, + authorizedConsumers: { + [ALERTS_FEATURE_ID]: { read: true, all: true }, + test: { read: true, all: true }, + }, + actionVariables: { + context: [], + state: [], + }, + }, + ]; + loadAlertTypes.mockResolvedValue(alertTypes); const [ { application: { capabilities }, @@ -120,7 +150,11 @@ describe('alert_add', () => { }, }} > - {}} /> + {}} + /> ); @@ -135,6 +169,10 @@ describe('alert_add', () => { it('renders alert add flyout', async () => { await setup(); + await new Promise((resolve) => { + setTimeout(resolve, 1000); + }); + expect(wrapper.find('[data-test-subj="addAlertFlyoutTitle"]').exists()).toBeTruthy(); expect(wrapper.find('[data-test-subj="saveAlertButton"]').exists()).toBeTruthy(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.tsx index 52c281761f2c..20cbd42e34b6 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.tsx @@ -168,6 +168,9 @@ export const AlertAdd = ({ dispatch={dispatch} errors={errors} canChangeTrigger={canChangeTrigger} + operation={i18n.translate('xpack.triggersActionsUI.sections.alertAdd.operationName', { + defaultMessage: 'create', + })} /> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.tsx index 076f4b69fb49..f991cea9c009 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_edit.tsx @@ -156,6 +156,9 @@ export const AlertEdit = ({ initialAlert, onClose }: AlertEditProps) => { errors={errors} canChangeTrigger={false} setHasActionsDisabled={setHasActionsDisabled} + operation="i18n.translate('xpack.triggersActionsUI.sections.alertEdit.operationName', { + defaultMessage: 'edit', + })" /> diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.test.tsx index c9ce2848c567..6091519f5851 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.test.tsx @@ -13,6 +13,8 @@ import { ValidationResult, Alert } from '../../../types'; import { AlertForm } from './alert_form'; import { AlertsContextProvider } from '../../context/alerts_context'; import { coreMock } from 'src/core/public/mocks'; +import { ALERTS_FEATURE_ID } from '../../../../../alerts/common'; + const actionTypeRegistry = actionTypeRegistryMock.create(); const alertTypeRegistry = alertTypeRegistryMock.create(); jest.mock('../../lib/alert_api', () => ({ @@ -20,6 +22,10 @@ jest.mock('../../lib/alert_api', () => ({ })); describe('alert_form', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + let deps: any; const alertType = { id: 'my-alert-type', @@ -63,6 +69,26 @@ describe('alert_form', () => { async function setup() { const mocks = coreMock.createSetup(); + const { loadAlertTypes } = jest.requireMock('../../lib/alert_api'); + const alertTypes = [ + { + id: 'my-alert-type', + name: 'Test', + actionGroups: [ + { + id: 'testActionGroup', + name: 'Test Action Group', + }, + ], + defaultActionGroupId: 'testActionGroup', + producer: ALERTS_FEATURE_ID, + authorizedConsumers: { + [ALERTS_FEATURE_ID]: { read: true, all: true }, + test: { read: true, all: true }, + }, + }, + ]; + loadAlertTypes.mockResolvedValue(alertTypes); const [ { application: { capabilities }, @@ -85,7 +111,7 @@ describe('alert_form', () => { const initialAlert = ({ name: 'test', params: {}, - consumer: 'alerts', + consumer: ALERTS_FEATURE_ID, schedule: { interval: '1m', }, @@ -111,7 +137,12 @@ describe('alert_form', () => { capabilities: deps!.capabilities, }} > - {}} errors={{ name: [], interval: [] }} /> + {}} + errors={{ name: [], interval: [] }} + operation="create" + /> ); @@ -167,7 +198,11 @@ describe('alert_form', () => { }, ], defaultActionGroupId: 'testActionGroup', - producer: 'alerting', + producer: ALERTS_FEATURE_ID, + authorizedConsumers: { + [ALERTS_FEATURE_ID]: { read: true, all: true }, + test: { read: true, all: true }, + }, }, { id: 'same-consumer-producer-alert-type', @@ -180,6 +215,10 @@ describe('alert_form', () => { ], defaultActionGroupId: 'testActionGroup', producer: 'test', + authorizedConsumers: { + [ALERTS_FEATURE_ID]: { read: true, all: true }, + test: { read: true, all: true }, + }, }, ]); const mocks = coreMock.createSetup(); @@ -250,7 +289,12 @@ describe('alert_form', () => { capabilities: deps!.capabilities, }} > - {}} errors={{ name: [], interval: [] }} /> + {}} + errors={{ name: [], interval: [] }} + operation="create" + /> ); @@ -302,7 +346,7 @@ describe('alert_form', () => { name: 'test', alertTypeId: alertType.id, params: {}, - consumer: 'alerts', + consumer: ALERTS_FEATURE_ID, schedule: { interval: '1m', }, @@ -328,7 +372,12 @@ describe('alert_form', () => { capabilities: deps!.capabilities, }} > - {}} errors={{ name: [], interval: [] }} /> + {}} + errors={{ name: [], interval: [] }} + operation="create" + /> ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx index 874091b2bb7a..47ec2c436ca5 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx @@ -24,6 +24,7 @@ import { EuiButtonIcon, EuiHorizontalRule, EuiLoadingSpinner, + EuiEmptyPrompt, } from '@elastic/eui'; import { some, filter, map, fold } from 'fp-ts/lib/Option'; import { pipe } from 'fp-ts/lib/pipeable'; @@ -38,6 +39,8 @@ import { AlertTypeModel, Alert, IErrorObject, AlertAction, AlertTypeIndex } from import { getTimeOptions } from '../../../common/lib/get_time_options'; import { useAlertsContext } from '../../context/alerts_context'; import { ActionForm } from '../action_connector_form'; +import { ALERTS_FEATURE_ID } from '../../../../../alerts/common'; +import { hasAllPrivilege, hasShowActionsCapability } from '../../lib/capabilities'; export function validateBaseProperties(alertObject: Alert) { const validationResult = { errors: {} }; @@ -78,6 +81,7 @@ interface AlertFormProps { errors: IErrorObject; canChangeTrigger?: boolean; // to hide Change trigger button setHasActionsDisabled?: (value: boolean) => void; + operation: string; } export const AlertForm = ({ @@ -86,6 +90,7 @@ export const AlertForm = ({ dispatch, errors, setHasActionsDisabled, + operation, }: AlertFormProps) => { const alertsContext = useAlertsContext(); const { @@ -96,6 +101,7 @@ export const AlertForm = ({ docLinks, capabilities, } = alertsContext; + const canShowActions = hasShowActionsCapability(capabilities); const [alertTypeModel, setAlertTypeModel] = useState( alert.alertTypeId ? alertTypeRegistry.get(alert.alertTypeId) : null @@ -121,12 +127,12 @@ export const AlertForm = ({ (async () => { try { const alertTypes = await loadAlertTypes({ http }); - const index: AlertTypeIndex = {}; + const index: AlertTypeIndex = new Map(); for (const alertTypeItem of alertTypes) { - index[alertTypeItem.id] = alertTypeItem; + index.set(alertTypeItem.id, alertTypeItem); } - if (alert.alertTypeId && index[alert.alertTypeId]) { - setDefaultActionGroupId(index[alert.alertTypeId].defaultActionGroupId); + if (alert.alertTypeId && index.has(alert.alertTypeId)) { + setDefaultActionGroupId(index.get(alert.alertTypeId)!.defaultActionGroupId); } setAlertTypesIndex(index); } catch (e) { @@ -167,21 +173,21 @@ export const AlertForm = ({ ? alertTypeModel.alertParamsExpression : null; - const alertTypeRegistryList = - alert.consumer === 'alerts' - ? alertTypeRegistry - .list() - .filter( - (alertTypeRegistryItem: AlertTypeModel) => !alertTypeRegistryItem.requiresAppContext - ) - : alertTypeRegistry - .list() - .filter( - (alertTypeRegistryItem: AlertTypeModel) => - alertTypesIndex && - alertTypesIndex[alertTypeRegistryItem.id] && - alertTypesIndex[alertTypeRegistryItem.id].producer === alert.consumer - ); + const alertTypeRegistryList = alertTypesIndex + ? alertTypeRegistry + .list() + .filter( + (alertTypeRegistryItem: AlertTypeModel) => + alertTypesIndex.has(alertTypeRegistryItem.id) && + hasAllPrivilege(alert, alertTypesIndex.get(alertTypeRegistryItem.id)) + ) + .filter((alertTypeRegistryItem: AlertTypeModel) => + alert.consumer === ALERTS_FEATURE_ID + ? !alertTypeRegistryItem.requiresAppContext + : alertTypesIndex.get(alertTypeRegistryItem.id)!.producer === alert.consumer + ) + : []; + const alertTypeNodes = alertTypeRegistryList.map(function (item, index) { return ( @@ -257,13 +263,13 @@ export const AlertForm = ({ /> ) : null} - {defaultActionGroupId ? ( + {canShowActions && defaultActionGroupId ? ( av.name ) : undefined @@ -487,7 +493,7 @@ export const AlertForm = ({ {alertTypeModel ? ( {alertTypeDetails} - ) : ( + ) : alertTypeNodes.length ? ( @@ -503,7 +509,37 @@ export const AlertForm = ({ {alertTypeNodes} + ) : ( + )} ); }; + +const NoAuthorizedAlertTypes = ({ operation }: { operation: string }) => ( + + + + } + body={ +
+

+ +

+
+ } + /> +); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx index 69b0856297bb..b8278aa70129 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx @@ -17,6 +17,7 @@ import { AppContextProvider } from '../../../app_context'; import { chartPluginMock } from '../../../../../../../../src/plugins/charts/public/mocks'; import { dataPluginMock } from '../../../../../../../../src/plugins/data/public/mocks'; import { alertingPluginMock } from '../../../../../../alerts/public/mocks'; +import { ALERTS_FEATURE_ID } from '../../../../../../alerts/common'; jest.mock('../../../lib/action_connector_api', () => ({ loadActionTypes: jest.fn(), @@ -47,6 +48,17 @@ const alertType = { alertParamsExpression: () => null, requiresAppContext: false, }; +const alertTypeFromApi = { + id: 'test_alert_type', + name: 'some alert type', + actionGroups: [{ id: 'default', name: 'Default' }], + actionVariables: { context: [], state: [] }, + defaultActionGroupId: 'default', + producer: ALERTS_FEATURE_ID, + authorizedConsumers: { + [ALERTS_FEATURE_ID]: { read: true, all: true }, + }, +}; alertTypeRegistry.list.mockReturnValue([alertType]); actionTypeRegistry.list.mockReturnValue([]); @@ -73,7 +85,7 @@ describe('alerts_list component empty', () => { name: 'Test2', }, ]); - loadAlertTypes.mockResolvedValue([{ id: 'test_alert_type', name: 'some alert type' }]); + loadAlertTypes.mockResolvedValue([alertTypeFromApi]); loadAllActions.mockResolvedValue([]); const mockes = coreMock.createSetup(); @@ -98,8 +110,6 @@ describe('alerts_list component empty', () => { ...capabilities, securitySolution: { 'alerting:show': true, - 'alerting:save': true, - 'alerting:delete': true, }, }, history: scopedHistoryMock.create(), @@ -193,7 +203,7 @@ describe('alerts_list component with items', () => { name: 'Test2', }, ]); - loadAlertTypes.mockResolvedValue([{ id: 'test_alert_type', name: 'some alert type' }]); + loadAlertTypes.mockResolvedValue([alertTypeFromApi]); loadAllActions.mockResolvedValue([]); const mockes = coreMock.createSetup(); const [ @@ -217,8 +227,6 @@ describe('alerts_list component with items', () => { ...capabilities, securitySolution: { 'alerting:show': true, - 'alerting:save': true, - 'alerting:delete': true, }, }, history: scopedHistoryMock.create(), @@ -299,8 +307,6 @@ describe('alerts_list component empty with show only capability', () => { ...capabilities, securitySolution: { 'alerting:show': true, - 'alerting:save': false, - 'alerting:delete': false, }, }, history: scopedHistoryMock.create(), @@ -390,7 +396,8 @@ describe('alerts_list with show only capability', () => { name: 'Test2', }, ]); - loadAlertTypes.mockResolvedValue([{ id: 'test_alert_type', name: 'some alert type' }]); + + loadAlertTypes.mockResolvedValue([alertTypeFromApi]); loadAllActions.mockResolvedValue([]); const mockes = coreMock.createSetup(); const [ @@ -414,8 +421,6 @@ describe('alerts_list with show only capability', () => { ...capabilities, securitySolution: { 'alerting:show': true, - 'alerting:save': false, - 'alerting:delete': false, }, }, history: scopedHistoryMock.create(), diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx index 2929ce6defea..8cb7afbda0e7 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx @@ -18,6 +18,7 @@ import { EuiSpacer, EuiLink, EuiLoadingSpinner, + EuiEmptyPrompt, } from '@elastic/eui'; import { useHistory } from 'react-router-dom'; @@ -33,10 +34,12 @@ import { TypeFilter } from './type_filter'; import { ActionTypeFilter } from './action_type_filter'; import { loadAlerts, loadAlertTypes, deleteAlerts } from '../../../lib/alert_api'; import { loadActionTypes } from '../../../lib/action_connector_api'; -import { hasDeleteAlertsCapability, hasSaveAlertsCapability } from '../../../lib/capabilities'; +import { hasExecuteActionsCapability } from '../../../lib/capabilities'; import { routeToAlertDetails, DEFAULT_SEARCH_PAGE_SIZE } from '../../../constants'; import { DeleteModalConfirmation } from '../../../components/delete_modal_confirmation'; import { EmptyPrompt } from '../../../components/prompts/empty_prompt'; +import { ALERTS_FEATURE_ID } from '../../../../../../alerts/common'; +import { hasAllPrivilege } from '../../../lib/capabilities'; const ENTER_KEY = 13; @@ -64,8 +67,7 @@ export const AlertsList: React.FunctionComponent = () => { charts, dataPlugin, } = useAppDependencies(); - const canDelete = hasDeleteAlertsCapability(capabilities); - const canSave = hasSaveAlertsCapability(capabilities); + const canExecuteActions = hasExecuteActionsCapability(capabilities); const [actionTypes, setActionTypes] = useState([]); const [selectedIds, setSelectedIds] = useState([]); @@ -79,7 +81,7 @@ export const AlertsList: React.FunctionComponent = () => { const [alertTypesState, setAlertTypesState] = useState({ isLoading: false, isInitialized: false, - data: {}, + data: new Map(), }); const [alertsState, setAlertsState] = useState({ isLoading: false, @@ -98,9 +100,9 @@ export const AlertsList: React.FunctionComponent = () => { try { setAlertTypesState({ ...alertTypesState, isLoading: true }); const alertTypes = await loadAlertTypes({ http }); - const index: AlertTypeIndex = {}; + const index: AlertTypeIndex = new Map(); for (const alertType of alertTypes) { - index[alertType.id] = alertType; + index.set(alertType.id, alertType); } setAlertTypesState({ isLoading: false, data: index, isInitialized: true }); } catch (e) { @@ -245,11 +247,16 @@ export const AlertsList: React.FunctionComponent = () => { }, ]; + const authorizedAlertTypes = [...alertTypesState.data.values()]; + const authorizedToCreateAnyAlerts = authorizedAlertTypes.some( + (alertType) => alertType.authorizedConsumers[ALERTS_FEATURE_ID]?.all + ); + const toolsRight = [ setTypesFilter(types)} - options={Object.values(alertTypesState.data) + options={authorizedAlertTypes .map((alertType) => ({ value: alertType.id, name: alertType.name, @@ -263,7 +270,7 @@ export const AlertsList: React.FunctionComponent = () => { />, ]; - if (canSave) { + if (authorizedToCreateAnyAlerts) { toolsRight.push( { ); } + const authorizedToModifySelectedAlerts = selectedIds.length + ? filterAlertsById(alertsState.data, selectedIds).every((selectedAlert) => + hasAllPrivilege(selectedAlert, alertTypesState.data.get(selectedAlert.alertTypeId)) + ) + : false; + const table = ( - {selectedIds.length > 0 && canDelete && ( + {selectedIds.length > 0 && authorizedToModifySelectedAlerts && ( setIsPerformingAction(true)} onActionPerformed={() => { @@ -337,7 +351,7 @@ export const AlertsList: React.FunctionComponent = () => { items={ alertTypesState.isInitialized === false ? [] - : convertAlertsToTableItems(alertsState.data, alertTypesState.data) + : convertAlertsToTableItems(alertsState.data, alertTypesState.data, canExecuteActions) } itemId="id" columns={alertsTableColumns} @@ -354,15 +368,12 @@ export const AlertsList: React.FunctionComponent = () => { /* Don't display alert count until we have the alert types initialized */ totalItemCount: alertTypesState.isInitialized === false ? 0 : alertsState.totalItemCount, }} - selection={ - canDelete - ? { - onSelectionChange(updatedSelectedItemsList: AlertTableItem[]) { - setSelectedIds(updatedSelectedItemsList.map((item) => item.id)); - }, - } - : undefined - } + selection={{ + selectable: (alert: AlertTableItem) => alert.isEditable, + onSelectionChange(updatedSelectedItemsList: AlertTableItem[]) { + setSelectedIds(updatedSelectedItemsList.map((item) => item.id)); + }, + }} onChange={({ page: changedPage }: { page: Pagination }) => { setPage(changedPage); }} @@ -370,7 +381,11 @@ export const AlertsList: React.FunctionComponent = () => { ); - const loadedItems = convertAlertsToTableItems(alertsState.data, alertTypesState.data); + const loadedItems = convertAlertsToTableItems( + alertsState.data, + alertTypesState.data, + canExecuteActions + ); const isFilterApplied = !( isEmpty(searchText) && @@ -421,8 +436,10 @@ export const AlertsList: React.FunctionComponent = () => { - ) : ( + ) : authorizedToCreateAnyAlerts ? ( setAlertFlyoutVisibility(true)} /> + ) : ( + noPermissionPrompt )} { }} > @@ -448,15 +465,44 @@ export const AlertsList: React.FunctionComponent = () => { ); }; +const noPermissionPrompt = ( + + + + } + body={ +

+ +

+ } + /> +); + function filterAlertsById(alerts: Alert[], ids: string[]): Alert[] { return alerts.filter((alert) => ids.includes(alert.id)); } -function convertAlertsToTableItems(alerts: Alert[], alertTypesIndex: AlertTypeIndex) { +function convertAlertsToTableItems( + alerts: Alert[], + alertTypesIndex: AlertTypeIndex, + canExecuteActions: boolean +) { return alerts.map((alert) => ({ ...alert, actionsText: alert.actions.length, tagsText: alert.tags.join(', '), - alertType: alertTypesIndex[alert.alertTypeId]?.name ?? alert.alertTypeId, + alertType: alertTypesIndex.get(alert.alertTypeId)?.name ?? alert.alertTypeId, + isEditable: + hasAllPrivilege(alert, alertTypesIndex.get(alert.alertTypeId)) && + (canExecuteActions || (!canExecuteActions && !alert.actions.length)), })); } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/collapsed_item_actions.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/collapsed_item_actions.tsx index 2b746e5dea45..9279f8a1745f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/collapsed_item_actions.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/collapsed_item_actions.tsx @@ -20,8 +20,6 @@ import { } from '@elastic/eui'; import { AlertTableItem } from '../../../../types'; -import { useAppDependencies } from '../../../app_context'; -import { hasDeleteAlertsCapability, hasSaveAlertsCapability } from '../../../lib/capabilities'; import { ComponentOpts as BulkOperationsComponentOpts, withBulkAlertOperations, @@ -43,16 +41,11 @@ export const CollapsedItemActions: React.FunctionComponent = ({ muteAlert, setAlertsToDelete, }: ComponentOpts) => { - const { capabilities } = useAppDependencies(); - - const canDelete = hasDeleteAlertsCapability(capabilities); - const canSave = hasSaveAlertsCapability(capabilities); - const [isPopoverOpen, setIsPopoverOpen] = useState(false); const button = ( setIsPopoverOpen(!isPopoverOpen)} aria-label={i18n.translate( @@ -75,7 +68,7 @@ export const CollapsedItemActions: React.FunctionComponent = ({
= ({ { @@ -134,7 +127,7 @@ export const CollapsedItemActions: React.FunctionComponent = ({
setAlertsToDelete([item.id])} > diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index fe3bf98b0323..dd2b070956db 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -19,7 +19,7 @@ export { Alert, AlertAction, AlertTaskState, RawAlertInstance, AlertingFramework export { ActionType }; export type ActionTypeIndex = Record; -export type AlertTypeIndex = Record; +export type AlertTypeIndex = Map; export type ActionTypeRegistryContract = PublicMethodsOf< TypeRegistry> >; @@ -32,6 +32,7 @@ export interface ActionConnectorFieldsProps { errors: IErrorObject; docLinks: DocLinksStart; http?: HttpSetup; + readOnly: boolean; consumer?: string; } @@ -101,6 +102,7 @@ export interface AlertType { actionGroups: ActionGroup[]; actionVariables: ActionVariables; defaultActionGroupId: ActionGroup['id']; + authorizedConsumers: Record; producer: string; } @@ -111,6 +113,7 @@ export type AlertWithoutId = Omit; export interface AlertTableItem extends Alert { alertType: AlertType['name']; tagsText: string; + isEditable: boolean; } export interface AlertTypeParamsExpressionProps< diff --git a/x-pack/plugins/uptime/server/kibana.index.ts b/x-pack/plugins/uptime/server/kibana.index.ts index d68bbabe82b8..a2d5f58bbec1 100644 --- a/x-pack/plugins/uptime/server/kibana.index.ts +++ b/x-pack/plugins/uptime/server/kibana.index.ts @@ -35,51 +35,33 @@ export const initServerWithKibana = (server: UptimeCoreSetup, plugins: UptimeCor icon: 'uptimeApp', app: ['uptime', 'kibana'], catalogue: ['uptime'], + alerting: ['xpack.uptime.alerts.tls', 'xpack.uptime.alerts.monitorStatus'], privileges: { all: { app: ['uptime', 'kibana'], catalogue: ['uptime'], - api: [ - 'uptime-read', - 'uptime-write', - 'actions-read', - 'actions-all', - 'alerting-read', - 'alerting-all', - ], + api: ['uptime-read', 'uptime-write'], savedObject: { - all: [umDynamicSettings.name, 'alert', 'action', 'action_task_params'], + all: [umDynamicSettings.name, 'alert'], read: [], }, - ui: [ - 'save', - 'configureSettings', - 'show', - 'alerting:show', - 'actions:show', - 'alerting:save', - 'actions:save', - 'alerting:delete', - 'actions:delete', - ], + alerting: { + all: ['xpack.uptime.alerts.tls', 'xpack.uptime.alerts.monitorStatus'], + }, + ui: ['save', 'configureSettings', 'show', 'alerting:show'], }, read: { app: ['uptime', 'kibana'], catalogue: ['uptime'], - api: ['uptime-read', 'actions-read', 'actions-all', 'alerting-read', 'alerting-all'], + api: ['uptime-read'], savedObject: { - all: ['alert', 'action', 'action_task_params'], + all: ['alert'], read: [umDynamicSettings.name], }, - ui: [ - 'show', - 'alerting:show', - 'actions:show', - 'alerting:save', - 'actions:save', - 'alerting:delete', - 'actions:delete', - ], + alerting: { + all: ['xpack.uptime.alerts.tls', 'xpack.uptime.alerts.monitorStatus'], + }, + ui: ['show', 'alerting:show'], }, }, }); diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts index b8b2cbdc03f3..cb1271494c29 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts @@ -61,27 +61,27 @@ export class FixturePlugin implements Plugin { public setup(core: CoreSetup, { features, actions, alerts }: FixtureSetupDeps) { features.registerFeature({ - id: 'alerts', + id: 'alertsFixture', name: 'Alerts', app: ['alerts', 'kibana'], + alerting: [ + 'test.always-firing', + 'test.cumulative-firing', + 'test.never-firing', + 'test.failing', + 'test.authorization', + 'test.validation', + 'test.onlyContextVariables', + 'test.onlyStateVariables', + 'test.noop', + 'test.unrestricted-noop', + ], privileges: { all: { app: ['alerts', 'kibana'], @@ -36,8 +48,21 @@ export class FixturePlugin implements Plugin, + { alerts }: Pick +) { + const noopRestrictedAlertType: AlertType = { + id: 'test.restricted-noop', + name: 'Test: Restricted Noop', + actionGroups: [{ id: 'default', name: 'Default' }], + producer: 'alertsRestrictedFixture', + defaultActionGroupId: 'default', + async executor({ services, params, state }: AlertExecutorOptions) {}, + }; + const noopUnrestrictedAlertType: AlertType = { + id: 'test.unrestricted-noop', + name: 'Test: Unrestricted Noop', + actionGroups: [{ id: 'default', name: 'Default' }], + producer: 'alertsRestrictedFixture', + defaultActionGroupId: 'default', + async executor({ services, params, state }: AlertExecutorOptions) {}, + }; + alerts.registerType(noopRestrictedAlertType); + alerts.registerType(noopUnrestrictedAlertType); +} diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts_restricted/server/index.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts_restricted/server/index.ts new file mode 100644 index 000000000000..54d6de50cff4 --- /dev/null +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts_restricted/server/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FixturePlugin } from './plugin'; + +export const plugin = () => new FixturePlugin(); diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts_restricted/server/plugin.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts_restricted/server/plugin.ts new file mode 100644 index 000000000000..e297733fb47e --- /dev/null +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts_restricted/server/plugin.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Plugin, CoreSetup } from 'kibana/server'; +import { PluginSetupContract as ActionsPluginSetup } from '../../../../../../../plugins/actions/server/plugin'; +import { PluginSetupContract as AlertingPluginSetup } from '../../../../../../../plugins/alerts/server/plugin'; +import { EncryptedSavedObjectsPluginStart } from '../../../../../../../plugins/encrypted_saved_objects/server'; +import { PluginSetupContract as FeaturesPluginSetup } from '../../../../../../../plugins/features/server'; +import { defineAlertTypes } from './alert_types'; + +export interface FixtureSetupDeps { + features: FeaturesPluginSetup; + actions: ActionsPluginSetup; + alerts: AlertingPluginSetup; +} + +export interface FixtureStartDeps { + encryptedSavedObjects: EncryptedSavedObjectsPluginStart; +} + +export class FixturePlugin implements Plugin { + public setup(core: CoreSetup, { features, alerts }: FixtureSetupDeps) { + features.registerFeature({ + id: 'alertsRestrictedFixture', + name: 'AlertRestricted', + app: ['alerts', 'kibana'], + alerting: ['test.restricted-noop', 'test.unrestricted-noop', 'test.noop'], + privileges: { + all: { + app: ['alerts', 'kibana'], + savedObject: { + all: ['alert'], + read: [], + }, + alerting: { + all: ['test.restricted-noop', 'test.unrestricted-noop', 'test.noop'], + }, + ui: [], + }, + read: { + app: ['alerts', 'kibana'], + savedObject: { + all: [], + read: [], + }, + alerting: { + read: ['test.restricted-noop', 'test.unrestricted-noop', 'test.noop'], + }, + ui: [], + }, + }, + }); + + defineAlertTypes(core, { alerts }); + } + + public start() {} + public stop() {} +} diff --git a/x-pack/test/alerting_api_integration/common/lib/alert_utils.ts b/x-pack/test/alerting_api_integration/common/lib/alert_utils.ts index 708e7e1b75b5..a68f8de39d48 100644 --- a/x-pack/test/alerting_api_integration/common/lib/alert_utils.ts +++ b/x-pack/test/alerting_api_integration/common/lib/alert_utils.ts @@ -252,7 +252,7 @@ export class AlertUtils { throttle: '30s', tags: [], alertTypeId: 'test.failing', - consumer: 'bar', + consumer: 'alertsFixture', params: { index: ES_TEST_INDEX_NAME, reference, @@ -267,6 +267,22 @@ export class AlertUtils { } } +export function getConsumerUnauthorizedErrorMessage( + operation: string, + alertType: string, + consumer: string +) { + return `Unauthorized to ${operation} a "${alertType}" alert for "${consumer}"`; +} + +export function getProducerUnauthorizedErrorMessage( + operation: string, + alertType: string, + producer: string +) { + return `Unauthorized to ${operation} a "${alertType}" alert by "${producer}"`; +} + function getDefaultAlwaysFiringAlertData(reference: string, actionId: string) { const messageTemplate = ` alertId: {{alertId}}, @@ -284,7 +300,7 @@ instanceStateValue: {{state.instanceStateValue}} throttle: '1m', tags: ['tag-A', 'tag-B'], alertTypeId: 'test.always-firing', - consumer: 'bar', + consumer: 'alertsFixture', params: { index: ES_TEST_INDEX_NAME, reference, diff --git a/x-pack/test/alerting_api_integration/common/lib/get_test_alert_data.ts b/x-pack/test/alerting_api_integration/common/lib/get_test_alert_data.ts index 76f78809d5d1..2e7a4e325094 100644 --- a/x-pack/test/alerting_api_integration/common/lib/get_test_alert_data.ts +++ b/x-pack/test/alerting_api_integration/common/lib/get_test_alert_data.ts @@ -10,7 +10,7 @@ export function getTestAlertData(overwrites = {}) { name: 'abc', tags: ['foo'], alertTypeId: 'test.noop', - consumer: 'bar', + consumer: 'alertsFixture', schedule: { interval: '1m' }, throttle: '1m', actions: [], diff --git a/x-pack/test/alerting_api_integration/common/lib/index.ts b/x-pack/test/alerting_api_integration/common/lib/index.ts index eae679cd38c1..94caf373c98d 100644 --- a/x-pack/test/alerting_api_integration/common/lib/index.ts +++ b/x-pack/test/alerting_api_integration/common/lib/index.ts @@ -8,7 +8,11 @@ export { ObjectRemover } from './object_remover'; export { getUrlPrefix } from './space_test_utils'; export { ES_TEST_INDEX_NAME, ESTestIndexTool } from './es_test_index_tool'; export { getTestAlertData } from './get_test_alert_data'; -export { AlertUtils } from './alert_utils'; +export { + AlertUtils, + getConsumerUnauthorizedErrorMessage, + getProducerUnauthorizedErrorMessage, +} from './alert_utils'; export { TaskManagerUtils } from './task_manager_utils'; export * from './test_assertions'; export { checkAAD } from './check_aad'; diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/scenarios.ts b/x-pack/test/alerting_api_integration/security_and_spaces/scenarios.ts index c72ee6a192bf..2f57d05be422 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/scenarios.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/scenarios.ts @@ -47,8 +47,10 @@ const GlobalRead: User = { kibana: [ { feature: { - alerts: ['read'], actions: ['read'], + alertsFixture: ['read'], + alertsRestrictedFixture: ['read'], + actionsSimulators: ['read'], }, spaces: ['*'], }, @@ -75,8 +77,9 @@ const Space1All: User = { kibana: [ { feature: { - alerts: ['all'], actions: ['all'], + alertsFixture: ['all'], + actionsSimulators: ['all'], }, spaces: ['space1'], }, @@ -94,7 +97,71 @@ const Space1All: User = { }, }; -export const Users: User[] = [NoKibanaPrivileges, Superuser, GlobalRead, Space1All]; +const Space1AllAlertingNoneActions: User = { + username: 'space_1_all_alerts_none_actions', + fullName: 'space_1_all_alerts_none_actions', + password: 'space_1_all_alerts_none_actions-password', + role: { + name: 'space_1_all_alerts_none_actions_role', + kibana: [ + { + feature: { + alertsFixture: ['all'], + actionsSimulators: ['all'], + }, + spaces: ['space1'], + }, + ], + elasticsearch: { + // TODO: Remove once Elasticsearch doesn't require the permission for own keys + cluster: ['manage_api_key'], + indices: [ + { + names: [`${ES_TEST_INDEX_NAME}*`], + privileges: ['all'], + }, + ], + }, + }, +}; + +const Space1AllWithRestrictedFixture: User = { + username: 'space_1_all_with_restricted_fixture', + fullName: 'space_1_all_with_restricted_fixture', + password: 'space_1_all_with_restricted_fixture-password', + role: { + name: 'space_1_all_with_restricted_fixture_role', + kibana: [ + { + feature: { + actions: ['all'], + alertsFixture: ['all'], + alertsRestrictedFixture: ['all'], + }, + spaces: ['space1'], + }, + ], + elasticsearch: { + // TODO: Remove once Elasticsearch doesn't require the permission for own keys + cluster: ['manage_api_key'], + indices: [ + { + names: [`${ES_TEST_INDEX_NAME}*`], + privileges: ['all'], + }, + ], + }, + }, +}; + +export const Users: User[] = [ + NoKibanaPrivileges, + Superuser, + GlobalRead, + Space1All, + Space1AllWithRestrictedFixture, + Space1AllAlertingNoneActions, +]; const Space1: Space = { id: 'space1', @@ -160,6 +227,23 @@ const Space1AllAtSpace1: Space1AllAtSpace1 = { user: Space1All, space: Space1, }; +interface Space1AllWithRestrictedFixtureAtSpace1 extends Scenario { + id: 'space_1_all_with_restricted_fixture at space1'; +} +const Space1AllWithRestrictedFixtureAtSpace1: Space1AllWithRestrictedFixtureAtSpace1 = { + id: 'space_1_all_with_restricted_fixture at space1', + user: Space1AllWithRestrictedFixture, + space: Space1, +}; + +interface Space1AllAlertingNoneActionsAtSpace1 extends Scenario { + id: 'space_1_all_alerts_none_actions at space1'; +} +const Space1AllAlertingNoneActionsAtSpace1: Space1AllAlertingNoneActionsAtSpace1 = { + id: 'space_1_all_alerts_none_actions at space1', + user: Space1AllAlertingNoneActions, + space: Space1, +}; interface Space1AllAtSpace2 extends Scenario { id: 'space_1_all at space2'; @@ -175,11 +259,15 @@ export const UserAtSpaceScenarios: [ SuperuserAtSpace1, GlobalReadAtSpace1, Space1AllAtSpace1, - Space1AllAtSpace2 + Space1AllAtSpace2, + Space1AllWithRestrictedFixtureAtSpace1, + Space1AllAlertingNoneActionsAtSpace1 ] = [ NoKibanaPrivilegesAtSpace1, SuperuserAtSpace1, GlobalReadAtSpace1, Space1AllAtSpace1, Space1AllAtSpace2, + Space1AllWithRestrictedFixtureAtSpace1, + Space1AllAlertingNoneActionsAtSpace1, ]; diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/create.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/create.ts index 69dcb7c81381..75609d58f779 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/create.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/create.ts @@ -41,16 +41,18 @@ export default function createActionTests({ getService }: FtrProviderContext) { switch (scenario.id) { case 'no_kibana_privileges at space1': case 'global_read at space1': + case 'space_1_all_alerts_none_actions at space1': case 'space_1_all at space2': - expect(response.statusCode).to.eql(404); + expect(response.statusCode).to.eql(403); expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', + statusCode: 403, + error: 'Forbidden', + message: 'Unauthorized to create a "test.index-record" action', }); break; case 'superuser at space1': case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(200); objectRemover.add(space.id, response.body.id, 'action', 'actions'); expect(response.body).to.eql({ @@ -90,16 +92,18 @@ export default function createActionTests({ getService }: FtrProviderContext) { switch (scenario.id) { case 'no_kibana_privileges at space1': case 'global_read at space1': + case 'space_1_all_alerts_none_actions at space1': case 'space_1_all at space2': - expect(response.statusCode).to.eql(404); + expect(response.statusCode).to.eql(403); expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', + statusCode: 403, + error: 'Forbidden', + message: 'Unauthorized to create a "test.unregistered-action-type" action', }); break; case 'superuser at space1': case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(400); expect(response.body).to.eql({ statusCode: 400, @@ -122,16 +126,11 @@ export default function createActionTests({ getService }: FtrProviderContext) { switch (scenario.id) { case 'no_kibana_privileges at space1': case 'global_read at space1': + case 'space_1_all_alerts_none_actions at space1': case 'space_1_all at space2': - expect(response.statusCode).to.eql(404); - expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', - }); - break; case 'superuser at space1': case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(400); expect(response.body).to.eql({ statusCode: 400, @@ -160,16 +159,18 @@ export default function createActionTests({ getService }: FtrProviderContext) { switch (scenario.id) { case 'no_kibana_privileges at space1': case 'global_read at space1': + case 'space_1_all_alerts_none_actions at space1': case 'space_1_all at space2': - expect(response.statusCode).to.eql(404); + expect(response.statusCode).to.eql(403); expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', + statusCode: 403, + error: 'Forbidden', + message: 'Unauthorized to create a "test.index-record" action', }); break; case 'superuser at space1': case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(400); expect(response.body).to.eql({ statusCode: 400, @@ -196,16 +197,18 @@ export default function createActionTests({ getService }: FtrProviderContext) { switch (scenario.id) { case 'no_kibana_privileges at space1': case 'global_read at space1': + case 'space_1_all_alerts_none_actions at space1': case 'space_1_all at space2': - expect(response.statusCode).to.eql(404); + expect(response.statusCode).to.eql(403); expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', + statusCode: 403, + error: 'Forbidden', + message: 'Unauthorized to create a "test.not-enabled" action', }); break; case 'superuser at space1': case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(403); expect(response.body).to.eql({ statusCode: 403, diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/delete.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/delete.ts index d96ffc5bb3be..97c933f2ef8c 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/delete.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/delete.ts @@ -46,18 +46,20 @@ export default function deleteActionTests({ getService }: FtrProviderContext) { switch (scenario.id) { case 'no_kibana_privileges at space1': + case 'space_1_all_alerts_none_actions at space1': case 'global_read at space1': case 'space_1_all at space2': - expect(response.statusCode).to.eql(404); + expect(response.statusCode).to.eql(403); expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', + statusCode: 403, + error: 'Forbidden', + message: 'Unauthorized to delete actions', }); objectRemover.add(space.id, createdAction.id, 'action', 'actions'); break; case 'superuser at space1': case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(204); expect(response.body).to.eql(''); break; @@ -88,19 +90,22 @@ export default function deleteActionTests({ getService }: FtrProviderContext) { .auth(user.username, user.password) .set('kbn-xsrf', 'foo'); - expect(response.statusCode).to.eql(404); switch (scenario.id) { case 'no_kibana_privileges at space1': + case 'space_1_all_alerts_none_actions at space1': case 'global_read at space1': case 'space_1_all at space2': case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(403); expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', + statusCode: 403, + error: 'Forbidden', + message: 'Unauthorized to delete actions', }); break; case 'superuser at space1': + expect(response.statusCode).to.eql(404); expect(response.body).to.eql({ statusCode: 404, error: 'Not Found', @@ -120,17 +125,19 @@ export default function deleteActionTests({ getService }: FtrProviderContext) { switch (scenario.id) { case 'no_kibana_privileges at space1': + case 'space_1_all_alerts_none_actions at space1': case 'global_read at space1': case 'space_1_all at space2': - expect(response.statusCode).to.eql(404); + expect(response.statusCode).to.eql(403); expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', + statusCode: 403, + error: 'Forbidden', + message: 'Unauthorized to delete actions', }); break; case 'superuser at space1': case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(404); break; default: @@ -146,17 +153,19 @@ export default function deleteActionTests({ getService }: FtrProviderContext) { switch (scenario.id) { case 'no_kibana_privileges at space1': + case 'space_1_all_alerts_none_actions at space1': case 'global_read at space1': case 'space_1_all at space2': - expect(response.statusCode).to.eql(404); + expect(response.statusCode).to.eql(403); expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', + statusCode: 403, + error: 'Forbidden', + message: 'Unauthorized to delete actions', }); break; case 'superuser at space1': case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.body).to.eql({ statusCode: 400, error: 'Bad Request', diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/execute.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/execute.ts index 5d609d001ee5..a45eee400b44 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/execute.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/execute.ts @@ -77,17 +77,19 @@ export default function ({ getService }: FtrProviderContext) { switch (scenario.id) { case 'no_kibana_privileges at space1': + case 'space_1_all_alerts_none_actions at space1': case 'space_1_all at space2': - expect(response.statusCode).to.eql(404); + expect(response.statusCode).to.eql(403); expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', + statusCode: 403, + error: 'Forbidden', + message: 'Unauthorized to execute actions', }); break; case 'global_read at space1': case 'superuser at space1': case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(200); expect(response.body).to.be.an('object'); const searchResult = await esTestIndexTool.search( @@ -154,19 +156,22 @@ export default function ({ getService }: FtrProviderContext) { }, }); - expect(response.statusCode).to.eql(404); switch (scenario.id) { case 'no_kibana_privileges at space1': + case 'space_1_all_alerts_none_actions at space1': case 'space_1_all at space2': case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(403); expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', + statusCode: 403, + error: 'Forbidden', + message: 'Unauthorized to execute actions', }); break; case 'global_read at space1': case 'superuser at space1': + expect(response.statusCode).to.eql(404); expect(response.body).to.eql({ statusCode: 404, error: 'Not Found', @@ -224,17 +229,19 @@ export default function ({ getService }: FtrProviderContext) { switch (scenario.id) { case 'no_kibana_privileges at space1': + case 'space_1_all_alerts_none_actions at space1': case 'space_1_all at space2': - expect(response.statusCode).to.eql(404); + expect(response.statusCode).to.eql(403); expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', + statusCode: 403, + error: 'Forbidden', + message: 'Unauthorized to execute actions', }); break; case 'global_read at space1': case 'superuser at space1': case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(200); expect(response.body).to.be.an('object'); const searchResult = await esTestIndexTool.search( @@ -275,17 +282,19 @@ export default function ({ getService }: FtrProviderContext) { switch (scenario.id) { case 'no_kibana_privileges at space1': + case 'space_1_all_alerts_none_actions at space1': case 'space_1_all at space2': - expect(response.statusCode).to.eql(404); + expect(response.statusCode).to.eql(403); expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', + statusCode: 403, + error: 'Forbidden', + message: 'Unauthorized to execute actions', }); break; case 'global_read at space1': case 'superuser at space1': case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(404); expect(response.body).to.eql({ statusCode: 404, @@ -307,17 +316,12 @@ export default function ({ getService }: FtrProviderContext) { switch (scenario.id) { case 'no_kibana_privileges at space1': + case 'space_1_all_alerts_none_actions at space1': case 'space_1_all at space2': - expect(response.statusCode).to.eql(404); - expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', - }); - break; case 'global_read at space1': case 'superuser at space1': case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(400); expect(response.body).to.eql({ statusCode: 400, @@ -383,17 +387,19 @@ export default function ({ getService }: FtrProviderContext) { switch (scenario.id) { case 'no_kibana_privileges at space1': + case 'space_1_all_alerts_none_actions at space1': case 'space_1_all at space2': - expect(response.statusCode).to.eql(404); + expect(response.statusCode).to.eql(403); expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', + statusCode: 403, + error: 'Forbidden', + message: 'Unauthorized to execute actions', }); break; case 'global_read at space1': case 'superuser at space1': case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(200); break; default: @@ -431,16 +437,18 @@ export default function ({ getService }: FtrProviderContext) { switch (scenario.id) { case 'no_kibana_privileges at space1': + case 'space_1_all_alerts_none_actions at space1': case 'space_1_all at space2': - expect(response.statusCode).to.eql(404); + expect(response.statusCode).to.eql(403); expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', + statusCode: 403, + error: 'Forbidden', + message: 'Unauthorized to execute actions', }); break; case 'global_read at space1': case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(200); searchResult = await esTestIndexTool.search('action:test.authorization', reference); expect(searchResult.hits.total.value).to.eql(1); diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/get.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/get.ts index c610ac670f69..fc08be3e30a6 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/get.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/get.ts @@ -45,17 +45,19 @@ export default function getActionTests({ getService }: FtrProviderContext) { switch (scenario.id) { case 'no_kibana_privileges at space1': + case 'space_1_all_alerts_none_actions at space1': case 'space_1_all at space2': - expect(response.statusCode).to.eql(404); + expect(response.statusCode).to.eql(403); expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', + statusCode: 403, + error: 'Forbidden', + message: 'Unauthorized to get actions', }); break; case 'global_read at space1': case 'superuser at space1': case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(200); expect(response.body).to.eql({ id: createdAction.id, @@ -93,19 +95,22 @@ export default function getActionTests({ getService }: FtrProviderContext) { .get(`${getUrlPrefix('other')}/api/actions/action/${createdAction.id}`) .auth(user.username, user.password); - expect(response.statusCode).to.eql(404); switch (scenario.id) { case 'no_kibana_privileges at space1': + case 'space_1_all_alerts_none_actions at space1': case 'space_1_all at space2': case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(403); expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', + statusCode: 403, + error: 'Forbidden', + message: 'Unauthorized to get actions', }); break; case 'global_read at space1': case 'superuser at space1': + expect(response.statusCode).to.eql(404); expect(response.body).to.eql({ statusCode: 404, error: 'Not Found', @@ -124,17 +129,19 @@ export default function getActionTests({ getService }: FtrProviderContext) { switch (scenario.id) { case 'no_kibana_privileges at space1': + case 'space_1_all_alerts_none_actions at space1': case 'space_1_all at space2': - expect(response.statusCode).to.eql(404); + expect(response.statusCode).to.eql(403); expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', + statusCode: 403, + error: 'Forbidden', + message: 'Unauthorized to get actions', }); break; case 'global_read at space1': case 'superuser at space1': case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(200); expect(response.body).to.eql({ id: 'my-slack1', diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/get_all.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/get_all.ts index 45491aa2d28f..994072d5cb03 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/get_all.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/get_all.ts @@ -45,17 +45,19 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { switch (scenario.id) { case 'no_kibana_privileges at space1': + case 'space_1_all_alerts_none_actions at space1': case 'space_1_all at space2': - expect(response.statusCode).to.eql(404); + expect(response.statusCode).to.eql(403); expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', + statusCode: 403, + error: 'Forbidden', + message: 'Unauthorized to get actions', }); break; case 'global_read at space1': case 'superuser at space1': case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(200); expect(response.body).to.eql([ { @@ -150,17 +152,19 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { switch (scenario.id) { case 'no_kibana_privileges at space1': + case 'space_1_all_alerts_none_actions at space1': case 'space_1_all at space2': - expect(response.statusCode).to.eql(404); + expect(response.statusCode).to.eql(403); expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', + statusCode: 403, + error: 'Forbidden', + message: 'Unauthorized to get actions', }); break; case 'global_read at space1': case 'superuser at space1': case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(200); expect(response.body).to.eql([ { @@ -231,13 +235,15 @@ export default function getAllActionTests({ getService }: FtrProviderContext) { switch (scenario.id) { case 'no_kibana_privileges at space1': + case 'space_1_all_alerts_none_actions at space1': case 'space_1_all at space2': case 'space_1_all at space1': - expect(response.statusCode).to.eql(404); + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(403); expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', + statusCode: 403, + error: 'Forbidden', + message: 'Unauthorized to get actions', }); break; case 'global_read at space1': diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/list_action_types.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/list_action_types.ts index 22c89a1a8148..83b7077cbaad 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/list_action_types.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/list_action_types.ts @@ -28,19 +28,15 @@ export default function listActionTypesTests({ getService }: FtrProviderContext) }; } + expect(response.statusCode).to.eql(200); switch (scenario.id) { case 'no_kibana_privileges at space1': + case 'space_1_all_alerts_none_actions at space1': case 'space_1_all at space2': - expect(response.statusCode).to.eql(404); - expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', - }); - break; case 'global_read at space1': case 'superuser at space1': case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(200); // Check for values explicitly in order to avoid this test failing each time plugins register // a new action type diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/update.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/update.ts index cb0e0efda0b1..82b12e6ce9a2 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/update.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/update.ts @@ -55,17 +55,19 @@ export default function updateActionTests({ getService }: FtrProviderContext) { switch (scenario.id) { case 'no_kibana_privileges at space1': + case 'space_1_all_alerts_none_actions at space1': case 'space_1_all at space2': case 'global_read at space1': - expect(response.statusCode).to.eql(404); + expect(response.statusCode).to.eql(403); expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', + statusCode: 403, + error: 'Forbidden', + message: 'Unauthorized to update actions', }); break; case 'superuser at space1': case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(200); expect(response.body).to.eql({ id: createdAction.id, @@ -120,19 +122,22 @@ export default function updateActionTests({ getService }: FtrProviderContext) { }, }); - expect(response.statusCode).to.eql(404); switch (scenario.id) { case 'no_kibana_privileges at space1': + case 'space_1_all_alerts_none_actions at space1': case 'space_1_all at space2': case 'global_read at space1': case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(403); expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', + statusCode: 403, + error: 'Forbidden', + message: 'Unauthorized to update actions', }); break; case 'superuser at space1': + expect(response.statusCode).to.eql(404); expect(response.body).to.eql({ statusCode: 404, error: 'Not Found', @@ -156,17 +161,12 @@ export default function updateActionTests({ getService }: FtrProviderContext) { switch (scenario.id) { case 'no_kibana_privileges at space1': + case 'space_1_all_alerts_none_actions at space1': case 'space_1_all at space2': case 'global_read at space1': - expect(response.statusCode).to.eql(404); - expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', - }); - break; case 'superuser at space1': case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(400); expect(response.body).to.eql({ statusCode: 400, @@ -196,17 +196,19 @@ export default function updateActionTests({ getService }: FtrProviderContext) { switch (scenario.id) { case 'no_kibana_privileges at space1': + case 'space_1_all_alerts_none_actions at space1': case 'space_1_all at space2': case 'global_read at space1': - expect(response.statusCode).to.eql(404); + expect(response.statusCode).to.eql(403); expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', + statusCode: 403, + error: 'Forbidden', + message: 'Unauthorized to update actions', }); break; case 'superuser at space1': case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(404); expect(response.body).to.eql({ statusCode: 404, @@ -228,17 +230,12 @@ export default function updateActionTests({ getService }: FtrProviderContext) { switch (scenario.id) { case 'no_kibana_privileges at space1': + case 'space_1_all_alerts_none_actions at space1': case 'space_1_all at space2': case 'global_read at space1': - expect(response.statusCode).to.eql(404); - expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', - }); - break; case 'superuser at space1': case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(400); expect(response.body).to.eql({ statusCode: 400, @@ -285,17 +282,19 @@ export default function updateActionTests({ getService }: FtrProviderContext) { switch (scenario.id) { case 'no_kibana_privileges at space1': + case 'space_1_all_alerts_none_actions at space1': case 'space_1_all at space2': case 'global_read at space1': - expect(response.statusCode).to.eql(404); + expect(response.statusCode).to.eql(403); expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', + statusCode: 403, + error: 'Forbidden', + message: 'Unauthorized to update actions', }); break; case 'superuser at space1': case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(400); expect(response.body).to.eql({ statusCode: 400, @@ -326,17 +325,19 @@ export default function updateActionTests({ getService }: FtrProviderContext) { switch (scenario.id) { case 'no_kibana_privileges at space1': + case 'space_1_all_alerts_none_actions at space1': case 'space_1_all at space2': case 'global_read at space1': - expect(response.statusCode).to.eql(404); + expect(response.statusCode).to.eql(403); expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', + statusCode: 403, + error: 'Forbidden', + message: 'Unauthorized to update actions', }); break; case 'superuser at space1': case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.body).to.eql({ statusCode: 400, error: 'Bad Request', diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts index db1e59746162..ffa9855478a0 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts @@ -14,6 +14,7 @@ import { getTestAlertData, ObjectRemover, AlertUtils, + getConsumerUnauthorizedErrorMessage, TaskManagerUtils, getEventLog, } from '../../../common/lib'; @@ -88,15 +89,28 @@ export default function alertTests({ getService }: FtrProviderContext) { case 'no_kibana_privileges at space1': case 'global_read at space1': case 'space_1_all at space2': - expect(response.statusCode).to.eql(404); + expect(response.statusCode).to.eql(403); expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'create', + 'test.always-firing', + 'alertsFixture' + ), + statusCode: 403, + }); + break; + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: `Unauthorized to get actions`, + statusCode: 403, }); break; case 'superuser at space1': case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(200); // Wait for the action to index a document before disabling the alert and waiting for tasks to finish @@ -189,15 +203,28 @@ instanceStateValue: true case 'no_kibana_privileges at space1': case 'global_read at space1': case 'space_1_all at space2': - expect(response.statusCode).to.eql(404); + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'create', + 'test.always-firing', + 'alertsFixture' + ), + statusCode: 403, + }); + break; + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(403); expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', + error: 'Forbidden', + message: `Unauthorized to get actions`, + statusCode: 403, }); break; case 'superuser at space1': case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(200); // Wait for the action to index a document before disabling the alert and waiting for tasks to finish @@ -376,15 +403,28 @@ instanceStateValue: true case 'no_kibana_privileges at space1': case 'global_read at space1': case 'space_1_all at space2': - expect(response.statusCode).to.eql(404); + expect(response.statusCode).to.eql(403); expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'create', + 'test.always-firing', + 'alertsFixture' + ), + statusCode: 403, + }); + break; + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: `Unauthorized to get actions`, + statusCode: 403, }); break; case 'superuser at space1': case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(200); objectRemover.add(space.id, response.body.id, 'alert', 'alerts'); @@ -460,14 +500,20 @@ instanceStateValue: true case 'no_kibana_privileges at space1': case 'space_1_all at space2': case 'global_read at space1': - expect(response.statusCode).to.eql(404); + expect(response.statusCode).to.eql(403); expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'create', + 'test.authorization', + 'alertsFixture' + ), + statusCode: 403, }); break; case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(200); objectRemover.add(space.id, response.body.id, 'alert', 'alerts'); @@ -574,14 +620,19 @@ instanceStateValue: true case 'no_kibana_privileges at space1': case 'space_1_all at space2': case 'global_read at space1': - expect(response.statusCode).to.eql(404); + expect(response.statusCode).to.eql(403); expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'create', + 'test.always-firing', + 'alertsFixture' + ), + statusCode: 403, }); break; case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(200); objectRemover.add(space.id, response.body.id, 'alert', 'alerts'); @@ -614,6 +665,14 @@ instanceStateValue: true }, }); break; + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: `Unauthorized to get actions`, + statusCode: 403, + }); + break; case 'superuser at space1': expect(response.statusCode).to.eql(200); objectRemover.add(space.id, response.body.id, 'alert', 'alerts'); @@ -658,14 +717,27 @@ instanceStateValue: true case 'no_kibana_privileges at space1': case 'space_1_all at space2': case 'global_read at space1': - expect(response.statusCode).to.eql(404); + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'create', + 'test.always-firing', + 'alertsFixture' + ), + statusCode: 403, + }); + break; + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(403); expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', + error: 'Forbidden', + message: `Unauthorized to get actions`, + statusCode: 403, }); break; case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': case 'superuser at space1': expect(response.statusCode).to.eql(200); // Wait until alerts scheduled actions 3 times before disabling the alert and waiting for tasks to finish @@ -724,14 +796,27 @@ instanceStateValue: true case 'no_kibana_privileges at space1': case 'space_1_all at space2': case 'global_read at space1': - expect(response.statusCode).to.eql(404); + expect(response.statusCode).to.eql(403); expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'create', + 'test.always-firing', + 'alertsFixture' + ), + statusCode: 403, + }); + break; + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: `Unauthorized to get actions`, + statusCode: 403, }); break; case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': case 'superuser at space1': expect(response.statusCode).to.eql(200); // Wait for actions to execute twice before disabling the alert and waiting for tasks to finish @@ -774,14 +859,27 @@ instanceStateValue: true case 'no_kibana_privileges at space1': case 'space_1_all at space2': case 'global_read at space1': - expect(response.statusCode).to.eql(404); + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'create', + 'test.always-firing', + 'alertsFixture' + ), + statusCode: 403, + }); + break; + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(403); expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', + error: 'Forbidden', + message: `Unauthorized to get actions`, + statusCode: 403, }); break; case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': case 'superuser at space1': expect(response.statusCode).to.eql(200); // Actions should execute twice before widning things down @@ -816,14 +914,27 @@ instanceStateValue: true case 'no_kibana_privileges at space1': case 'space_1_all at space2': case 'global_read at space1': - expect(response.statusCode).to.eql(404); + expect(response.statusCode).to.eql(403); expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'create', + 'test.always-firing', + 'alertsFixture' + ), + statusCode: 403, + }); + break; + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: `Unauthorized to get actions`, + statusCode: 403, }); break; case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': case 'superuser at space1': await alertUtils.muteAll(response.body.id); await alertUtils.enable(response.body.id); @@ -861,14 +972,27 @@ instanceStateValue: true case 'no_kibana_privileges at space1': case 'space_1_all at space2': case 'global_read at space1': - expect(response.statusCode).to.eql(404); + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'create', + 'test.always-firing', + 'alertsFixture' + ), + statusCode: 403, + }); + break; + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(403); expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', + error: 'Forbidden', + message: `Unauthorized to get actions`, + statusCode: 403, }); break; case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': case 'superuser at space1': await alertUtils.muteInstance(response.body.id, '1'); await alertUtils.enable(response.body.id); @@ -906,14 +1030,27 @@ instanceStateValue: true case 'no_kibana_privileges at space1': case 'space_1_all at space2': case 'global_read at space1': - expect(response.statusCode).to.eql(404); + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'create', + 'test.always-firing', + 'alertsFixture' + ), + statusCode: 403, + }); + break; + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(403); expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', + error: 'Forbidden', + message: `Unauthorized to get actions`, + statusCode: 403, }); break; case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': case 'superuser at space1': await alertUtils.muteInstance(response.body.id, '1'); await alertUtils.muteAll(response.body.id); diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/create.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/create.ts index 4ca943f3e188..8d7b9dec58cf 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/create.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/create.ts @@ -6,7 +6,14 @@ import expect from '@kbn/expect'; import { UserAtSpaceScenarios } from '../../scenarios'; -import { checkAAD, getTestAlertData, getUrlPrefix, ObjectRemover } from '../../../common/lib'; +import { + checkAAD, + getTestAlertData, + getConsumerUnauthorizedErrorMessage, + getUrlPrefix, + ObjectRemover, + getProducerUnauthorizedErrorMessage, +} from '../../../common/lib'; import { FtrProviderContext } from '../../../common/ftr_provider_context'; // eslint-disable-next-line import/no-default-export @@ -62,15 +69,28 @@ export default function createAlertTests({ getService }: FtrProviderContext) { case 'no_kibana_privileges at space1': case 'global_read at space1': case 'space_1_all at space2': - expect(response.statusCode).to.eql(404); + expect(response.statusCode).to.eql(403); expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'create', + 'test.noop', + 'alertsFixture' + ), + statusCode: 403, + }); + break; + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: `Unauthorized to get actions`, + statusCode: 403, }); break; case 'superuser at space1': case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(200); objectRemover.add(space.id, response.body.id, 'alert', 'alerts'); expect(response.body).to.eql({ @@ -87,7 +107,7 @@ export default function createAlertTests({ getService }: FtrProviderContext) { ], enabled: true, alertTypeId: 'test.noop', - consumer: 'bar', + consumer: 'alertsFixture', params: {}, createdBy: user.username, schedule: { interval: '1m' }, @@ -124,6 +144,174 @@ export default function createAlertTests({ getService }: FtrProviderContext) { } }); + it('should handle create alert request appropriately when consumer is the same as producer', async () => { + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(space.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .send( + getTestAlertData({ + alertTypeId: 'test.restricted-noop', + consumer: 'alertsRestrictedFixture', + }) + ); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'global_read at space1': + case 'space_1_all at space2': + case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'create', + 'test.restricted-noop', + 'alertsRestrictedFixture' + ), + statusCode: 403, + }); + break; + case 'superuser at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(200); + objectRemover.add(space.id, response.body.id, 'alert', 'alerts'); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle create alert request appropriately when consumer is not the producer', async () => { + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(space.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .send( + getTestAlertData({ alertTypeId: 'test.unrestricted-noop', consumer: 'alertsFixture' }) + ); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'global_read at space1': + case 'space_1_all at space2': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'create', + 'test.unrestricted-noop', + 'alertsFixture' + ), + statusCode: 403, + }); + break; + case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getProducerUnauthorizedErrorMessage( + 'create', + 'test.unrestricted-noop', + 'alertsRestrictedFixture' + ), + statusCode: 403, + }); + break; + case 'superuser at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(200); + objectRemover.add(space.id, response.body.id, 'alert', 'alerts'); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle create alert request appropriately when consumer is "alerts"', async () => { + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(space.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .send( + getTestAlertData({ + alertTypeId: 'test.noop', + consumer: 'alerts', + }) + ); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage('create', 'test.noop', 'alerts'), + statusCode: 403, + }); + break; + case 'global_read at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getProducerUnauthorizedErrorMessage( + 'create', + 'test.noop', + 'alertsFixture' + ), + statusCode: 403, + }); + break; + case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'superuser at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(200); + objectRemover.add(space.id, response.body.id, 'alert', 'alerts'); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle create alert request appropriately when consumer is unknown', async () => { + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(space.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .send( + getTestAlertData({ + alertTypeId: 'test.noop', + consumer: 'some consumer patrick invented', + }) + ); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'global_read at space1': + case 'space_1_all at space2': + case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'space_1_all_with_restricted_fixture at space1': + case 'superuser at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'create', + 'test.noop', + 'some consumer patrick invented' + ), + statusCode: 403, + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + it('should handle create alert request appropriately when an alert is disabled ', async () => { const response = await supertestWithoutAuth .post(`${getUrlPrefix(space.id)}/api/alerts/alert`) @@ -135,15 +323,21 @@ export default function createAlertTests({ getService }: FtrProviderContext) { case 'no_kibana_privileges at space1': case 'global_read at space1': case 'space_1_all at space2': - expect(response.statusCode).to.eql(404); + expect(response.statusCode).to.eql(403); expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'create', + 'test.noop', + 'alertsFixture' + ), + statusCode: 403, }); break; case 'superuser at space1': case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(200); objectRemover.add(space.id, response.body.id, 'alert', 'alerts'); expect(response.body.scheduledTaskId).to.eql(undefined); @@ -168,15 +362,10 @@ export default function createAlertTests({ getService }: FtrProviderContext) { case 'no_kibana_privileges at space1': case 'global_read at space1': case 'space_1_all at space2': - expect(response.statusCode).to.eql(404); - expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', - }); - break; - case 'superuser at space1': case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'space_1_all_with_restricted_fixture at space1': + case 'superuser at space1': expect(response.statusCode).to.eql(400); expect(response.body).to.eql({ statusCode: 400, @@ -200,15 +389,10 @@ export default function createAlertTests({ getService }: FtrProviderContext) { case 'no_kibana_privileges at space1': case 'global_read at space1': case 'space_1_all at space2': - expect(response.statusCode).to.eql(404); - expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', - }); - break; case 'superuser at space1': case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(400); expect(response.body).to.eql({ statusCode: 400, @@ -236,15 +420,21 @@ export default function createAlertTests({ getService }: FtrProviderContext) { case 'no_kibana_privileges at space1': case 'global_read at space1': case 'space_1_all at space2': - expect(response.statusCode).to.eql(404); + expect(response.statusCode).to.eql(403); expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'create', + 'test.validation', + 'alertsFixture' + ), + statusCode: 403, }); break; case 'superuser at space1': case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(400); expect(response.body).to.eql({ statusCode: 400, @@ -269,15 +459,10 @@ export default function createAlertTests({ getService }: FtrProviderContext) { case 'no_kibana_privileges at space1': case 'global_read at space1': case 'space_1_all at space2': - expect(response.statusCode).to.eql(404); - expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', - }); - break; case 'superuser at space1': case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(400); expect(response.body).to.eql({ error: 'Bad Request', @@ -301,15 +486,10 @@ export default function createAlertTests({ getService }: FtrProviderContext) { case 'no_kibana_privileges at space1': case 'global_read at space1': case 'space_1_all at space2': - expect(response.statusCode).to.eql(404); - expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', - }); - break; case 'superuser at space1': case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(400); expect(response.body).to.eql({ error: 'Bad Request', diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/delete.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/delete.ts index 6f8ae010b9cd..fc453c8da72e 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/delete.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/delete.ts @@ -6,7 +6,13 @@ import expect from '@kbn/expect'; import { UserAtSpaceScenarios } from '../../scenarios'; -import { getUrlPrefix, getTestAlertData, ObjectRemover } from '../../../common/lib'; +import { + getUrlPrefix, + getTestAlertData, + getConsumerUnauthorizedErrorMessage, + getProducerUnauthorizedErrorMessage, + ObjectRemover, +} from '../../../common/lib'; import { FtrProviderContext } from '../../../common/ftr_provider_context'; // eslint-disable-next-line import/no-default-export @@ -46,11 +52,15 @@ export default function createDeleteTests({ getService }: FtrProviderContext) { case 'no_kibana_privileges at space1': case 'space_1_all at space2': case 'global_read at space1': - expect(response.statusCode).to.eql(404); + expect(response.statusCode).to.eql(403); expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'delete', + 'test.noop', + 'alertsFixture' + ), + statusCode: 403, }); objectRemover.add(space.id, createdAlert.id, 'alert', 'alerts'); // Ensure task still exists @@ -58,6 +68,185 @@ export default function createDeleteTests({ getService }: FtrProviderContext) { break; case 'superuser at space1': case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(204); + expect(response.body).to.eql(''); + try { + await getScheduledTask(createdAlert.scheduledTaskId); + throw new Error('Should have removed scheduled task'); + } catch (e) { + expect(e.status).to.eql(404); + } + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle delete alert request appropriately when consumer is the same as producer', async () => { + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + alertTypeId: 'test.restricted-noop', + consumer: 'alertsRestrictedFixture', + }) + ) + .expect(200); + + const response = await supertestWithoutAuth + .delete(`${getUrlPrefix(space.id)}/api/alerts/alert/${createdAlert.id}`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + case 'global_read at space1': + case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'delete', + 'test.restricted-noop', + 'alertsRestrictedFixture' + ), + statusCode: 403, + }); + objectRemover.add(space.id, createdAlert.id, 'alert', 'alerts'); + // Ensure task still exists + await getScheduledTask(createdAlert.scheduledTaskId); + break; + case 'superuser at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(204); + expect(response.body).to.eql(''); + try { + await getScheduledTask(createdAlert.scheduledTaskId); + throw new Error('Should have removed scheduled task'); + } catch (e) { + expect(e.status).to.eql(404); + } + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle delete alert request appropriately when consumer is not the producer', async () => { + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ alertTypeId: 'test.unrestricted-noop', consumer: 'alertsFixture' }) + ) + .expect(200); + + const response = await supertestWithoutAuth + .delete(`${getUrlPrefix(space.id)}/api/alerts/alert/${createdAlert.id}`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + case 'global_read at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'delete', + 'test.unrestricted-noop', + 'alertsFixture' + ), + statusCode: 403, + }); + objectRemover.add(space.id, createdAlert.id, 'alert', 'alerts'); + // Ensure task still exists + await getScheduledTask(createdAlert.scheduledTaskId); + break; + case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getProducerUnauthorizedErrorMessage( + 'delete', + 'test.unrestricted-noop', + 'alertsRestrictedFixture' + ), + statusCode: 403, + }); + objectRemover.add(space.id, createdAlert.id, 'alert', 'alerts'); + // Ensure task still exists + await getScheduledTask(createdAlert.scheduledTaskId); + break; + case 'superuser at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(204); + expect(response.body).to.eql(''); + try { + await getScheduledTask(createdAlert.scheduledTaskId); + throw new Error('Should have removed scheduled task'); + } catch (e) { + expect(e.status).to.eql(404); + } + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle delete alert request appropriately when consumer is "alerts"', async () => { + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + alertTypeId: 'test.noop', + consumer: 'alerts', + }) + ) + .expect(200); + + const response = await supertestWithoutAuth + .delete(`${getUrlPrefix(space.id)}/api/alerts/alert/${createdAlert.id}`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage('delete', 'test.noop', 'alerts'), + statusCode: 403, + }); + break; + case 'global_read at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getProducerUnauthorizedErrorMessage( + 'delete', + 'test.noop', + 'alertsFixture' + ), + statusCode: 403, + }); + objectRemover.add(space.id, createdAlert.id, 'alert', 'alerts'); + // Ensure task still exists + await getScheduledTask(createdAlert.scheduledTaskId); + break; + case 'superuser at space1': + case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(204); expect(response.body).to.eql(''); try { @@ -91,12 +280,8 @@ export default function createDeleteTests({ getService }: FtrProviderContext) { case 'space_1_all at space2': case 'global_read at space1': case 'space_1_all at space1': - expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', - }); - break; + case 'space_1_all_alerts_none_actions at space1': + case 'space_1_all_with_restricted_fixture at space1': case 'superuser at space1': expect(response.body).to.eql({ statusCode: 404, @@ -137,11 +322,15 @@ export default function createDeleteTests({ getService }: FtrProviderContext) { case 'no_kibana_privileges at space1': case 'space_1_all at space2': case 'global_read at space1': - expect(response.statusCode).to.eql(404); + expect(response.statusCode).to.eql(403); expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'delete', + 'test.noop', + 'alertsFixture' + ), + statusCode: 403, }); objectRemover.add(space.id, createdAlert.id, 'alert', 'alerts'); // Ensure task still exists @@ -149,6 +338,8 @@ export default function createDeleteTests({ getService }: FtrProviderContext) { break; case 'superuser at space1': case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(204); expect(response.body).to.eql(''); try { diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/disable.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/disable.ts index 589942a7ac11..4e4f9053bd24 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/disable.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/disable.ts @@ -13,6 +13,8 @@ import { getUrlPrefix, getTestAlertData, ObjectRemover, + getConsumerUnauthorizedErrorMessage, + getProducerUnauthorizedErrorMessage, } from '../../../common/lib'; // eslint-disable-next-line import/no-default-export @@ -39,10 +41,32 @@ export default function createDisableAlertTests({ getService }: FtrProviderConte describe(scenario.id, () => { it('should handle disable alert request appropriately', async () => { + const { body: createdAction } = await supertest + .post(`${getUrlPrefix(space.id)}/api/actions/action`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'MY action', + actionTypeId: 'test.noop', + config: {}, + secrets: {}, + }) + .expect(200); + const { body: createdAlert } = await supertest .post(`${getUrlPrefix(space.id)}/api/alerts/alert`) .set('kbn-xsrf', 'foo') - .send(getTestAlertData({ enabled: true })) + .send( + getTestAlertData({ + enabled: true, + actions: [ + { + id: createdAction.id, + group: 'default', + params: {}, + }, + ], + }) + ) .expect(200); objectRemover.add(space.id, createdAlert.id, 'alert', 'alerts'); @@ -52,17 +76,23 @@ export default function createDisableAlertTests({ getService }: FtrProviderConte case 'no_kibana_privileges at space1': case 'space_1_all at space2': case 'global_read at space1': - expect(response.statusCode).to.eql(404); + expect(response.statusCode).to.eql(403); expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'disable', + 'test.noop', + 'alertsFixture' + ), + statusCode: 403, }); // Ensure task still exists await getScheduledTask(createdAlert.scheduledTaskId); break; + case 'space_1_all_alerts_none_actions at space1': case 'superuser at space1': case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(204); expect(response.body).to.eql(''); try { @@ -84,6 +114,171 @@ export default function createDisableAlertTests({ getService }: FtrProviderConte } }); + it('should handle disable alert request appropriately when consumer is the same as producer', async () => { + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + alertTypeId: 'test.restricted-noop', + consumer: 'alertsRestrictedFixture', + enabled: true, + }) + ) + .expect(200); + objectRemover.add(space.id, createdAlert.id, 'alert', 'alerts'); + + const response = await alertUtils.getDisableRequest(createdAlert.id); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + case 'global_read at space1': + case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'disable', + 'test.restricted-noop', + 'alertsRestrictedFixture' + ), + statusCode: 403, + }); + break; + case 'superuser at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(204); + expect(response.body).to.eql(''); + try { + await getScheduledTask(createdAlert.scheduledTaskId); + throw new Error('Should have removed scheduled task'); + } catch (e) { + expect(e.status).to.eql(404); + } + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle disable alert request appropriately when consumer is not the producer', async () => { + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + alertTypeId: 'test.unrestricted-noop', + consumer: 'alertsFixture', + enabled: true, + }) + ) + .expect(200); + objectRemover.add(space.id, createdAlert.id, 'alert', 'alerts'); + + const response = await alertUtils.getDisableRequest(createdAlert.id); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + case 'global_read at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'disable', + 'test.unrestricted-noop', + 'alertsFixture' + ), + statusCode: 403, + }); + break; + case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getProducerUnauthorizedErrorMessage( + 'disable', + 'test.unrestricted-noop', + 'alertsRestrictedFixture' + ), + statusCode: 403, + }); + break; + case 'superuser at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(204); + expect(response.body).to.eql(''); + try { + await getScheduledTask(createdAlert.scheduledTaskId); + throw new Error('Should have removed scheduled task'); + } catch (e) { + expect(e.status).to.eql(404); + } + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle disable alert request appropriately when consumer is "alerts"', async () => { + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + alertTypeId: 'test.noop', + consumer: 'alerts', + enabled: true, + }) + ) + .expect(200); + objectRemover.add(space.id, createdAlert.id, 'alert', 'alerts'); + + const response = await alertUtils.getDisableRequest(createdAlert.id); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage('disable', 'test.noop', 'alerts'), + statusCode: 403, + }); + break; + case 'global_read at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getProducerUnauthorizedErrorMessage( + 'disable', + 'test.noop', + 'alertsFixture' + ), + statusCode: 403, + }); + break; + case 'superuser at space1': + case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(204); + expect(response.body).to.eql(''); + try { + await getScheduledTask(createdAlert.scheduledTaskId); + throw new Error('Should have removed scheduled task'); + } catch (e) { + expect(e.status).to.eql(404); + } + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + it('should still be able to disable alert when AAD is broken', async () => { const { body: createdAlert } = await supertest .post(`${getUrlPrefix(space.id)}/api/alerts/alert`) @@ -110,17 +305,23 @@ export default function createDisableAlertTests({ getService }: FtrProviderConte case 'no_kibana_privileges at space1': case 'space_1_all at space2': case 'global_read at space1': - expect(response.statusCode).to.eql(404); + expect(response.statusCode).to.eql(403); expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'disable', + 'test.noop', + 'alertsFixture' + ), + statusCode: 403, }); // Ensure task still exists await getScheduledTask(createdAlert.scheduledTaskId); break; case 'superuser at space1': case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(204); expect(response.body).to.eql(''); try { @@ -157,14 +358,10 @@ export default function createDisableAlertTests({ getService }: FtrProviderConte case 'no_kibana_privileges at space1': case 'space_1_all at space2': case 'global_read at space1': - expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', - }); - break; case 'superuser at space1': case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.body).to.eql({ statusCode: 404, error: 'Not Found', diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/enable.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/enable.ts index 8cb0eeb0092a..d7f6546bf34a 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/enable.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/enable.ts @@ -13,6 +13,8 @@ import { getUrlPrefix, getTestAlertData, ObjectRemover, + getConsumerUnauthorizedErrorMessage, + getProducerUnauthorizedErrorMessage, } from '../../../common/lib'; // eslint-disable-next-line import/no-default-export @@ -39,10 +41,32 @@ export default function createEnableAlertTests({ getService }: FtrProviderContex describe(scenario.id, () => { it('should handle enable alert request appropriately', async () => { + const { body: createdAction } = await supertest + .post(`${getUrlPrefix(space.id)}/api/actions/action`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'MY action', + actionTypeId: 'test.noop', + config: {}, + secrets: {}, + }) + .expect(200); + const { body: createdAlert } = await supertest .post(`${getUrlPrefix(space.id)}/api/alerts/alert`) .set('kbn-xsrf', 'foo') - .send(getTestAlertData({ enabled: false })) + .send( + getTestAlertData({ + enabled: false, + actions: [ + { + id: createdAction.id, + group: 'default', + params: {}, + }, + ], + }) + ) .expect(200); objectRemover.add(space.id, createdAlert.id, 'alert', 'alerts'); @@ -51,16 +75,40 @@ export default function createEnableAlertTests({ getService }: FtrProviderContex switch (scenario.id) { case 'no_kibana_privileges at space1': case 'space_1_all at space2': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'enable', + 'test.noop', + 'alertsFixture' + ), + statusCode: 403, + }); + break; case 'global_read at space1': - expect(response.statusCode).to.eql(404); + expect(response.statusCode).to.eql(403); expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'enable', + 'test.noop', + 'alertsFixture' + ), + statusCode: 403, + }); + break; + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: `Unauthorized to execute actions`, + statusCode: 403, }); break; case 'superuser at space1': case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(204); expect(response.body).to.eql(''); const { body: updatedAlert } = await supertestWithoutAuth @@ -89,6 +137,165 @@ export default function createEnableAlertTests({ getService }: FtrProviderContex } }); + it('should handle enable alert request appropriately when consumer is the same as producer', async () => { + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + alertTypeId: 'test.restricted-noop', + consumer: 'alertsRestrictedFixture', + enabled: false, + }) + ) + .expect(200); + objectRemover.add(space.id, createdAlert.id, 'alert', 'alerts'); + + const response = await alertUtils.getEnableRequest(createdAlert.id); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + case 'global_read at space1': + case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'enable', + 'test.restricted-noop', + 'alertsRestrictedFixture' + ), + statusCode: 403, + }); + break; + case 'superuser at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(204); + expect(response.body).to.eql(''); + try { + await getScheduledTask(createdAlert.scheduledTaskId); + throw new Error('Should have removed scheduled task'); + } catch (e) { + expect(e.status).to.eql(404); + } + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle enable alert request appropriately when consumer is not the producer', async () => { + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + alertTypeId: 'test.unrestricted-noop', + consumer: 'alertsFixture', + enabled: false, + }) + ) + .expect(200); + objectRemover.add(space.id, createdAlert.id, 'alert', 'alerts'); + + const response = await alertUtils.getEnableRequest(createdAlert.id); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + case 'global_read at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'enable', + 'test.unrestricted-noop', + 'alertsFixture' + ), + statusCode: 403, + }); + break; + case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getProducerUnauthorizedErrorMessage( + 'enable', + 'test.unrestricted-noop', + 'alertsRestrictedFixture' + ), + statusCode: 403, + }); + break; + case 'superuser at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(204); + expect(response.body).to.eql(''); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle enable alert request appropriately when consumer is "alerts"', async () => { + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + alertTypeId: 'test.noop', + consumer: 'alerts', + enabled: false, + }) + ) + .expect(200); + objectRemover.add(space.id, createdAlert.id, 'alert', 'alerts'); + + const response = await alertUtils.getEnableRequest(createdAlert.id); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage('enable', 'test.noop', 'alerts'), + statusCode: 403, + }); + break; + case 'global_read at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getProducerUnauthorizedErrorMessage( + 'enable', + 'test.noop', + 'alertsFixture' + ), + statusCode: 403, + }); + break; + case 'superuser at space1': + case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(204); + expect(response.body).to.eql(''); + try { + await getScheduledTask(createdAlert.scheduledTaskId); + throw new Error('Should have removed scheduled task'); + } catch (e) { + expect(e.status).to.eql(404); + } + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + it('should still be able to enable alert when AAD is broken', async () => { const { body: createdAlert } = await supertest .post(`${getUrlPrefix(space.id)}/api/alerts/alert`) @@ -115,15 +322,21 @@ export default function createEnableAlertTests({ getService }: FtrProviderContex case 'no_kibana_privileges at space1': case 'space_1_all at space2': case 'global_read at space1': - expect(response.statusCode).to.eql(404); + expect(response.statusCode).to.eql(403); expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'enable', + 'test.noop', + 'alertsFixture' + ), + statusCode: 403, }); break; case 'superuser at space1': case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(204); expect(response.body).to.eql(''); const { body: updatedAlert } = await supertestWithoutAuth @@ -167,14 +380,10 @@ export default function createEnableAlertTests({ getService }: FtrProviderContex case 'no_kibana_privileges at space1': case 'space_1_all at space2': case 'global_read at space1': - expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', - }); - break; case 'superuser at space1': case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.body).to.eql({ statusCode: 404, error: 'Not Found', diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/find.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/find.ts index 5fe9edeb91ec..268212d4294d 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/find.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/find.ts @@ -5,6 +5,8 @@ */ import expect from '@kbn/expect'; +import { chunk, omit } from 'lodash'; +import uuid from 'uuid'; import { UserAtSpaceScenarios } from '../../scenarios'; import { getUrlPrefix, getTestAlertData, ObjectRemover } from '../../../common/lib'; import { FtrProviderContext } from '../../../common/ftr_provider_context'; @@ -41,16 +43,18 @@ export default function createFindTests({ getService }: FtrProviderContext) { switch (scenario.id) { case 'no_kibana_privileges at space1': case 'space_1_all at space2': - expect(response.statusCode).to.eql(404); + expect(response.statusCode).to.eql(403); expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', + error: 'Forbidden', + message: `Unauthorized to find any alert types`, + statusCode: 403, }); break; case 'global_read at space1': case 'superuser at space1': case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(200); expect(response.body.page).to.equal(1); expect(response.body.perPage).to.be.greaterThan(0); @@ -61,7 +65,7 @@ export default function createFindTests({ getService }: FtrProviderContext) { name: 'abc', tags: ['foo'], alertTypeId: 'test.noop', - consumer: 'bar', + consumer: 'alertsFixture', schedule: { interval: '1m' }, enabled: true, actions: [], @@ -84,6 +88,108 @@ export default function createFindTests({ getService }: FtrProviderContext) { } }); + it('should filter out types that the user is not authorized to `get` retaining pagination', async () => { + async function createNoOpAlert(overrides = {}) { + const alert = getTestAlertData(overrides); + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send(alert) + .expect(200); + objectRemover.add(space.id, createdAlert.id, 'alert', 'alerts'); + return { + id: createdAlert.id, + alertTypeId: alert.alertTypeId, + }; + } + function createRestrictedNoOpAlert() { + return createNoOpAlert({ + alertTypeId: 'test.restricted-noop', + consumer: 'alertsRestrictedFixture', + }); + } + function createUnrestrictedNoOpAlert() { + return createNoOpAlert({ + alertTypeId: 'test.unrestricted-noop', + consumer: 'alertsFixture', + }); + } + const allAlerts = []; + allAlerts.push(await createNoOpAlert()); + allAlerts.push(await createNoOpAlert()); + allAlerts.push(await createRestrictedNoOpAlert()); + allAlerts.push(await createUnrestrictedNoOpAlert()); + allAlerts.push(await createUnrestrictedNoOpAlert()); + allAlerts.push(await createRestrictedNoOpAlert()); + allAlerts.push(await createNoOpAlert()); + allAlerts.push(await createNoOpAlert()); + + const perPage = 4; + + const response = await supertestWithoutAuth + .get( + `${getUrlPrefix(space.id)}/api/alerts/_find?per_page=${perPage}&sort_field=createdAt` + ) + .auth(user.username, user.password); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: `Unauthorized to find any alert types`, + statusCode: 403, + }); + break; + case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(200); + expect(response.body.page).to.equal(1); + expect(response.body.perPage).to.be.equal(perPage); + expect(response.body.total).to.be.equal(6); + { + const [firstPage] = chunk( + allAlerts + .filter((alert) => alert.alertTypeId !== 'test.restricted-noop') + .map((alert) => alert.id), + perPage + ); + expect(response.body.data.map((alert: any) => alert.id)).to.eql(firstPage); + } + break; + case 'global_read at space1': + case 'superuser at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(200); + expect(response.body.page).to.equal(1); + expect(response.body.perPage).to.be.equal(perPage); + expect(response.body.total).to.be.equal(8); + + { + const [firstPage, secondPage] = chunk( + allAlerts.map((alert) => alert.id), + perPage + ); + expect(response.body.data.map((alert: any) => alert.id)).to.eql(firstPage); + + const secondResponse = await supertestWithoutAuth + .get( + `${getUrlPrefix( + space.id + )}/api/alerts/_find?per_page=${perPage}&sort_field=createdAt&page=2` + ) + .auth(user.username, user.password); + + expect(secondResponse.body.data.map((alert: any) => alert.id)).to.eql(secondPage); + } + + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + it('should handle find alert request with filter appropriately', async () => { const { body: createdAction } = await supertest .post(`${getUrlPrefix(space.id)}/api/actions/action`) @@ -125,16 +231,18 @@ export default function createFindTests({ getService }: FtrProviderContext) { switch (scenario.id) { case 'no_kibana_privileges at space1': case 'space_1_all at space2': - expect(response.statusCode).to.eql(404); + expect(response.statusCode).to.eql(403); expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', + error: 'Forbidden', + message: `Unauthorized to find any alert types`, + statusCode: 403, }); break; case 'global_read at space1': case 'superuser at space1': case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(200); expect(response.body.page).to.equal(1); expect(response.body.perPage).to.be.greaterThan(0); @@ -145,7 +253,7 @@ export default function createFindTests({ getService }: FtrProviderContext) { name: 'abc', tags: ['foo'], alertTypeId: 'test.noop', - consumer: 'bar', + consumer: 'alertsFixture', schedule: { interval: '1m' }, enabled: false, actions: [ @@ -174,6 +282,83 @@ export default function createFindTests({ getService }: FtrProviderContext) { } }); + it('should handle find alert request with fields appropriately', async () => { + const myTag = uuid.v4(); + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + enabled: false, + tags: [myTag], + alertTypeId: 'test.restricted-noop', + consumer: 'alertsRestrictedFixture', + }) + ) + .expect(200); + objectRemover.add(space.id, createdAlert.id, 'alert', 'alerts'); + + // create another type with same tag + const { body: createdSecondAlert } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + tags: [myTag], + alertTypeId: 'test.restricted-noop', + consumer: 'alertsRestrictedFixture', + }) + ) + .expect(200); + objectRemover.add(space.id, createdSecondAlert.id, 'alert', 'alerts'); + + const response = await supertestWithoutAuth + .get( + `${getUrlPrefix( + space.id + )}/api/alerts/_find?filter=alert.attributes.alertTypeId:test.restricted-noop&fields=["tags"]&sort_field=createdAt` + ) + .auth(user.username, user.password); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: `Unauthorized to find any alert types`, + statusCode: 403, + }); + break; + case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(200); + expect(response.body.data).to.eql([]); + break; + case 'global_read at space1': + case 'superuser at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(200); + expect(response.body.page).to.equal(1); + expect(response.body.perPage).to.be.greaterThan(0); + expect(response.body.total).to.be.greaterThan(0); + const [matchFirst, matchSecond] = response.body.data; + expect(omit(matchFirst, 'updatedAt')).to.eql({ + id: createdAlert.id, + actions: [], + tags: [myTag], + }); + expect(omit(matchSecond, 'updatedAt')).to.eql({ + id: createdSecondAlert.id, + actions: [], + tags: [myTag], + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + it(`shouldn't find alert from another space`, async () => { const { body: createdAlert } = await supertest .post(`${getUrlPrefix(space.id)}/api/alerts/alert`) @@ -192,11 +377,13 @@ export default function createFindTests({ getService }: FtrProviderContext) { case 'no_kibana_privileges at space1': case 'space_1_all at space2': case 'space_1_all at space1': - expect(response.statusCode).to.eql(404); + case 'space_1_all_alerts_none_actions at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(403); expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', + error: 'Forbidden', + message: `Unauthorized to find any alert types`, + statusCode: 403, }); break; case 'global_read at space1': diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/get.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/get.ts index a203b7d7c151..1043ece08a2a 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/get.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/get.ts @@ -6,7 +6,13 @@ import expect from '@kbn/expect'; import { UserAtSpaceScenarios } from '../../scenarios'; -import { getUrlPrefix, getTestAlertData, ObjectRemover } from '../../../common/lib'; +import { + getUrlPrefix, + getTestAlertData, + ObjectRemover, + getConsumerUnauthorizedErrorMessage, + getProducerUnauthorizedErrorMessage, +} from '../../../common/lib'; import { FtrProviderContext } from '../../../common/ftr_provider_context'; // eslint-disable-next-line import/no-default-export @@ -37,23 +43,25 @@ export default function createGetTests({ getService }: FtrProviderContext) { switch (scenario.id) { case 'no_kibana_privileges at space1': case 'space_1_all at space2': - expect(response.statusCode).to.eql(404); + expect(response.statusCode).to.eql(403); expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage('get', 'test.noop', 'alertsFixture'), + statusCode: 403, }); break; case 'global_read at space1': case 'superuser at space1': case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(200); expect(response.body).to.eql({ id: createdAlert.id, name: 'abc', tags: ['foo'], alertTypeId: 'test.noop', - consumer: 'bar', + consumer: 'alertsFixture', schedule: { interval: '1m' }, enabled: true, actions: [], @@ -76,6 +84,157 @@ export default function createGetTests({ getService }: FtrProviderContext) { } }); + it('should handle get alert request appropriately when consumer is the same as producer', async () => { + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + alertTypeId: 'test.restricted-noop', + consumer: 'alertsRestrictedFixture', + }) + ) + .expect(200); + objectRemover.add(space.id, createdAlert.id, 'alert', 'alerts'); + + const response = await supertestWithoutAuth + .get(`${getUrlPrefix(space.id)}/api/alerts/alert/${createdAlert.id}`) + .auth(user.username, user.password); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'get', + 'test.restricted-noop', + 'alertsRestrictedFixture' + ), + statusCode: 403, + }); + break; + case 'global_read at space1': + case 'superuser at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(200); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle get alert request appropriately when consumer is not the producer', async () => { + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + alertTypeId: 'test.unrestricted-noop', + consumer: 'alertsFixture', + }) + ) + .expect(200); + objectRemover.add(space.id, createdAlert.id, 'alert', 'alerts'); + + const response = await supertestWithoutAuth + .get(`${getUrlPrefix(space.id)}/api/alerts/alert/${createdAlert.id}`) + .auth(user.username, user.password); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'get', + 'test.unrestricted-noop', + 'alertsFixture' + ), + statusCode: 403, + }); + break; + case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getProducerUnauthorizedErrorMessage( + 'get', + 'test.unrestricted-noop', + 'alertsRestrictedFixture' + ), + statusCode: 403, + }); + break; + case 'superuser at space1': + case 'global_read at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(200); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle get alert request appropriately when consumer is "alerts"', async () => { + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + alertTypeId: 'test.restricted-noop', + consumer: 'alerts', + }) + ) + .expect(200); + objectRemover.add(space.id, createdAlert.id, 'alert', 'alerts'); + + const response = await supertestWithoutAuth + .get(`${getUrlPrefix(space.id)}/api/alerts/alert/${createdAlert.id}`) + .auth(user.username, user.password); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'get', + 'test.restricted-noop', + 'alerts' + ), + statusCode: 403, + }); + break; + case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getProducerUnauthorizedErrorMessage( + 'get', + 'test.restricted-noop', + 'alertsRestrictedFixture' + ), + statusCode: 403, + }); + break; + case 'global_read at space1': + case 'superuser at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(200); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + it(`shouldn't get alert from another space`, async () => { const { body: createdAlert } = await supertest .post(`${getUrlPrefix(space.id)}/api/alerts/alert`) @@ -93,12 +252,8 @@ export default function createGetTests({ getService }: FtrProviderContext) { case 'no_kibana_privileges at space1': case 'space_1_all at space2': case 'space_1_all at space1': - expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', - }); - break; + case 'space_1_all_alerts_none_actions at space1': + case 'space_1_all_with_restricted_fixture at space1': case 'global_read at space1': case 'superuser at space1': expect(response.body).to.eql({ @@ -120,16 +275,11 @@ export default function createGetTests({ getService }: FtrProviderContext) { switch (scenario.id) { case 'no_kibana_privileges at space1': case 'space_1_all at space2': - expect(response.statusCode).to.eql(404); - expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', - }); - break; case 'global_read at space1': case 'superuser at space1': case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(404); expect(response.body).to.eql({ statusCode: 404, diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/get_alert_state.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/get_alert_state.ts index fd071bd55b37..2e89aa2961c7 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/get_alert_state.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/get_alert_state.ts @@ -5,7 +5,13 @@ */ import expect from '@kbn/expect'; -import { getUrlPrefix, ObjectRemover, getTestAlertData } from '../../../common/lib'; +import { + getUrlPrefix, + ObjectRemover, + getTestAlertData, + getConsumerUnauthorizedErrorMessage, + getProducerUnauthorizedErrorMessage, +} from '../../../common/lib'; import { FtrProviderContext } from '../../../common/ftr_provider_context'; import { UserAtSpaceScenarios } from '../../scenarios'; @@ -37,16 +43,73 @@ export default function createGetAlertStateTests({ getService }: FtrProviderCont switch (scenario.id) { case 'no_kibana_privileges at space1': case 'space_1_all at space2': - expect(response.statusCode).to.eql(404); + expect(response.statusCode).to.eql(403); expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage('get', 'test.noop', 'alertsFixture'), + statusCode: 403, }); break; case 'global_read at space1': case 'superuser at space1': case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(200); + expect(response.body).to.key('alertInstances', 'previousStartedAt'); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle getAlertState alert request appropriately when unauthorized', async () => { + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + alertTypeId: 'test.unrestricted-noop', + consumer: 'alertsFixture', + }) + ) + .expect(200); + objectRemover.add(space.id, createdAlert.id, 'alert', 'alerts'); + + const response = await supertestWithoutAuth + .get(`${getUrlPrefix(space.id)}/api/alerts/alert/${createdAlert.id}/state`) + .auth(user.username, user.password); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'get', + 'test.unrestricted-noop', + 'alertsFixture' + ), + statusCode: 403, + }); + break; + case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getProducerUnauthorizedErrorMessage( + 'get', + 'test.unrestricted-noop', + 'alertsRestrictedFixture' + ), + statusCode: 403, + }); + break; + case 'global_read at space1': + case 'superuser at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(200); expect(response.body).to.key('alertInstances', 'previousStartedAt'); break; @@ -72,12 +135,8 @@ export default function createGetAlertStateTests({ getService }: FtrProviderCont case 'no_kibana_privileges at space1': case 'space_1_all at space2': case 'space_1_all at space1': - expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', - }); - break; + case 'space_1_all_alerts_none_actions at space1': + case 'space_1_all_with_restricted_fixture at space1': case 'global_read at space1': case 'superuser at space1': expect(response.body).to.eql({ @@ -99,16 +158,11 @@ export default function createGetAlertStateTests({ getService }: FtrProviderCont switch (scenario.id) { case 'no_kibana_privileges at space1': case 'space_1_all at space2': - expect(response.statusCode).to.eql(404); - expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', - }); - break; case 'global_read at space1': case 'superuser at space1': case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(404); expect(response.body).to.eql({ statusCode: 404, diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/index.ts index f14f66f66fe2..4cd5f0805121 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/index.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/index.ts @@ -9,11 +9,11 @@ import { FtrProviderContext } from '../../../common/ftr_provider_context'; // eslint-disable-next-line import/no-default-export export default function alertingTests({ loadTestFile }: FtrProviderContext) { describe('Alerts', () => { + loadTestFile(require.resolve('./find')); loadTestFile(require.resolve('./create')); loadTestFile(require.resolve('./delete')); loadTestFile(require.resolve('./disable')); loadTestFile(require.resolve('./enable')); - loadTestFile(require.resolve('./find')); loadTestFile(require.resolve('./get')); loadTestFile(require.resolve('./get_alert_state')); loadTestFile(require.resolve('./list_alert_types')); diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/list_alert_types.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/list_alert_types.ts index dd31e2dbbb5b..c3e5af0d1f77 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/list_alert_types.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/list_alert_types.ts @@ -5,6 +5,7 @@ */ import expect from '@kbn/expect'; +import { omit } from 'lodash'; import { UserAtSpaceScenarios } from '../../scenarios'; import { getUrlPrefix } from '../../../common/lib/space_test_utils'; import { FtrProviderContext } from '../../../common/ftr_provider_context'; @@ -13,42 +14,125 @@ import { FtrProviderContext } from '../../../common/ftr_provider_context'; export default function listAlertTypes({ getService }: FtrProviderContext) { const supertestWithoutAuth = getService('supertestWithoutAuth'); + const expectedNoOpType = { + actionGroups: [{ id: 'default', name: 'Default' }], + defaultActionGroupId: 'default', + id: 'test.noop', + name: 'Test: Noop', + actionVariables: { + state: [], + context: [], + }, + producer: 'alertsFixture', + }; + + const expectedRestrictedNoOpType = { + actionGroups: [{ id: 'default', name: 'Default' }], + defaultActionGroupId: 'default', + id: 'test.restricted-noop', + name: 'Test: Restricted Noop', + actionVariables: { + state: [], + context: [], + }, + producer: 'alertsRestrictedFixture', + }; + describe('list_alert_types', () => { for (const scenario of UserAtSpaceScenarios) { const { user, space } = scenario; describe(scenario.id, () => { - it('should return 200 with list of alert types', async () => { + it('should return 200 with list of globally available alert types', async () => { const response = await supertestWithoutAuth .get(`${getUrlPrefix(space.id)}/api/alerts/list_alert_types`) .auth(user.username, user.password); + expect(response.statusCode).to.eql(200); + const noOpAlertType = response.body.find( + (alertType: any) => alertType.id === 'test.noop' + ); + const restrictedNoOpAlertType = response.body.find( + (alertType: any) => alertType.id === 'test.restricted-noop' + ); switch (scenario.id) { case 'no_kibana_privileges at space1': case 'space_1_all at space2': - expect(response.statusCode).to.eql(404); - expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', + expect(response.body).to.eql([]); + break; + case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + expect(omit(noOpAlertType, 'authorizedConsumers')).to.eql(expectedNoOpType); + expect(restrictedNoOpAlertType).to.eql(undefined); + expect(noOpAlertType.authorizedConsumers).to.eql({ + alerts: { read: true, all: true }, + alertsFixture: { read: true, all: true }, }); break; case 'global_read at space1': + expect(omit(noOpAlertType, 'authorizedConsumers')).to.eql(expectedNoOpType); + expect(noOpAlertType.authorizedConsumers.alertsFixture).to.eql({ + read: true, + all: false, + }); + expect(noOpAlertType.authorizedConsumers.alertsRestrictedFixture).to.eql({ + read: true, + all: false, + }); + + expect(omit(restrictedNoOpAlertType, 'authorizedConsumers')).to.eql( + expectedRestrictedNoOpType + ); + expect(Object.keys(restrictedNoOpAlertType.authorizedConsumers)).not.to.contain( + 'alertsFixture' + ); + expect(restrictedNoOpAlertType.authorizedConsumers.alertsRestrictedFixture).to.eql({ + read: true, + all: false, + }); + break; + case 'space_1_all_with_restricted_fixture at space1': + expect(omit(noOpAlertType, 'authorizedConsumers')).to.eql(expectedNoOpType); + expect(noOpAlertType.authorizedConsumers.alertsFixture).to.eql({ + read: true, + all: true, + }); + expect(noOpAlertType.authorizedConsumers.alertsRestrictedFixture).to.eql({ + read: true, + all: true, + }); + + expect(omit(restrictedNoOpAlertType, 'authorizedConsumers')).to.eql( + expectedRestrictedNoOpType + ); + expect(Object.keys(restrictedNoOpAlertType.authorizedConsumers)).not.to.contain( + 'alertsFixture' + ); + expect(restrictedNoOpAlertType.authorizedConsumers.alertsRestrictedFixture).to.eql({ + read: true, + all: true, + }); + break; case 'superuser at space1': - case 'space_1_all at space1': - expect(response.statusCode).to.eql(200); - const fixtureAlertType = response.body.find( - (alertType: any) => alertType.id === 'test.noop' + expect(omit(noOpAlertType, 'authorizedConsumers')).to.eql(expectedNoOpType); + expect(noOpAlertType.authorizedConsumers.alertsFixture).to.eql({ + read: true, + all: true, + }); + expect(noOpAlertType.authorizedConsumers.alertsRestrictedFixture).to.eql({ + read: true, + all: true, + }); + + expect(omit(restrictedNoOpAlertType, 'authorizedConsumers')).to.eql( + expectedRestrictedNoOpType ); - expect(fixtureAlertType).to.eql({ - actionGroups: [{ id: 'default', name: 'Default' }], - defaultActionGroupId: 'default', - id: 'test.noop', - name: 'Test: Noop', - actionVariables: { - state: [], - context: [], - }, - producer: 'alerting', + expect(noOpAlertType.authorizedConsumers.alertsFixture).to.eql({ + read: true, + all: true, + }); + expect(noOpAlertType.authorizedConsumers.alertsRestrictedFixture).to.eql({ + read: true, + all: true, }); break; default: diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/mute_all.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/mute_all.ts index 2416bc2ea1d1..a497affa266e 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/mute_all.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/mute_all.ts @@ -13,6 +13,8 @@ import { getUrlPrefix, getTestAlertData, ObjectRemover, + getConsumerUnauthorizedErrorMessage, + getProducerUnauthorizedErrorMessage, } from '../../../common/lib'; // eslint-disable-next-line import/no-default-export @@ -31,10 +33,151 @@ export default function createMuteAlertTests({ getService }: FtrProviderContext) describe(scenario.id, () => { it('should handle mute alert request appropriately', async () => { + const { body: createdAction } = await supertest + .post(`${getUrlPrefix(space.id)}/api/actions/action`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'MY action', + actionTypeId: 'test.noop', + config: {}, + secrets: {}, + }) + .expect(200); + + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + enabled: false, + actions: [ + { + id: createdAction.id, + group: 'default', + params: {}, + }, + ], + }) + ) + .expect(200); + objectRemover.add(space.id, createdAlert.id, 'alert', 'alerts'); + + const response = await alertUtils.getMuteAllRequest(createdAlert.id); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + case 'global_read at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'muteAll', + 'test.noop', + 'alertsFixture' + ), + statusCode: 403, + }); + break; + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: `Unauthorized to execute actions`, + statusCode: 403, + }); + break; + case 'superuser at space1': + case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(204); + expect(response.body).to.eql(''); + const { body: updatedAlert } = await supertestWithoutAuth + .get(`${getUrlPrefix(space.id)}/api/alerts/alert/${createdAlert.id}`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .expect(200); + expect(updatedAlert.muteAll).to.eql(true); + // Ensure AAD isn't broken + await checkAAD({ + supertest, + spaceId: space.id, + type: 'alert', + id: createdAlert.id, + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle mute alert request appropriately when consumer is the same as producer', async () => { + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + enabled: false, + alertTypeId: 'test.restricted-noop', + consumer: 'alertsRestrictedFixture', + }) + ) + .expect(200); + objectRemover.add(space.id, createdAlert.id, 'alert', 'alerts'); + + const response = await alertUtils.getMuteAllRequest(createdAlert.id); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + case 'global_read at space1': + case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'muteAll', + 'test.restricted-noop', + 'alertsRestrictedFixture' + ), + statusCode: 403, + }); + break; + case 'superuser at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(204); + expect(response.body).to.eql(''); + const { body: updatedAlert } = await supertestWithoutAuth + .get(`${getUrlPrefix(space.id)}/api/alerts/alert/${createdAlert.id}`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .expect(200); + expect(updatedAlert.muteAll).to.eql(true); + // Ensure AAD isn't broken + await checkAAD({ + supertest, + spaceId: space.id, + type: 'alert', + id: createdAlert.id, + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle mute alert request appropriately when consumer is not the producer', async () => { const { body: createdAlert } = await supertest .post(`${getUrlPrefix(space.id)}/api/alerts/alert`) .set('kbn-xsrf', 'foo') - .send(getTestAlertData({ enabled: false })) + .send( + getTestAlertData({ + enabled: false, + alertTypeId: 'test.unrestricted-noop', + consumer: 'alertsFixture', + }) + ) .expect(200); objectRemover.add(space.id, createdAlert.id, 'alert', 'alerts'); @@ -44,15 +187,99 @@ export default function createMuteAlertTests({ getService }: FtrProviderContext) case 'no_kibana_privileges at space1': case 'space_1_all at space2': case 'global_read at space1': - expect(response.statusCode).to.eql(404); + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'muteAll', + 'test.unrestricted-noop', + 'alertsFixture' + ), + statusCode: 403, + }); + break; + case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(403); expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', + error: 'Forbidden', + message: getProducerUnauthorizedErrorMessage( + 'muteAll', + 'test.unrestricted-noop', + 'alertsRestrictedFixture' + ), + statusCode: 403, }); break; case 'superuser at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(204); + expect(response.body).to.eql(''); + const { body: updatedAlert } = await supertestWithoutAuth + .get(`${getUrlPrefix(space.id)}/api/alerts/alert/${createdAlert.id}`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .expect(200); + expect(updatedAlert.muteAll).to.eql(true); + // Ensure AAD isn't broken + await checkAAD({ + supertest, + spaceId: space.id, + type: 'alert', + id: createdAlert.id, + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle mute alert request appropriately when consumer is "alerts"', async () => { + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + enabled: false, + alertTypeId: 'test.restricted-noop', + consumer: 'alerts', + }) + ) + .expect(200); + objectRemover.add(space.id, createdAlert.id, 'alert', 'alerts'); + + const response = await alertUtils.getMuteAllRequest(createdAlert.id); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'muteAll', + 'test.restricted-noop', + 'alerts' + ), + statusCode: 403, + }); + break; + case 'global_read at space1': case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getProducerUnauthorizedErrorMessage( + 'muteAll', + 'test.restricted-noop', + 'alertsRestrictedFixture' + ), + statusCode: 403, + }); + break; + case 'superuser at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(204); expect(response.body).to.eql(''); const { body: updatedAlert } = await supertestWithoutAuth diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/mute_instance.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/mute_instance.ts index c59b9f4503a0..b4277479d8fd 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/mute_instance.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/mute_instance.ts @@ -13,6 +13,8 @@ import { getUrlPrefix, getTestAlertData, ObjectRemover, + getConsumerUnauthorizedErrorMessage, + getProducerUnauthorizedErrorMessage, } from '../../../common/lib'; // eslint-disable-next-line import/no-default-export @@ -31,10 +33,32 @@ export default function createMuteAlertInstanceTests({ getService }: FtrProvider describe(scenario.id, () => { it('should handle mute alert instance request appropriately', async () => { + const { body: createdAction } = await supertest + .post(`${getUrlPrefix(space.id)}/api/actions/action`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'MY action', + actionTypeId: 'test.noop', + config: {}, + secrets: {}, + }) + .expect(200); + const { body: createdAlert } = await supertest .post(`${getUrlPrefix(space.id)}/api/alerts/alert`) .set('kbn-xsrf', 'foo') - .send(getTestAlertData({ enabled: false })) + .send( + getTestAlertData({ + enabled: false, + actions: [ + { + id: createdAction.id, + group: 'default', + params: {}, + }, + ], + }) + ) .expect(200); objectRemover.add(space.id, createdAlert.id, 'alert', 'alerts'); @@ -44,15 +68,218 @@ export default function createMuteAlertInstanceTests({ getService }: FtrProvider case 'no_kibana_privileges at space1': case 'space_1_all at space2': case 'global_read at space1': - expect(response.statusCode).to.eql(404); + expect(response.statusCode).to.eql(403); expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'muteInstance', + 'test.noop', + 'alertsFixture' + ), + statusCode: 403, + }); + break; + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: `Unauthorized to execute actions`, + statusCode: 403, }); break; case 'superuser at space1': case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(204); + expect(response.body).to.eql(''); + const { body: updatedAlert } = await supertestWithoutAuth + .get(`${getUrlPrefix(space.id)}/api/alerts/alert/${createdAlert.id}`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .expect(200); + expect(updatedAlert.mutedInstanceIds).to.eql(['1']); + // Ensure AAD isn't broken + await checkAAD({ + supertest, + spaceId: space.id, + type: 'alert', + id: createdAlert.id, + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle mute alert instance request appropriately when consumer is the same as producer', async () => { + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + enabled: false, + alertTypeId: 'test.restricted-noop', + consumer: 'alertsRestrictedFixture', + }) + ) + .expect(200); + objectRemover.add(space.id, createdAlert.id, 'alert', 'alerts'); + + const response = await alertUtils.getMuteInstanceRequest(createdAlert.id, '1'); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + case 'global_read at space1': + case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'muteInstance', + 'test.restricted-noop', + 'alertsRestrictedFixture' + ), + statusCode: 403, + }); + break; + case 'superuser at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(204); + expect(response.body).to.eql(''); + const { body: updatedAlert } = await supertestWithoutAuth + .get(`${getUrlPrefix(space.id)}/api/alerts/alert/${createdAlert.id}`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .expect(200); + expect(updatedAlert.mutedInstanceIds).to.eql(['1']); + // Ensure AAD isn't broken + await checkAAD({ + supertest, + spaceId: space.id, + type: 'alert', + id: createdAlert.id, + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle mute alert instance request appropriately when consumer is not the producer', async () => { + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + enabled: false, + alertTypeId: 'test.unrestricted-noop', + consumer: 'alertsFixture', + }) + ) + .expect(200); + objectRemover.add(space.id, createdAlert.id, 'alert', 'alerts'); + + const response = await alertUtils.getMuteInstanceRequest(createdAlert.id, '1'); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + case 'global_read at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'muteInstance', + 'test.unrestricted-noop', + 'alertsFixture' + ), + statusCode: 403, + }); + break; + case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getProducerUnauthorizedErrorMessage( + 'muteInstance', + 'test.unrestricted-noop', + 'alertsRestrictedFixture' + ), + statusCode: 403, + }); + break; + case 'superuser at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(204); + expect(response.body).to.eql(''); + const { body: updatedAlert } = await supertestWithoutAuth + .get(`${getUrlPrefix(space.id)}/api/alerts/alert/${createdAlert.id}`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .expect(200); + expect(updatedAlert.mutedInstanceIds).to.eql(['1']); + // Ensure AAD isn't broken + await checkAAD({ + supertest, + spaceId: space.id, + type: 'alert', + id: createdAlert.id, + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle mute alert instance request appropriately when consumer is "alerts"', async () => { + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + enabled: false, + alertTypeId: 'test.restricted-noop', + consumer: 'alerts', + }) + ) + .expect(200); + objectRemover.add(space.id, createdAlert.id, 'alert', 'alerts'); + + const response = await alertUtils.getMuteInstanceRequest(createdAlert.id, '1'); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'muteInstance', + 'test.restricted-noop', + 'alerts' + ), + statusCode: 403, + }); + break; + case 'global_read at space1': + case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getProducerUnauthorizedErrorMessage( + 'muteInstance', + 'test.restricted-noop', + 'alertsRestrictedFixture' + ), + statusCode: 403, + }); + break; + case 'superuser at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(204); expect(response.body).to.eql(''); const { body: updatedAlert } = await supertestWithoutAuth @@ -95,15 +322,21 @@ export default function createMuteAlertInstanceTests({ getService }: FtrProvider case 'no_kibana_privileges at space1': case 'space_1_all at space2': case 'global_read at space1': - expect(response.statusCode).to.eql(404); + expect(response.statusCode).to.eql(403); expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'muteInstance', + 'test.noop', + 'alertsFixture' + ), + statusCode: 403, }); break; case 'superuser at space1': case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(204); expect(response.body).to.eql(''); const { body: updatedAlert } = await supertestWithoutAuth diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/unmute_all.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/unmute_all.ts index fd22752ccc11..46653900cb1c 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/unmute_all.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/unmute_all.ts @@ -13,6 +13,8 @@ import { getUrlPrefix, getTestAlertData, ObjectRemover, + getConsumerUnauthorizedErrorMessage, + getProducerUnauthorizedErrorMessage, } from '../../../common/lib'; // eslint-disable-next-line import/no-default-export @@ -31,10 +33,32 @@ export default function createUnmuteAlertTests({ getService }: FtrProviderContex describe(scenario.id, () => { it('should handle unmute alert request appropriately', async () => { + const { body: createdAction } = await supertest + .post(`${getUrlPrefix(space.id)}/api/actions/action`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'MY action', + actionTypeId: 'test.noop', + config: {}, + secrets: {}, + }) + .expect(200); + const { body: createdAlert } = await supertest .post(`${getUrlPrefix(space.id)}/api/alerts/alert`) .set('kbn-xsrf', 'foo') - .send(getTestAlertData({ enabled: false })) + .send( + getTestAlertData({ + enabled: false, + actions: [ + { + id: createdAction.id, + group: 'default', + params: {}, + }, + ], + }) + ) .expect(200); objectRemover.add(space.id, createdAlert.id, 'alert', 'alerts'); @@ -49,15 +73,233 @@ export default function createUnmuteAlertTests({ getService }: FtrProviderContex case 'no_kibana_privileges at space1': case 'space_1_all at space2': case 'global_read at space1': - expect(response.statusCode).to.eql(404); + expect(response.statusCode).to.eql(403); expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'unmuteAll', + 'test.noop', + 'alertsFixture' + ), + statusCode: 403, + }); + break; + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: `Unauthorized to execute actions`, + statusCode: 403, }); break; case 'superuser at space1': case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(204); + expect(response.body).to.eql(''); + const { body: updatedAlert } = await supertestWithoutAuth + .get(`${getUrlPrefix(space.id)}/api/alerts/alert/${createdAlert.id}`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .expect(200); + expect(updatedAlert.muteAll).to.eql(false); + // Ensure AAD isn't broken + await checkAAD({ + supertest, + spaceId: space.id, + type: 'alert', + id: createdAlert.id, + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle unmute alert request appropriately when consumer is the same as producer', async () => { + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + enabled: false, + alertTypeId: 'test.restricted-noop', + consumer: 'alertsRestrictedFixture', + }) + ) + .expect(200); + objectRemover.add(space.id, createdAlert.id, 'alert', 'alerts'); + + await supertest + .post(`${getUrlPrefix(space.id)}/api/alerts/alert/${createdAlert.id}/_mute_all`) + .set('kbn-xsrf', 'foo') + .expect(204, ''); + + const response = await alertUtils.getUnmuteAllRequest(createdAlert.id); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + case 'global_read at space1': + case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'unmuteAll', + 'test.restricted-noop', + 'alertsRestrictedFixture' + ), + statusCode: 403, + }); + break; + case 'superuser at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(204); + expect(response.body).to.eql(''); + const { body: updatedAlert } = await supertestWithoutAuth + .get(`${getUrlPrefix(space.id)}/api/alerts/alert/${createdAlert.id}`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .expect(200); + expect(updatedAlert.muteAll).to.eql(false); + // Ensure AAD isn't broken + await checkAAD({ + supertest, + spaceId: space.id, + type: 'alert', + id: createdAlert.id, + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle unmute alert request appropriately when consumer is not the producer', async () => { + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + enabled: false, + alertTypeId: 'test.unrestricted-noop', + consumer: 'alertsFixture', + }) + ) + .expect(200); + objectRemover.add(space.id, createdAlert.id, 'alert', 'alerts'); + + await supertest + .post(`${getUrlPrefix(space.id)}/api/alerts/alert/${createdAlert.id}/_mute_all`) + .set('kbn-xsrf', 'foo') + .expect(204, ''); + + const response = await alertUtils.getUnmuteAllRequest(createdAlert.id); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + case 'global_read at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'unmuteAll', + 'test.unrestricted-noop', + 'alertsFixture' + ), + statusCode: 403, + }); + break; + case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getProducerUnauthorizedErrorMessage( + 'unmuteAll', + 'test.unrestricted-noop', + 'alertsRestrictedFixture' + ), + statusCode: 403, + }); + break; + case 'superuser at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(204); + expect(response.body).to.eql(''); + const { body: updatedAlert } = await supertestWithoutAuth + .get(`${getUrlPrefix(space.id)}/api/alerts/alert/${createdAlert.id}`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .expect(200); + expect(updatedAlert.muteAll).to.eql(false); + // Ensure AAD isn't broken + await checkAAD({ + supertest, + spaceId: space.id, + type: 'alert', + id: createdAlert.id, + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle unmute alert request appropriately when consumer is "alerts"', async () => { + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + enabled: false, + alertTypeId: 'test.restricted-noop', + consumer: 'alerts', + }) + ) + .expect(200); + objectRemover.add(space.id, createdAlert.id, 'alert', 'alerts'); + + await supertest + .post(`${getUrlPrefix(space.id)}/api/alerts/alert/${createdAlert.id}/_mute_all`) + .set('kbn-xsrf', 'foo') + .expect(204, ''); + + const response = await alertUtils.getUnmuteAllRequest(createdAlert.id); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'unmuteAll', + 'test.restricted-noop', + 'alerts' + ), + statusCode: 403, + }); + break; + case 'global_read at space1': + case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getProducerUnauthorizedErrorMessage( + 'unmuteAll', + 'test.restricted-noop', + 'alertsRestrictedFixture' + ), + statusCode: 403, + }); + break; + case 'superuser at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(204); expect(response.body).to.eql(''); const { body: updatedAlert } = await supertestWithoutAuth diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/unmute_instance.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/unmute_instance.ts index 72b524282354..2bc501c9a7c7 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/unmute_instance.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/unmute_instance.ts @@ -13,6 +13,8 @@ import { getUrlPrefix, getTestAlertData, ObjectRemover, + getConsumerUnauthorizedErrorMessage, + getProducerUnauthorizedErrorMessage, } from '../../../common/lib'; // eslint-disable-next-line import/no-default-export @@ -31,10 +33,32 @@ export default function createMuteAlertInstanceTests({ getService }: FtrProvider describe(scenario.id, () => { it('should handle unmute alert instance request appropriately', async () => { + const { body: createdAction } = await supertest + .post(`${getUrlPrefix(space.id)}/api/actions/action`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'MY action', + actionTypeId: 'test.noop', + config: {}, + secrets: {}, + }) + .expect(200); + const { body: createdAlert } = await supertest .post(`${getUrlPrefix(space.id)}/api/alerts/alert`) .set('kbn-xsrf', 'foo') - .send(getTestAlertData({ enabled: false })) + .send( + getTestAlertData({ + enabled: false, + actions: [ + { + id: createdAction.id, + group: 'default', + params: {}, + }, + ], + }) + ) .expect(200); objectRemover.add(space.id, createdAlert.id, 'alert', 'alerts'); @@ -51,15 +75,239 @@ export default function createMuteAlertInstanceTests({ getService }: FtrProvider case 'no_kibana_privileges at space1': case 'space_1_all at space2': case 'global_read at space1': - expect(response.statusCode).to.eql(404); + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'unmuteInstance', + 'test.noop', + 'alertsFixture' + ), + statusCode: 403, + }); + break; + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(403); expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', + error: 'Forbidden', + message: `Unauthorized to execute actions`, + statusCode: 403, }); break; case 'superuser at space1': case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(204); + expect(response.body).to.eql(''); + const { body: updatedAlert } = await supertestWithoutAuth + .get(`${getUrlPrefix(space.id)}/api/alerts/alert/${createdAlert.id}`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .expect(200); + expect(updatedAlert.mutedInstanceIds).to.eql([]); + // Ensure AAD isn't broken + await checkAAD({ + supertest, + spaceId: space.id, + type: 'alert', + id: createdAlert.id, + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle unmute alert instance request appropriately when consumer is the same as producer', async () => { + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + enabled: false, + alertTypeId: 'test.restricted-noop', + consumer: 'alertsRestrictedFixture', + }) + ) + .expect(200); + objectRemover.add(space.id, createdAlert.id, 'alert', 'alerts'); + + await supertest + .post( + `${getUrlPrefix(space.id)}/api/alerts/alert/${createdAlert.id}/alert_instance/1/_mute` + ) + .set('kbn-xsrf', 'foo') + .expect(204, ''); + + const response = await alertUtils.getUnmuteInstanceRequest(createdAlert.id, '1'); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + case 'global_read at space1': + case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'unmuteInstance', + 'test.restricted-noop', + 'alertsRestrictedFixture' + ), + statusCode: 403, + }); + break; + case 'superuser at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(204); + expect(response.body).to.eql(''); + const { body: updatedAlert } = await supertestWithoutAuth + .get(`${getUrlPrefix(space.id)}/api/alerts/alert/${createdAlert.id}`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .expect(200); + expect(updatedAlert.mutedInstanceIds).to.eql([]); + // Ensure AAD isn't broken + await checkAAD({ + supertest, + spaceId: space.id, + type: 'alert', + id: createdAlert.id, + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle unmute alert instance request appropriately when consumer is not the producer', async () => { + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + enabled: false, + alertTypeId: 'test.unrestricted-noop', + consumer: 'alertsFixture', + }) + ) + .expect(200); + objectRemover.add(space.id, createdAlert.id, 'alert', 'alerts'); + + await supertest + .post( + `${getUrlPrefix(space.id)}/api/alerts/alert/${createdAlert.id}/alert_instance/1/_mute` + ) + .set('kbn-xsrf', 'foo') + .expect(204, ''); + + const response = await alertUtils.getUnmuteInstanceRequest(createdAlert.id, '1'); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + case 'global_read at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'unmuteInstance', + 'test.unrestricted-noop', + 'alertsFixture' + ), + statusCode: 403, + }); + break; + case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getProducerUnauthorizedErrorMessage( + 'unmuteInstance', + 'test.unrestricted-noop', + 'alertsRestrictedFixture' + ), + statusCode: 403, + }); + break; + case 'superuser at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(204); + expect(response.body).to.eql(''); + const { body: updatedAlert } = await supertestWithoutAuth + .get(`${getUrlPrefix(space.id)}/api/alerts/alert/${createdAlert.id}`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .expect(200); + expect(updatedAlert.mutedInstanceIds).to.eql([]); + // Ensure AAD isn't broken + await checkAAD({ + supertest, + spaceId: space.id, + type: 'alert', + id: createdAlert.id, + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle unmute alert instance request appropriately when consumer is "alerts"', async () => { + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + enabled: false, + alertTypeId: 'test.restricted-noop', + consumer: 'alerts', + }) + ) + .expect(200); + objectRemover.add(space.id, createdAlert.id, 'alert', 'alerts'); + + await supertest + .post( + `${getUrlPrefix(space.id)}/api/alerts/alert/${createdAlert.id}/alert_instance/1/_mute` + ) + .set('kbn-xsrf', 'foo') + .expect(204, ''); + + const response = await alertUtils.getUnmuteInstanceRequest(createdAlert.id, '1'); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'unmuteInstance', + 'test.restricted-noop', + 'alerts' + ), + statusCode: 403, + }); + break; + case 'global_read at space1': + case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getProducerUnauthorizedErrorMessage( + 'unmuteInstance', + 'test.restricted-noop', + 'alertsRestrictedFixture' + ), + statusCode: 403, + }); + break; + case 'superuser at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(204); expect(response.body).to.eql(''); const { body: updatedAlert } = await supertestWithoutAuth diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/update.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/update.ts index 2bcc035beb7a..ab3a92d0b3f7 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/update.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/update.ts @@ -13,6 +13,8 @@ import { getTestAlertData, ObjectRemover, ensureDatetimeIsWithinRange, + getConsumerUnauthorizedErrorMessage, + getProducerUnauthorizedErrorMessage, } from '../../../common/lib'; import { FtrProviderContext } from '../../../common/ftr_provider_context'; @@ -38,6 +40,17 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { const { user, space } = scenario; describe(scenario.id, () => { it('should handle update alert request appropriately', async () => { + const { body: createdAction } = await supertest + .post(`${getUrlPrefix(space.id)}/api/actions/action`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'MY action', + actionTypeId: 'test.noop', + config: {}, + secrets: {}, + }) + .expect(200); + const { body: createdAlert } = await supertest .post(`${getUrlPrefix(space.id)}/api/alerts/alert`) .set('kbn-xsrf', 'foo') @@ -52,7 +65,13 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { foo: true, }, schedule: { interval: '12s' }, - actions: [], + actions: [ + { + id: createdAction.id, + group: 'default', + params: {}, + }, + ], throttle: '1m', }; const response = await supertestWithoutAuth @@ -65,21 +84,310 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { case 'no_kibana_privileges at space1': case 'space_1_all at space2': case 'global_read at space1': - expect(response.statusCode).to.eql(404); + expect(response.statusCode).to.eql(403); expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'update', + 'test.noop', + 'alertsFixture' + ), + statusCode: 403, + }); + break; + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: `Unauthorized to get actions`, + statusCode: 403, }); break; case 'superuser at space1': case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(200); expect(response.body).to.eql({ ...updatedData, id: createdAlert.id, alertTypeId: 'test.noop', - consumer: 'bar', + consumer: 'alertsFixture', + createdBy: 'elastic', + enabled: true, + updatedBy: user.username, + apiKeyOwner: user.username, + muteAll: false, + mutedInstanceIds: [], + actions: [ + { + id: createdAction.id, + actionTypeId: 'test.noop', + group: 'default', + params: {}, + }, + ], + scheduledTaskId: createdAlert.scheduledTaskId, + createdAt: response.body.createdAt, + updatedAt: response.body.updatedAt, + }); + expect(Date.parse(response.body.createdAt)).to.be.greaterThan(0); + expect(Date.parse(response.body.updatedAt)).to.be.greaterThan(0); + expect(Date.parse(response.body.updatedAt)).to.be.greaterThan( + Date.parse(response.body.createdAt) + ); + // Ensure AAD isn't broken + await checkAAD({ + supertest, + spaceId: space.id, + type: 'alert', + id: createdAlert.id, + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle update alert request appropriately when consumer is the same as producer', async () => { + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + alertTypeId: 'test.restricted-noop', + consumer: 'alertsRestrictedFixture', + }) + ) + .expect(200); + objectRemover.add(space.id, createdAlert.id, 'alert', 'alerts'); + + const updatedData = { + name: 'bcd', + tags: ['bar'], + params: { + foo: true, + }, + schedule: { interval: '12s' }, + actions: [], + throttle: '1m', + }; + const response = await supertestWithoutAuth + .put(`${getUrlPrefix(space.id)}/api/alerts/alert/${createdAlert.id}`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .send(updatedData); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + case 'global_read at space1': + case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'update', + 'test.restricted-noop', + 'alertsRestrictedFixture' + ), + statusCode: 403, + }); + break; + case 'superuser at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(200); + expect(response.body).to.eql({ + ...updatedData, + id: createdAlert.id, + alertTypeId: 'test.restricted-noop', + consumer: 'alertsRestrictedFixture', + createdBy: 'elastic', + enabled: true, + updatedBy: user.username, + apiKeyOwner: user.username, + muteAll: false, + mutedInstanceIds: [], + scheduledTaskId: createdAlert.scheduledTaskId, + createdAt: response.body.createdAt, + updatedAt: response.body.updatedAt, + }); + expect(Date.parse(response.body.createdAt)).to.be.greaterThan(0); + expect(Date.parse(response.body.updatedAt)).to.be.greaterThan(0); + expect(Date.parse(response.body.updatedAt)).to.be.greaterThan( + Date.parse(response.body.createdAt) + ); + // Ensure AAD isn't broken + await checkAAD({ + supertest, + spaceId: space.id, + type: 'alert', + id: createdAlert.id, + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle update alert request appropriately when consumer is not the producer', async () => { + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + alertTypeId: 'test.unrestricted-noop', + consumer: 'alertsFixture', + }) + ) + .expect(200); + objectRemover.add(space.id, createdAlert.id, 'alert', 'alerts'); + + const updatedData = { + name: 'bcd', + tags: ['bar'], + params: { + foo: true, + }, + schedule: { interval: '12s' }, + actions: [], + throttle: '1m', + }; + const response = await supertestWithoutAuth + .put(`${getUrlPrefix(space.id)}/api/alerts/alert/${createdAlert.id}`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .send(updatedData); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + case 'global_read at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'update', + 'test.unrestricted-noop', + 'alertsFixture' + ), + statusCode: 403, + }); + break; + case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getProducerUnauthorizedErrorMessage( + 'update', + 'test.unrestricted-noop', + 'alertsRestrictedFixture' + ), + statusCode: 403, + }); + break; + case 'superuser at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(200); + expect(response.body).to.eql({ + ...updatedData, + id: createdAlert.id, + alertTypeId: 'test.unrestricted-noop', + consumer: 'alertsFixture', + createdBy: 'elastic', + enabled: true, + updatedBy: user.username, + apiKeyOwner: user.username, + muteAll: false, + mutedInstanceIds: [], + scheduledTaskId: createdAlert.scheduledTaskId, + createdAt: response.body.createdAt, + updatedAt: response.body.updatedAt, + }); + expect(Date.parse(response.body.createdAt)).to.be.greaterThan(0); + expect(Date.parse(response.body.updatedAt)).to.be.greaterThan(0); + expect(Date.parse(response.body.updatedAt)).to.be.greaterThan( + Date.parse(response.body.createdAt) + ); + // Ensure AAD isn't broken + await checkAAD({ + supertest, + spaceId: space.id, + type: 'alert', + id: createdAlert.id, + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle update alert request appropriately when consumer is "alerts"', async () => { + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + alertTypeId: 'test.restricted-noop', + consumer: 'alerts', + }) + ) + .expect(200); + objectRemover.add(space.id, createdAlert.id, 'alert', 'alerts'); + + const updatedData = { + name: 'bcd', + tags: ['bar'], + params: { + foo: true, + }, + schedule: { interval: '12s' }, + actions: [], + throttle: '1m', + }; + const response = await supertestWithoutAuth + .put(`${getUrlPrefix(space.id)}/api/alerts/alert/${createdAlert.id}`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .send(updatedData); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'update', + 'test.restricted-noop', + 'alerts' + ), + statusCode: 403, + }); + break; + case 'global_read at space1': + case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getProducerUnauthorizedErrorMessage( + 'update', + 'test.restricted-noop', + 'alertsRestrictedFixture' + ), + statusCode: 403, + }); + break; + case 'superuser at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(200); + expect(response.body).to.eql({ + ...updatedData, + id: createdAlert.id, + alertTypeId: 'test.restricted-noop', + consumer: 'alerts', createdBy: 'elastic', enabled: true, updatedBy: user.username, @@ -148,21 +456,27 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { case 'no_kibana_privileges at space1': case 'space_1_all at space2': case 'global_read at space1': - expect(response.statusCode).to.eql(404); + expect(response.statusCode).to.eql(403); expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'update', + 'test.noop', + 'alertsFixture' + ), + statusCode: 403, }); break; case 'superuser at space1': case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(200); expect(response.body).to.eql({ ...updatedData, id: createdAlert.id, alertTypeId: 'test.noop', - consumer: 'bar', + consumer: 'alertsFixture', createdBy: 'elastic', enabled: true, updatedBy: user.username, @@ -220,12 +534,8 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { case 'space_1_all at space2': case 'global_read at space1': case 'space_1_all at space1': - expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', - }); - break; + case 'space_1_all_alerts_none_actions at space1': + case 'space_1_all_with_restricted_fixture at space1': case 'superuser at space1': expect(response.body).to.eql({ statusCode: 404, @@ -266,15 +576,10 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { case 'no_kibana_privileges at space1': case 'space_1_all at space2': case 'global_read at space1': - expect(response.statusCode).to.eql(404); - expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', - }); - break; case 'superuser at space1': case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(400); expect(response.body).to.eql({ statusCode: 400, @@ -298,15 +603,10 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { case 'no_kibana_privileges at space1': case 'space_1_all at space2': case 'global_read at space1': - expect(response.statusCode).to.eql(404); - expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', - }); - break; case 'superuser at space1': case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(400); expect(response.body).to.eql({ statusCode: 400, @@ -351,15 +651,21 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { case 'no_kibana_privileges at space1': case 'space_1_all at space2': case 'global_read at space1': - expect(response.statusCode).to.eql(404); + expect(response.statusCode).to.eql(403); expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'update', + 'test.validation', + 'alertsFixture' + ), + statusCode: 403, }); break; case 'superuser at space1': case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(400); expect(response.body).to.eql({ statusCode: 400, @@ -390,15 +696,10 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { case 'no_kibana_privileges at space1': case 'space_1_all at space2': case 'global_read at space1': - expect(response.statusCode).to.eql(404); - expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', - }); - break; case 'superuser at space1': case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(400); expect(response.body).to.eql({ statusCode: 400, @@ -450,15 +751,21 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { case 'no_kibana_privileges at space1': case 'space_1_all at space2': case 'global_read at space1': - expect(response.statusCode).to.eql(404); + expect(response.statusCode).to.eql(403); expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'update', + 'test.noop', + 'alertsFixture' + ), + statusCode: 403, }); break; case 'superuser at space1': case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(200); await retry.try(async () => { const alertTask = (await getAlertingTaskById(createdAlert.scheduledTaskId)).docs[0]; diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/update_api_key.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/update_api_key.ts index bf72b970dc0f..7dea591b895e 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/update_api_key.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/update_api_key.ts @@ -13,6 +13,8 @@ import { getUrlPrefix, getTestAlertData, ObjectRemover, + getConsumerUnauthorizedErrorMessage, + getProducerUnauthorizedErrorMessage, } from '../../../common/lib'; // eslint-disable-next-line import/no-default-export @@ -31,28 +33,245 @@ export default function createUpdateApiKeyTests({ getService }: FtrProviderConte describe(scenario.id, () => { it('should handle update alert api key request appropriately', async () => { + const { body: createdAction } = await supertest + .post(`${getUrlPrefix(space.id)}/api/actions/action`) + .set('kbn-xsrf', 'foo') + .send({ + name: 'MY action', + actionTypeId: 'test.noop', + config: {}, + secrets: {}, + }) + .expect(200); + const { body: createdAlert } = await supertest .post(`${getUrlPrefix(space.id)}/api/alerts/alert`) .set('kbn-xsrf', 'foo') - .send(getTestAlertData()) + .send( + getTestAlertData({ + actions: [ + { + id: createdAction.id, + group: 'default', + params: {}, + }, + ], + }) + ) .expect(200); objectRemover.add(space.id, createdAlert.id, 'alert', 'alerts'); const response = await alertUtils.getUpdateApiKeyRequest(createdAlert.id); + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + case 'global_read at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'updateApiKey', + 'test.noop', + 'alertsFixture' + ), + statusCode: 403, + }); + break; + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: `Unauthorized to execute actions`, + statusCode: 403, + }); + break; + case 'superuser at space1': + case 'space_1_all at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(204); + expect(response.body).to.eql(''); + const { body: updatedAlert } = await supertestWithoutAuth + .get(`${getUrlPrefix(space.id)}/api/alerts/alert/${createdAlert.id}`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .expect(200); + expect(updatedAlert.apiKeyOwner).to.eql(user.username); + // Ensure AAD isn't broken + await checkAAD({ + supertest, + spaceId: space.id, + type: 'alert', + id: createdAlert.id, + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + it('should handle update alert api key request appropriately when consumer is the same as producer', async () => { + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + alertTypeId: 'test.restricted-noop', + consumer: 'alertsRestrictedFixture', + }) + ) + .expect(200); + objectRemover.add(space.id, createdAlert.id, 'alert', 'alerts'); + + const response = await alertUtils.getUpdateApiKeyRequest(createdAlert.id); switch (scenario.id) { case 'no_kibana_privileges at space1': case 'space_1_all at space2': case 'global_read at space1': - expect(response.statusCode).to.eql(404); + case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(403); expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'updateApiKey', + 'test.restricted-noop', + 'alertsRestrictedFixture' + ), + statusCode: 403, + }); + break; + case 'superuser at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(204); + expect(response.body).to.eql(''); + const { body: updatedAlert } = await supertestWithoutAuth + .get(`${getUrlPrefix(space.id)}/api/alerts/alert/${createdAlert.id}`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .expect(200); + expect(updatedAlert.apiKeyOwner).to.eql(user.username); + // Ensure AAD isn't broken + await checkAAD({ + supertest, + spaceId: space.id, + type: 'alert', + id: createdAlert.id, + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle update alert api key request appropriately when consumer is not the producer', async () => { + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + alertTypeId: 'test.unrestricted-noop', + consumer: 'alertsFixture', + }) + ) + .expect(200); + objectRemover.add(space.id, createdAlert.id, 'alert', 'alerts'); + + const response = await alertUtils.getUpdateApiKeyRequest(createdAlert.id); + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + case 'global_read at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'updateApiKey', + 'test.unrestricted-noop', + 'alertsFixture' + ), + statusCode: 403, + }); + break; + case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getProducerUnauthorizedErrorMessage( + 'updateApiKey', + 'test.unrestricted-noop', + 'alertsRestrictedFixture' + ), + statusCode: 403, }); break; case 'superuser at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(204); + expect(response.body).to.eql(''); + const { body: updatedAlert } = await supertestWithoutAuth + .get(`${getUrlPrefix(space.id)}/api/alerts/alert/${createdAlert.id}`) + .set('kbn-xsrf', 'foo') + .auth(user.username, user.password) + .expect(200); + expect(updatedAlert.apiKeyOwner).to.eql(user.username); + // Ensure AAD isn't broken + await checkAAD({ + supertest, + spaceId: space.id, + type: 'alert', + id: createdAlert.id, + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle update alert api key request appropriately when consumer is "alerts"', async () => { + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + alertTypeId: 'test.restricted-noop', + consumer: 'alerts', + }) + ) + .expect(200); + objectRemover.add(space.id, createdAlert.id, 'alert', 'alerts'); + + const response = await alertUtils.getUpdateApiKeyRequest(createdAlert.id); + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'updateApiKey', + 'test.restricted-noop', + 'alerts' + ), + statusCode: 403, + }); + break; + case 'global_read at space1': case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getProducerUnauthorizedErrorMessage( + 'updateApiKey', + 'test.restricted-noop', + 'alertsRestrictedFixture' + ), + statusCode: 403, + }); + break; + case 'superuser at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(204); expect(response.body).to.eql(''); const { body: updatedAlert } = await supertestWithoutAuth @@ -100,15 +319,21 @@ export default function createUpdateApiKeyTests({ getService }: FtrProviderConte case 'no_kibana_privileges at space1': case 'space_1_all at space2': case 'global_read at space1': - expect(response.statusCode).to.eql(404); + expect(response.statusCode).to.eql(403); expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'updateApiKey', + 'test.noop', + 'alertsFixture' + ), + statusCode: 403, }); break; case 'superuser at space1': case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.statusCode).to.eql(204); expect(response.body).to.eql(''); const { body: updatedAlert } = await supertestWithoutAuth @@ -145,14 +370,10 @@ export default function createUpdateApiKeyTests({ getService }: FtrProviderConte case 'no_kibana_privileges at space1': case 'space_1_all at space2': case 'global_read at space1': - expect(response.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Not Found', - }); - break; case 'superuser at space1': case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'space_1_all_with_restricted_fixture at space1': expect(response.body).to.eql({ statusCode: 404, error: 'Not Found', diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/alert.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/alert.ts index 8412c09eefcd..92db0458c063 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/alert.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/alert.ts @@ -346,7 +346,7 @@ export default function alertTests({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'foo') .send({ name: params.name, - consumer: 'function test', + consumer: 'alerts', enabled: true, alertTypeId: ALERT_TYPE_ID, schedule: { interval: `${ALERT_INTERVAL_SECONDS}s` }, diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/create.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/create.ts index fa256712a012..86775f77a767 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/create.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/create.ts @@ -6,7 +6,13 @@ import expect from '@kbn/expect'; import { Spaces } from '../../scenarios'; -import { checkAAD, getUrlPrefix, getTestAlertData, ObjectRemover } from '../../../common/lib'; +import { + checkAAD, + getUrlPrefix, + getTestAlertData, + ObjectRemover, + getConsumerUnauthorizedErrorMessage, +} from '../../../common/lib'; import { FtrProviderContext } from '../../../common/ftr_provider_context'; // eslint-disable-next-line import/no-default-export @@ -69,7 +75,7 @@ export default function createAlertTests({ getService }: FtrProviderContext) { ], enabled: true, alertTypeId: 'test.noop', - consumer: 'bar', + consumer: 'alertsFixture', params: {}, createdBy: null, schedule: { interval: '1m' }, @@ -102,6 +108,24 @@ export default function createAlertTests({ getService }: FtrProviderContext) { }); }); + it('should handle create alert request appropriately when consumer is unknown', async () => { + const response = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send(getTestAlertData({ consumer: 'some consumer patrick invented' })); + + expect(response.status).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'create', + 'test.noop', + 'some consumer patrick invented' + ), + statusCode: 403, + }); + }); + it('should handle create alert request appropriately when an alert is disabled ', async () => { const response = await supertest .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert`) diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/find.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/find.ts index 06f27d666c3d..b28ce89b3047 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/find.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/find.ts @@ -42,7 +42,7 @@ export default function createFindTests({ getService }: FtrProviderContext) { name: 'abc', tags: ['foo'], alertTypeId: 'test.noop', - consumer: 'bar', + consumer: 'alertsFixture', schedule: { interval: '1m' }, enabled: true, actions: [], diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/get.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/get.ts index ff671e16654b..165eaa09126a 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/get.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/get.ts @@ -36,7 +36,7 @@ export default function createGetTests({ getService }: FtrProviderContext) { name: 'abc', tags: ['foo'], alertTypeId: 'test.noop', - consumer: 'bar', + consumer: 'alertsFixture', schedule: { interval: '1m' }, enabled: true, actions: [], diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/get_alert_state.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/get_alert_state.ts index d3f08d7c509a..e3f87a9be00b 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/get_alert_state.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/get_alert_state.ts @@ -44,7 +44,7 @@ export default function createGetAlertStateTests({ getService }: FtrProviderCont name: 'abc', tags: ['foo'], alertTypeId: 'test.cumulative-firing', - consumer: 'bar', + consumer: 'alertsFixture', schedule: { interval: '5s' }, throttle: '5s', actions: [], diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/list_alert_types.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/list_alert_types.ts index aef87eefba2a..dd09a14b4cb8 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/list_alert_types.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/list_alert_types.ts @@ -19,7 +19,9 @@ export default function listAlertTypes({ getService }: FtrProviderContext) { `${getUrlPrefix(Spaces.space1.id)}/api/alerts/list_alert_types` ); expect(response.status).to.eql(200); - const fixtureAlertType = response.body.find((alertType: any) => alertType.id === 'test.noop'); + const { authorizedConsumers, ...fixtureAlertType } = response.body.find( + (alertType: any) => alertType.id === 'test.noop' + ); expect(fixtureAlertType).to.eql({ actionGroups: [{ id: 'default', name: 'Default' }], defaultActionGroupId: 'default', @@ -29,8 +31,9 @@ export default function listAlertTypes({ getService }: FtrProviderContext) { state: [], context: [], }, - producer: 'alerting', + producer: 'alertsFixture', }); + expect(Object.keys(authorizedConsumers)).to.contain('alertsFixture'); }); it('should return actionVariables with both context and state', async () => { diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/migrations.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/migrations.ts index fc61f59d129d..d0e1be12e762 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/migrations.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/migrations.ts @@ -30,5 +30,14 @@ export default function createGetTests({ getService }: FtrProviderContext) { expect(response.status).to.eql(200); expect(response.body.consumer).to.equal('alerts'); }); + + it('7.10.0 migrates the `metrics` consumer to be the `infrastructure`', async () => { + const response = await supertest.get( + `${getUrlPrefix(``)}/api/alerts/alert/74f3e6d7-b7bb-477d-ac28-fdf248d5f2a4` + ); + + expect(response.status).to.eql(200); + expect(response.body.consumer).to.equal('infrastructure'); + }); }); } diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/update.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/update.ts index b01a1b140f2d..9c8e6f6b8d94 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/update.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/update.ts @@ -47,7 +47,7 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { id: createdAlert.id, tags: ['bar'], alertTypeId: 'test.noop', - consumer: 'bar', + consumer: 'alertsFixture', createdBy: null, enabled: true, updatedBy: null, diff --git a/x-pack/test/api_integration/apis/features/features/features.ts b/x-pack/test/api_integration/apis/features/features/features.ts index df6eca795f80..9c44bfeb810f 100644 --- a/x-pack/test/api_integration/apis/features/features/features.ts +++ b/x-pack/test/api_integration/apis/features/features/features.ts @@ -97,6 +97,7 @@ export default function ({ getService }: FtrProviderContext) { 'visualize', 'dashboard', 'dev_tools', + 'actions', 'enterpriseSearch', 'advancedSettings', 'indexPatterns', @@ -106,6 +107,7 @@ export default function ({ getService }: FtrProviderContext) { 'savedObjectsManagement', 'ml', 'apm', + 'builtInAlerts', 'canvas', 'infrastructure', 'logs', diff --git a/x-pack/test/api_integration/apis/security/privileges.ts b/x-pack/test/api_integration/apis/security/privileges.ts index 59fcfae5db3c..1ad25a11be87 100644 --- a/x-pack/test/api_integration/apis/security/privileges.ts +++ b/x-pack/test/api_integration/apis/security/privileges.ts @@ -38,6 +38,8 @@ export default function ({ getService }: FtrProviderContext) { ml: ['all', 'read'], siem: ['all', 'read'], ingestManager: ['all', 'read'], + builtInAlerts: ['all', 'read'], + actions: ['all', 'read'], }, global: ['all', 'read'], space: ['all', 'read'], diff --git a/x-pack/test/api_integration/apis/security/privileges_basic.ts b/x-pack/test/api_integration/apis/security/privileges_basic.ts index 5c2a2875852d..d5263aed26d0 100644 --- a/x-pack/test/api_integration/apis/security/privileges_basic.ts +++ b/x-pack/test/api_integration/apis/security/privileges_basic.ts @@ -36,6 +36,8 @@ export default function ({ getService }: FtrProviderContext) { ml: ['all', 'read'], siem: ['all', 'read'], ingestManager: ['all', 'read'], + builtInAlerts: ['all', 'read'], + actions: ['all', 'read'], }, global: ['all', 'read'], space: ['all', 'read'], diff --git a/x-pack/test/functional/es_archives/alerts/data.json b/x-pack/test/functional/es_archives/alerts/data.json index 3703473606ea..cc246b0fe44d 100644 --- a/x-pack/test/functional/es_archives/alerts/data.json +++ b/x-pack/test/functional/es_archives/alerts/data.json @@ -38,4 +38,46 @@ "updated_at": "2020-06-17T15:35:39.839Z" } } +} + +{ + "type": "doc", + "value": { + "id": "alert:74f3e6d7-b7bb-477d-ac28-fdf248d5f2a4", + "index": ".kibana_1", + "source": { + "alert": { + "actions": [ + ], + "alertTypeId": "example.always-firing", + "apiKey": "XHcE1hfSJJCvu2oJrKErgbIbR7iu3XAX+c1kki8jESzWZNyBlD4+6yHhCDEx7rNLlP/Hvbut/V8N1BaQkaSpVpiNsW/UxshiCouqJ+cmQ9LbaYnca9eTTVUuPhbHwwsDjfYkakDPqW3gB8sonwZl6rpzZVacfp4=", + "apiKeyOwner": "elastic", + "consumer": "metrics", + "createdAt": "2020-06-17T15:35:38.497Z", + "createdBy": "elastic", + "enabled": true, + "muteAll": false, + "mutedInstanceIds": [ + ], + "name": "always-firing-alert", + "params": { + }, + "schedule": { + "interval": "1m" + }, + "scheduledTaskId": "329798f0-b0b0-11ea-9510-fdf248d5f2a4", + "tags": [ + ], + "throttle": null, + "updatedBy": "elastic" + }, + "migrationVersion": { + "alert": "7.8.0" + }, + "references": [ + ], + "type": "alert", + "updated_at": "2020-06-17T15:35:39.839Z" + } + } } \ No newline at end of file diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts.ts index 2225316bba80..09c415685450 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts.ts @@ -28,7 +28,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { name: generateUniqueKey(), tags: ['foo', 'bar'], alertTypeId: 'test.noop', - consumer: 'test', + consumer: 'alerts', schedule: { interval: '1m' }, throttle: '1m', actions: [], @@ -372,7 +372,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await testSubjects.click('deleteAll'); await testSubjects.existOrFail('deleteIdsConfirmation'); await testSubjects.click('deleteIdsConfirmation > confirmModalConfirmButton'); - await testSubjects.missingOrFail('deleteIdsConfirmation'); + await testSubjects.missingOrFail('deleteIdsConfirmation', { timeout: 5000 }); await pageObjects.common.closeToast(); diff --git a/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/kibana.json b/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/kibana.json index 74f740f52a8b..4ad7aa3126e8 100644 --- a/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/kibana.json +++ b/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/kibana.json @@ -3,7 +3,7 @@ "version": "1.0.0", "kibanaVersion": "kibana", "configPath": ["xpack"], - "requiredPlugins": ["alerts", "triggers_actions_ui"], + "requiredPlugins": ["alerts", "triggers_actions_ui", "features"], "server": true, "ui": true } diff --git a/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/public/plugin.ts b/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/public/plugin.ts index 2bc299ede930..503c328017a9 100644 --- a/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/public/plugin.ts +++ b/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/public/plugin.ts @@ -21,7 +21,7 @@ export interface AlertingExamplePublicSetupDeps { export class AlertingFixturePlugin implements Plugin { public setup(core: CoreSetup, { alerts, triggers_actions_ui }: AlertingExamplePublicSetupDeps) { alerts.registerNavigation( - 'consumer-noop', + 'alerting_fixture', 'test.noop', (alert: SanitizedAlert, alertType: AlertType) => `/alert/${alert.id}` ); @@ -49,8 +49,8 @@ export class AlertingFixturePlugin implements Plugin { - public setup(core: CoreSetup, { alerts }: AlertingExampleDeps) { + public setup(core: CoreSetup, { alerts, features }: AlertingExampleDeps) { createNoopAlertType(alerts); createAlwaysFiringAlertType(alerts); + features.registerFeature({ + id: 'alerting_fixture', + name: 'alerting_fixture', + app: [], + alerting: ['test.always-firing', 'test.noop'], + privileges: { + all: { + alerting: { + all: ['test.always-firing', 'test.noop'], + }, + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + read: { + alerting: { + all: ['test.always-firing', 'test.noop'], + }, + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + }, + }); } public start() {} @@ -32,7 +62,7 @@ function createNoopAlertType(alerts: AlertingSetup) { actionGroups: [{ id: 'default', name: 'Default' }], defaultActionGroupId: 'default', async executor() {}, - producer: 'alerting', + producer: 'alerts', }; alerts.registerType(noopAlertType); } @@ -46,7 +76,7 @@ function createAlwaysFiringAlertType(alerts: AlertingSetup) { { id: 'default', name: 'Default' }, { id: 'other', name: 'Other' }, ], - producer: 'alerting', + producer: 'alerts', async executor(alertExecutorOptions: any) { const { services, state, params } = alertExecutorOptions; diff --git a/x-pack/test/functional_with_es_ssl/services/alerting/alerts.ts b/x-pack/test/functional_with_es_ssl/services/alerting/alerts.ts index 25f4c6a932d5..23a4529139c5 100644 --- a/x-pack/test/functional_with_es_ssl/services/alerting/alerts.ts +++ b/x-pack/test/functional_with_es_ssl/services/alerting/alerts.ts @@ -43,7 +43,7 @@ export class Alerts { name, tags, alertTypeId, - consumer: consumer ?? 'bar', + consumer: consumer ?? 'alerts', schedule: schedule ?? { interval: '1m' }, throttle: throttle ?? '1m', actions: actions ?? [], @@ -68,7 +68,7 @@ export class Alerts { name, tags: ['foo'], alertTypeId: 'test.noop', - consumer: 'consumer-noop', + consumer: 'alerting_fixture', schedule: { interval: '1m' }, throttle: '1m', actions: [], @@ -101,7 +101,7 @@ export class Alerts { name, tags: ['foo'], alertTypeId: 'test.always-firing', - consumer: 'bar', + consumer: 'alerts', schedule: { interval: '1m' }, throttle: '1m', actions, From f6bc61f2225cd3aa6ba901048b13d0745f21b972 Mon Sep 17 00:00:00 2001 From: Steph Milovic Date: Wed, 22 Jul 2020 08:03:41 -0600 Subject: [PATCH 037/142] [Security solution] [Timeline] Bug fix for "Collapse event" button (#72552) --- .../__snapshots__/event_details.test.tsx.snap | 34 ++----- .../event_details/event_details.test.tsx | 90 ++++------------- .../event_details/event_details.tsx | 97 ++++++------------- 3 files changed, 59 insertions(+), 162 deletions(-) diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/event_details.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/event_details.test.tsx.snap index ebaf60e7078f..2ae621e71a72 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/event_details.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/event_details/__snapshots__/event_details.test.tsx.snap @@ -4,33 +4,6 @@ exports[`EventDetails rendering should match snapshot 1`] = `
- - - - - } - closePopover={[Function]} - display="inlineBlock" - hasArrow={true} - isOpen={false} - ownFocus={false} - panelPaddingSize="m" - repositionOnScroll={true} - /> - + + Collapse event +
`; diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.test.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.test.tsx index a5b44fd540c4..01b0810830dd 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.test.tsx @@ -11,7 +11,7 @@ import '../../mock/match_media'; import { mockDetailItemData, mockDetailItemDataId } from '../../mock/mock_detail_item'; import { TestProviders } from '../../mock/test_providers'; -import { EventDetails } from './event_details'; +import { EventDetails, View } from './event_details'; import { mockBrowserFields } from '../../containers/source/mock'; import { defaultHeaders } from '../../mock/header'; import { useMountAppended } from '../../utils/use_mount_appended'; @@ -20,47 +20,35 @@ jest.mock('../link_to'); describe('EventDetails', () => { const mount = useMountAppended(); + const onEventToggled = jest.fn(); + const defaultProps = { + browserFields: mockBrowserFields, + columnHeaders: defaultHeaders, + data: mockDetailItemData, + id: mockDetailItemDataId, + view: 'table-view' as View, + onEventToggled, + onUpdateColumns: jest.fn(), + onViewSelected: jest.fn(), + timelineId: 'test', + toggleColumn: jest.fn(), + }; + const wrapper = mount( + + + + ); describe('rendering', () => { test('should match snapshot', () => { - const wrapper = shallow( - - ); - expect(wrapper).toMatchSnapshot(); + const shallowWrap = shallow(); + expect(shallowWrap).toMatchSnapshot(); }); }); describe('tabs', () => { ['Table', 'JSON View'].forEach((tab) => { test(`it renders the ${tab} tab`, () => { - const wrapper = mount( - - - - ); - expect( wrapper .find('[data-test-subj="eventDetails"]') @@ -71,48 +59,12 @@ describe('EventDetails', () => { }); test('the Table tab is selected by default', () => { - const wrapper = mount( - - - - ); - expect( wrapper.find('[data-test-subj="eventDetails"]').find('.euiTab-isSelected').first().text() ).toEqual('Table'); }); test('it invokes `onEventToggled` when the collapse button is clicked', () => { - const onEventToggled = jest.fn(); - - const wrapper = mount( - - - - ); - wrapper.find('[data-test-subj="collapse"]').first().simulate('click'); wrapper.update(); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx index 53ec14380d5b..1cc50b7d951a 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/event_details.tsx @@ -4,14 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { noop } from 'lodash/fp'; -import { - EuiButtonIcon, - EuiPopover, - EuiTabbedContent, - EuiTabbedContentTab, - EuiToolTip, -} from '@elastic/eui'; +import { EuiLink, EuiTabbedContent, EuiTabbedContentTab } from '@elastic/eui'; import React, { useMemo } from 'react'; import styled from 'styled-components'; @@ -26,22 +19,11 @@ import { COLLAPSE, COLLAPSE_EVENT } from '../../../timelines/components/timeline export type View = 'table-view' | 'json-view'; -const PopoverContainer = styled.div` - left: -40px; - position: relative; - top: 10px; - - .euiPopover { - position: fixed; - z-index: 10; - } +const CollapseLink = styled(EuiLink)` + margin: 20px 0; `; -const CollapseButton = styled(EuiButtonIcon)` - border: 1px solid; -`; - -CollapseButton.displayName = 'CollapseButton'; +CollapseLink.displayName = 'CollapseLink'; interface Props { browserFields: BrowserFields; @@ -75,59 +57,42 @@ export const EventDetails = React.memo( timelineId, toggleColumn, }) => { - const button = useMemo( - () => ( - - - - ), - [onEventToggled] + const tabs: EuiTabbedContentTab[] = useMemo( + () => [ + { + id: 'table-view', + name: i18n.TABLE, + content: ( + + ), + }, + { + id: 'json-view', + name: i18n.JSON_VIEW, + content: , + }, + ], + [browserFields, columnHeaders, data, id, onUpdateColumns, timelineId, toggleColumn] ); - const tabs: EuiTabbedContentTab[] = [ - { - id: 'table-view', - name: i18n.TABLE, - content: ( - - ), - }, - { - id: 'json-view', - name: i18n.JSON_VIEW, - content: , - }, - ]; - return (
- - - onViewSelected(e.id as View)} /> + + {COLLAPSE_EVENT} +
); } From 10846cb361fca77002f1b48af4a091fd09784e7d Mon Sep 17 00:00:00 2001 From: gchaps <33642766+gchaps@users.noreply.github.com> Date: Wed, 22 Jul 2020 08:00:53 -0700 Subject: [PATCH 038/142] [DOCS] Updates Management docs to match UI (#72514) * [DOCS] Updates Management docs to match UI * [DOCS] Incorporates review comments Co-authored-by: Elastic Machine --- .../alerts-and-actions-intro.asciidoc | 11 ++--- .../add-policy-to-index.asciidoc | 13 +++--- .../example-index-lifecycle-policy.asciidoc | 8 ++-- .../ingest-pipelines.asciidoc | 4 +- docs/management/managing-beats.asciidoc | 7 ++-- docs/management/managing-ccr.asciidoc | 2 +- docs/management/managing-indices.asciidoc | 20 ++++----- docs/management/managing-licenses.asciidoc | 2 +- .../managing-remote-clusters.asciidoc | 2 +- .../create_and_manage_rollups.asciidoc | 11 +++-- .../snapshot-restore/index.asciidoc | 4 +- .../upgrade-assistant/index.asciidoc | 42 +++++++++---------- docs/management/watcher-ui/index.asciidoc | 6 +-- docs/spaces/index.asciidoc | 3 +- docs/user/reporting/index.asciidoc | 2 +- docs/user/security/rbac_tutorial.asciidoc | 2 +- 16 files changed, 71 insertions(+), 68 deletions(-) diff --git a/docs/management/alerting/alerts-and-actions-intro.asciidoc b/docs/management/alerting/alerts-and-actions-intro.asciidoc index efc6a670af3e..429d7915cc1c 100644 --- a/docs/management/alerting/alerts-and-actions-intro.asciidoc +++ b/docs/management/alerting/alerts-and-actions-intro.asciidoc @@ -4,9 +4,10 @@ beta[] -The *Alerts and Actions* UI lets you <> in a space, and provides tools to <> so that alerts can trigger actions like notification, indexing, and ticketing. +The *Alerts and Actions* UI lets you <> in a space, and provides tools to <> so that alerts can trigger actions like notification, indexing, and ticketing. -To manage alerting and connectors, open the menu, then go to *Stack Management > {kib} > Alerts and Actions*. +To manage alerting and connectors, open the menu, +then go to *Stack Management > Alerts and Insights > Alerts and Actions*. [role="screenshot"] image:management/alerting/images/alerts-and-actions-ui.png[Example alert listing in the Alerts and Actions UI] @@ -14,12 +15,12 @@ image:management/alerting/images/alerts-and-actions-ui.png[Example alert listing [NOTE] ============================================================================ Similar to dashboards, alerts and connectors reside in a <>. -The *Alerts and Actions* UI only shows alerts and connectors for the current space. +The *Alerts and Actions* UI only shows alerts and connectors for the current space. ============================================================================ [NOTE] ============================================================================ {es} also offers alerting capabilities through Watcher, which -can be managed through the <>. See +can be managed through the <>. See <> for more information. -============================================================================ \ No newline at end of file +============================================================================ diff --git a/docs/management/index-lifecycle-policies/add-policy-to-index.asciidoc b/docs/management/index-lifecycle-policies/add-policy-to-index.asciidoc index eb014a516504..0fec62d89575 100644 --- a/docs/management/index-lifecycle-policies/add-policy-to-index.asciidoc +++ b/docs/management/index-lifecycle-policies/add-policy-to-index.asciidoc @@ -2,17 +2,16 @@ [[adding-policy-to-index]] === Adding a policy to an index -To add a lifecycle policy to an index and view the status for indices -managed by a policy, open the menu, then go to *Stack Management > {es} > Index Management*. This page lists your -{es} indices, which you can filter by lifecycle status and lifecycle phase. +To add a lifecycle policy to an index and view the status for indices +managed by a policy, open the menu, then go to *Stack Management > Data > Index Management*. +This page lists your +{es} indices, which you can filter by lifecycle status and lifecycle phase. To add a policy, select the index name and then select *Manage Index > Add lifecycle policy*. -You’ll see the policy name, the phase the index is in, the current -action, and if any errors occurred performing that action. +You’ll see the policy name, the phase the index is in, the current +action, and if any errors occurred performing that action. To remove a policy from an index, select *Manage Index > Remove lifecycle policy*. [role="screenshot"] image::images/index_management_add_policy.png[][UI for adding a policy to an index] - - diff --git a/docs/management/index-lifecycle-policies/example-index-lifecycle-policy.asciidoc b/docs/management/index-lifecycle-policies/example-index-lifecycle-policy.asciidoc index 69e74d6538e4..0097bf8c648f 100644 --- a/docs/management/index-lifecycle-policies/example-index-lifecycle-policy.asciidoc +++ b/docs/management/index-lifecycle-policies/example-index-lifecycle-policy.asciidoc @@ -3,7 +3,7 @@ [[example-using-index-lifecycle-policy]] === Tutorial: Use {ilm-init} to manage {filebeat} time-based indices -With {ilm} ({ilm-init}), you can create policies that perform actions automatically +With {ilm} ({ilm-init}), you can create policies that perform actions automatically on indices as they age and grow. {ilm-init} policies help you to manage performance, resilience, and retention of your data during its lifecycle. This tutorial shows you how to use {kib}’s *Index Lifecycle Policies* to modify and create {ilm-init} @@ -59,7 +59,7 @@ output as described in {filebeat-ref}/filebeat-getting-started.html[Getting Star {filebeat} includes a default {ilm-init} policy that enables rollover. {ilm-init} is enabled automatically if you’re using the default `filebeat.yml` and index template. -To view the default policy in {kib}, open the menu, go to * Stack Management > {es} > Index Lifecycle Policies*, +To view the default policy in {kib}, open the menu, go to *Stack Management > Data > Index Lifecycle Policies*, search for _filebeat_, and choose the _filebeat-version_ policy. This policy initiates the rollover action when the index size reaches 50GB or @@ -114,7 +114,7 @@ If meeting a specific retention time period is most important, you can create a custom policy. For this option, you will use {filebeat} daily indices without rollover. -. To create a custom policy, open the menu, go to *Stack Management > {es} > Index Lifecycle Policies*, then click +. To create a custom policy, open the menu, go to *Stack Management > Data > Index Lifecycle Policies*, then click *Create policy*. . Activate the warm phase and configure it as follows: @@ -156,7 +156,7 @@ image::images/tutorial-ilm-custom-policy.png["Modify the custom policy to add a [role="screenshot"] image::images/tutorial-ilm-delete-phase-creation.png["Delete phase"] -. To configure the index to use the new policy, open the menu, then go to *Stack Management > {es} > Index Lifecycle +. To configure the index to use the new policy, open the menu, then go to *Stack Management > Data > Index Lifecycle Policies*. .. Find your {ilm-init} policy. diff --git a/docs/management/ingest-pipelines/ingest-pipelines.asciidoc b/docs/management/ingest-pipelines/ingest-pipelines.asciidoc index 8c259dae256d..da2d3b8accac 100644 --- a/docs/management/ingest-pipelines/ingest-pipelines.asciidoc +++ b/docs/management/ingest-pipelines/ingest-pipelines.asciidoc @@ -7,7 +7,7 @@ pipelines that perform common transformations and enrichments on your data. For example, you might remove a field, rename an existing field, or set a new field. -You’ll find *Ingest Node Pipelines* in *Management > Elasticsearch*. With this feature, you can: +You’ll find *Ingest Node Pipelines* in *Stack Management > Ingest*. With this feature, you can: * View a list of your pipelines and drill down into details. * Create a pipeline that defines a series of tasks, known as processors. @@ -23,7 +23,7 @@ image:management/ingest-pipelines/images/ingest-pipeline-list.png["Ingest node p The minimum required permissions to access *Ingest Node Pipelines* are the `manage_pipeline` and `cluster:monitor/nodes/info` cluster privileges. -You can add these privileges in *Management > Security > Roles*. +You can add these privileges in *Stack Management > Security > Roles*. [role="screenshot"] image:management/ingest-pipelines/images/ingest-pipeline-privileges.png["Privileges required for Ingest Node Pipelines"] diff --git a/docs/management/managing-beats.asciidoc b/docs/management/managing-beats.asciidoc index d5a9c52feae2..678e160b99af 100644 --- a/docs/management/managing-beats.asciidoc +++ b/docs/management/managing-beats.asciidoc @@ -4,7 +4,8 @@ include::{asciidoc-dir}/../../shared/discontinued.asciidoc[tag=cm-discontinued] -To use the Central Management UI, open the menu, go to *Stack Management > {beats} > Central Management*, then define and +To use {beats} Central Management UI, open the menu, go to *Stack Management > Ingest > +{beats} Central Management*, then define and manage configurations in a central location in {kib} and quickly deploy configuration changes to all {beats} running across your enterprise. For more about central management, see the related {beats} documentation: @@ -17,8 +18,8 @@ about central management, see the related {beats} documentation: This feature requires an Elastic license that includes {beats} central management. -Don't have a license? You can start a 30-day trial. Open the menu, go to -*Stack Management > Elasticsearch > License Management*. At the end of the trial +Don't have a license? You can start a 30-day trial. Open the menu, +go to *Stack Management > Stack > License Management*. At the end of the trial period, you can purchase a subscription to keep using central management. For more information, see https://www.elastic.co/subscriptions and <>. diff --git a/docs/management/managing-ccr.asciidoc b/docs/management/managing-ccr.asciidoc index 2df9addf7491..67193b3b5a03 100644 --- a/docs/management/managing-ccr.asciidoc +++ b/docs/management/managing-ccr.asciidoc @@ -7,7 +7,7 @@ remote clusters on a local cluster. {ref}/xpack-ccr.html[Cross-cluster replicati is commonly used to provide remote backups for disaster recovery and for geo-proximite copies of data. -To get started, open the menu, then go to *Stack Management > Elasticsearch > Cross-Cluster Replication*. +To get started, open the menu, then go to *Stack Management > Data > Cross-Cluster Replication*. [role="screenshot"] image::images/cross-cluster-replication-list-view.png[][Cross-cluster replication list view] diff --git a/docs/management/managing-indices.asciidoc b/docs/management/managing-indices.asciidoc index 4fc4ac1d3742..24cd094c877c 100644 --- a/docs/management/managing-indices.asciidoc +++ b/docs/management/managing-indices.asciidoc @@ -13,7 +13,7 @@ the amount of bookkeeping when working with indices. Instead of manually setting up your indices, you can create them automatically from a template, ensuring that your settings, mappings, and aliases are consistently defined. -To manage your indices, open the menu, then go to *Stack Management > {es} > Index Management*. +To manage your indices, open the menu, then go to *Stack Management > Data > Index Management*. [role="screenshot"] image::images/management_index_labels.png[Index Management UI] @@ -130,17 +130,17 @@ Alternatively, you can click the *Load JSON* link and define the mapping as JSON [source,js] ---------------------------------- -{ +{ "properties": { "geo": { - "properties": { - "coordinates": { - "type": "geo_point" - } - } - } - } -} + "properties": { + "coordinates": { + "type": "geo_point" + } + } + } + } +} ---------------------------------- You can create additional mapping configurations in the *Dynamic templates* and diff --git a/docs/management/managing-licenses.asciidoc b/docs/management/managing-licenses.asciidoc index 99cfd12eeade..25ae29036f65 100644 --- a/docs/management/managing-licenses.asciidoc +++ b/docs/management/managing-licenses.asciidoc @@ -7,7 +7,7 @@ with no expiration date. For the full list of features, refer to If you want to try out the full set of features, you can activate a free 30-day trial. To view the status of your license, start a trial, or install a new -license, open the menu, then go to *Stack Management > {es} > License Management*. +license, open the menu, then go to *Stack Management > Stack > License Management*. NOTE: You can start a trial only if your cluster has not already activated a trial license for the current major product version. For example, if you have diff --git a/docs/management/managing-remote-clusters.asciidoc b/docs/management/managing-remote-clusters.asciidoc index 8ccd27b65aed..83895838efec 100644 --- a/docs/management/managing-remote-clusters.asciidoc +++ b/docs/management/managing-remote-clusters.asciidoc @@ -6,7 +6,7 @@ connection from your cluster to other clusters. This functionality is required for {ref}/xpack-ccr.html[cross-cluster replication] and {ref}/modules-cross-cluster-search.html[cross-cluster search]. -To get started, open the menu, then go to *Stack Management > {es} > Remote Clusters*. +To get started, open the menu, then go to *Stack Management > Data > Remote Clusters*. [role="screenshot"] image::images/remote-clusters-list-view.png[Remote Clusters list view, including Add a remote cluster button] diff --git a/docs/management/rollups/create_and_manage_rollups.asciidoc b/docs/management/rollups/create_and_manage_rollups.asciidoc index bbdc382d04b3..831b536f8c1c 100644 --- a/docs/management/rollups/create_and_manage_rollups.asciidoc +++ b/docs/management/rollups/create_and_manage_rollups.asciidoc @@ -8,7 +8,7 @@ by an index pattern, and then rolls it into a new index. Rollup indices are a go compactly store months or years of historical data for use in visualizations and reports. -To get started, open the menu, then go to *Stack Management > {es} > Rollup Jobs*. With this UI, +To get started, open the menu, then go to *Stack Management > Data > Rollup Jobs*. With this UI, you can: * <> @@ -130,8 +130,9 @@ Your next step is to visualize your rolled up data in a vertical bar chart. Most visualizations support rolled up data, with the exception of Timelion and Vega visualizations. -. Create the rollup index pattern in *Management > Index Patterns* so you can -select your rolled up data for visualizations. Click *Create index pattern*, and select *Rollup index pattern* from the dropdown. +. Go to *Stack Management > {kib} > Index Patterns*. + +. Click *Create index pattern*, and select *Rollup index pattern* from the dropdown. + [role="screenshot"] image::images/management-rollup-index-pattern.png[][Create rollup index pattern] @@ -144,7 +145,9 @@ is `rollup_logstash,kibana_sample_data_logs`. In this index pattern, `rollup_log matches the rolled up index pattern and `kibana_sample_data_logs` matches the index pattern for raw data. -. Go to *Visualize* and create a vertical bar chart. Choose `rollup_logstash,kibana_sample_data_logs` +. Go to *Visualize* and create a vertical bar chart. + +. Choose `rollup_logstash,kibana_sample_data_logs` as your source to see both the raw and rolled up data. + [role="screenshot"] diff --git a/docs/management/snapshot-restore/index.asciidoc b/docs/management/snapshot-restore/index.asciidoc index a64b74069f97..1bf62522e245 100644 --- a/docs/management/snapshot-restore/index.asciidoc +++ b/docs/management/snapshot-restore/index.asciidoc @@ -8,7 +8,7 @@ Snapshots are important because they provide a copy of your data in case something goes wrong. If you need to roll back to an older version of your data, you can restore a snapshot from the repository. -To get started, open the menu, then go to *Stack Management > {es} > Snapshot and Restore*. +To get started, open the menu, then go to *Stack Management > Data > Snapshot and Restore*. With this UI, you can: * Register a repository for storing your snapshots @@ -191,7 +191,7 @@ your master and data nodes. You can do this in one of two ways: Use *Snapshot and Restore* to register the repository where your snapshots will live. -. Open the menu, then go to *Stack Management > {es} > Snapshot and Restore*. +. Open the menu, then go to *Stack Management > Data > Snapshot and Restore*. . Click *Register a repository* in either the introductory message or *Repository view*. . Enter a name for your repository, for example, `my_backup`. . Select *Shared file system*. diff --git a/docs/management/upgrade-assistant/index.asciidoc b/docs/management/upgrade-assistant/index.asciidoc index ab6d0790ffa3..c5fd6a3a555a 100644 --- a/docs/management/upgrade-assistant/index.asciidoc +++ b/docs/management/upgrade-assistant/index.asciidoc @@ -2,50 +2,50 @@ [[upgrade-assistant]] == Upgrade Assistant -The Upgrade Assistant helps you prepare for your upgrade to the next major {es} version. -For example, if you are using 6.8, the Upgrade Assistant helps you to upgrade to 7.0. -To access the assistant, open the menu, then go to *Stack Management > {es} > Upgrade Assistant*. +The Upgrade Assistant helps you prepare for your upgrade to the next major {es} version. +For example, if you are using 6.8, the Upgrade Assistant helps you to upgrade to 7.0. +To access the assistant, open the menu, then go to *Stack Management > Stack > Upgrade Assistant*. -The assistant identifies the deprecated settings in your cluster and indices -and guides you through the process of resolving issues, including reindexing. +The assistant identifies the deprecated settings in your cluster and indices +and guides you through the process of resolving issues, including reindexing. -Before you upgrade, make sure that you are using the latest released minor -version of {es} to see the most up-to-date deprecation issues. +Before you upgrade, make sure that you are using the latest released minor +version of {es} to see the most up-to-date deprecation issues. For example, if you want to upgrade to to 7.0, make sure that you are using 6.8. [float] === Reindexing -The *Indices* page lists the indices that are incompatible with the next +The *Indices* page lists the indices that are incompatible with the next major version of {es}. You can initiate a reindex to resolve the issues. [role="screenshot"] image::images/management-upgrade-assistant-9.0.png[] -For a preview of how the data will change during the reindex, select the -index name. A warning appears if the index requires destructive changes. -Back up your index, then proceed with the reindex by accepting each breaking change. +For a preview of how the data will change during the reindex, select the +index name. A warning appears if the index requires destructive changes. +Back up your index, then proceed with the reindex by accepting each breaking change. -You can follow the progress as the Upgrade Assistant makes the index read-only, -creates a new index, reindexes the documents, and creates an alias that points -from the old index to the new one. +You can follow the progress as the Upgrade Assistant makes the index read-only, +creates a new index, reindexes the documents, and creates an alias that points +from the old index to the new one. -If the reindexing fails or is cancelled, the changes are rolled back, the -new index is deleted, and the original index becomes writable. An error +If the reindexing fails or is cancelled, the changes are rolled back, the +new index is deleted, and the original index becomes writable. An error message explains the reason for the failure. -You can reindex multiple indices at a time, but keep an eye on the -{es} metrics, including CPU usage, memory pressure, and disk usage. If a -metric is so high it affects query performance, cancel the reindex and +You can reindex multiple indices at a time, but keep an eye on the +{es} metrics, including CPU usage, memory pressure, and disk usage. If a +metric is so high it affects query performance, cancel the reindex and continue by reindexing fewer indices at a time. Additional considerations: * If you use {alert-features}, when you reindex the internal indices -(`.watches`), the {watcher} process pauses and no alerts are triggered. +(`.watches`), the {watcher} process pauses and no alerts are triggered. * If you use {ml-features}, when you reindex the internal indices (`.ml-state`), -the {ml} jobs pause and models are not trained or updated. +the {ml} jobs pause and models are not trained or updated. * If you use {security-features}, before you reindex the internal indices (`.security*`), it is a good idea to create a temporary superuser account in the diff --git a/docs/management/watcher-ui/index.asciidoc b/docs/management/watcher-ui/index.asciidoc index fa3e0cce04ff..fbe5fcd5cd3a 100644 --- a/docs/management/watcher-ui/index.asciidoc +++ b/docs/management/watcher-ui/index.asciidoc @@ -8,7 +8,8 @@ Watches are helpful for analyzing mission-critical and business-critical streaming data. For example, you might watch application logs for performance outages or audit access logs for security threats. -To get started with the Watcher UI, open then menu, then go to *Stack Management > {es} > Watcher*. +To get started with the Watcher UI, open then menu, +then go to *Stack Management > Alerts and Insights > Watcher*. With this UI, you can: * <> @@ -238,6 +239,3 @@ Refer to these examples for creating an advanced watch: * {ref}/watch-cluster-status.html[Watch the status of an {es} cluster] * {ref}/watching-meetup-data.html[Watch event data] - - - diff --git a/docs/spaces/index.asciidoc b/docs/spaces/index.asciidoc index bbc213dc2050..9e505b8bfe04 100644 --- a/docs/spaces/index.asciidoc +++ b/docs/spaces/index.asciidoc @@ -116,7 +116,8 @@ interface. You can create a custom experience for users by configuring the {kib} landing page on a per-space basis. The landing page can route users to a specific dashboard, application, or saved object as they enter each space. -To configure the landing page, use the default route setting in < Advanced settings>>. +To configure the landing page, use the default route setting in +< {kib} > Advanced settings>>. For example, you might set the default route to `/app/kibana#/dashboards`. [role="screenshot"] diff --git a/docs/user/reporting/index.asciidoc b/docs/user/reporting/index.asciidoc index e4e4b461ac2b..4f4d59315faf 100644 --- a/docs/user/reporting/index.asciidoc +++ b/docs/user/reporting/index.asciidoc @@ -94,7 +94,7 @@ image::user/reporting/images/preserve-layout-switch.png["Share"] [[manage-report-history]] == View and manage report history -For a list of your reports, open the menu, then go to *Stack Management > {kib} > Reporting*. +For a list of your reports, open the menu, then go to *Stack Management > Alerts and Insights > Reporting*. From this view, you can monitor the generation of a report and download reports that you previously generated. diff --git a/docs/user/security/rbac_tutorial.asciidoc b/docs/user/security/rbac_tutorial.asciidoc index d7299f814b43..3a4b2202201e 100644 --- a/docs/user/security/rbac_tutorial.asciidoc +++ b/docs/user/security/rbac_tutorial.asciidoc @@ -90,7 +90,7 @@ image::security/images/role-space-visualization.png["Associate space"] [float] ==== Create the developer user account with the proper roles -. Open the menu, then go to *Stack Management > Users*. +. Open the menu, then go to *Stack Management > Security > Users*. . Click **Create user**, then give the user the `dev-mortgage` and `monitoring-user` roles, which are required for *Stack Monitoring* users. From 9655c87564366400fa41d8a1cafb257e8059d8fa Mon Sep 17 00:00:00 2001 From: Spencer Date: Wed, 22 Jul 2020 08:02:39 -0700 Subject: [PATCH 039/142] [kbn/es-archiver] move to a package (#72318) Co-authored-by: spalger Co-authored-by: Elastic Machine --- .github/CODEOWNERS | 2 +- package.json | 1 + .../src/run/run_with_commands.test.ts | 4 +- .../src/run/run_with_commands.ts | 9 +- packages/kbn-es-archiver/package.json | 17 ++ .../kbn-es-archiver/src}/actions/edit.ts | 2 +- .../src}/actions/empty_kibana_index.ts | 0 .../kbn-es-archiver/src}/actions/index.ts | 0 .../kbn-es-archiver/src}/actions/load.ts | 2 +- .../src}/actions/rebuild_all.ts | 2 +- .../kbn-es-archiver/src}/actions/save.ts | 2 +- .../kbn-es-archiver/src}/actions/unload.ts | 2 +- packages/kbn-es-archiver/src/cli.ts | 244 ++++++++++++++++++ .../kbn-es-archiver/src}/es_archiver.ts | 0 .../kbn-es-archiver/src}/index.ts | 1 + .../src}/lib/__tests__/stats.ts | 0 .../src}/lib/archives/__tests__/format.ts | 6 +- .../src}/lib/archives/__tests__/parse.ts | 10 +- .../src}/lib/archives/constants.ts | 0 .../src}/lib/archives/filenames.ts | 0 .../src}/lib/archives/format.ts | 2 +- .../src}/lib/archives/index.ts | 0 .../src}/lib/archives/parse.ts | 8 +- .../kbn-es-archiver/src}/lib/directory.ts | 0 .../__tests__/generate_doc_records_stream.ts | 6 +- .../__tests__/index_doc_records_stream.ts | 2 +- .../src}/lib/docs/__tests__/stubs.ts | 0 .../lib/docs/generate_doc_records_stream.ts | 0 .../kbn-es-archiver/src}/lib/docs/index.ts | 0 .../src}/lib/docs/index_doc_records_stream.ts | 0 .../kbn-es-archiver/src}/lib/index.ts | 0 .../indices/__tests__/create_index_stream.ts | 6 +- .../indices/__tests__/delete_index_stream.ts | 2 +- .../generate_index_records_stream.ts | 6 +- .../src}/lib/indices/__tests__/stubs.ts | 0 .../src}/lib/indices/create_index_stream.ts | 0 .../src}/lib/indices/delete_index.ts | 0 .../src}/lib/indices/delete_index_stream.ts | 0 .../indices/generate_index_records_stream.ts | 0 .../kbn-es-archiver/src}/lib/indices/index.ts | 0 .../src}/lib/indices/kibana_index.ts | 2 +- .../kbn-es-archiver/src}/lib/progress.ts | 0 .../__tests__/filter_records_stream.ts | 6 +- .../src}/lib/records/filter_records_stream.ts | 0 .../kbn-es-archiver/src}/lib/records/index.ts | 0 .../kbn-es-archiver/src}/lib/stats.ts | 0 packages/kbn-es-archiver/src/lib/streams.ts | 20 ++ packages/kbn-es-archiver/tsconfig.json | 12 + packages/kbn-es-archiver/yarn.lock | 1 + .../src/functional_test_runner/index.ts | 2 +- scripts/es_archiver.js | 2 +- src/dev/build/tasks/copy_source_task.js | 1 - .../ingestion_pipeline_painless.json | 2 +- src/es_archiver/cli.ts | 183 ------------- src/es_archiver/cli_help.txt | 15 -- test/common/services/es_archiver.ts | 2 +- .../app_search/engines.ts | 2 +- .../common/suites/copy_to_space.ts | 2 +- .../suites/resolve_copy_to_space_conflicts.ts | 2 +- 59 files changed, 336 insertions(+), 254 deletions(-) create mode 100644 packages/kbn-es-archiver/package.json rename {src/es_archiver => packages/kbn-es-archiver/src}/actions/edit.ts (97%) rename {src/es_archiver => packages/kbn-es-archiver/src}/actions/empty_kibana_index.ts (100%) rename {src/es_archiver => packages/kbn-es-archiver/src}/actions/index.ts (100%) rename {src/es_archiver => packages/kbn-es-archiver/src}/actions/load.ts (99%) rename {src/es_archiver => packages/kbn-es-archiver/src}/actions/rebuild_all.ts (97%) rename {src/es_archiver => packages/kbn-es-archiver/src}/actions/save.ts (96%) rename {src/es_archiver => packages/kbn-es-archiver/src}/actions/unload.ts (97%) create mode 100644 packages/kbn-es-archiver/src/cli.ts rename {src/es_archiver => packages/kbn-es-archiver/src}/es_archiver.ts (100%) rename {src/es_archiver => packages/kbn-es-archiver/src}/index.ts (97%) rename {src/es_archiver => packages/kbn-es-archiver/src}/lib/__tests__/stats.ts (100%) rename {src/es_archiver => packages/kbn-es-archiver/src}/lib/archives/__tests__/format.ts (96%) rename {src/es_archiver => packages/kbn-es-archiver/src}/lib/archives/__tests__/parse.ts (97%) rename {src/es_archiver => packages/kbn-es-archiver/src}/lib/archives/constants.ts (100%) rename {src/es_archiver => packages/kbn-es-archiver/src}/lib/archives/filenames.ts (100%) rename {src/es_archiver => packages/kbn-es-archiver/src}/lib/archives/format.ts (94%) rename {src/es_archiver => packages/kbn-es-archiver/src}/lib/archives/index.ts (100%) rename {src/es_archiver => packages/kbn-es-archiver/src}/lib/archives/parse.ts (87%) rename {src/es_archiver => packages/kbn-es-archiver/src}/lib/directory.ts (100%) rename {src/es_archiver => packages/kbn-es-archiver/src}/lib/docs/__tests__/generate_doc_records_stream.ts (97%) rename {src/es_archiver => packages/kbn-es-archiver/src}/lib/docs/__tests__/index_doc_records_stream.ts (99%) rename {src/es_archiver => packages/kbn-es-archiver/src}/lib/docs/__tests__/stubs.ts (100%) rename {src/es_archiver => packages/kbn-es-archiver/src}/lib/docs/generate_doc_records_stream.ts (100%) rename {src/es_archiver => packages/kbn-es-archiver/src}/lib/docs/index.ts (100%) rename {src/es_archiver => packages/kbn-es-archiver/src}/lib/docs/index_doc_records_stream.ts (100%) rename {src/es_archiver => packages/kbn-es-archiver/src}/lib/index.ts (100%) rename {src/es_archiver => packages/kbn-es-archiver/src}/lib/indices/__tests__/create_index_stream.ts (98%) rename {src/es_archiver => packages/kbn-es-archiver/src}/lib/indices/__tests__/delete_index_stream.ts (99%) rename {src/es_archiver => packages/kbn-es-archiver/src}/lib/indices/__tests__/generate_index_records_stream.ts (97%) rename {src/es_archiver => packages/kbn-es-archiver/src}/lib/indices/__tests__/stubs.ts (100%) rename {src/es_archiver => packages/kbn-es-archiver/src}/lib/indices/create_index_stream.ts (100%) rename {src/es_archiver => packages/kbn-es-archiver/src}/lib/indices/delete_index.ts (100%) rename {src/es_archiver => packages/kbn-es-archiver/src}/lib/indices/delete_index_stream.ts (100%) rename {src/es_archiver => packages/kbn-es-archiver/src}/lib/indices/generate_index_records_stream.ts (100%) rename {src/es_archiver => packages/kbn-es-archiver/src}/lib/indices/index.ts (100%) rename {src/es_archiver => packages/kbn-es-archiver/src}/lib/indices/kibana_index.ts (98%) rename {src/es_archiver => packages/kbn-es-archiver/src}/lib/progress.ts (100%) rename {src/es_archiver => packages/kbn-es-archiver/src}/lib/records/__tests__/filter_records_stream.ts (95%) rename {src/es_archiver => packages/kbn-es-archiver/src}/lib/records/filter_records_stream.ts (100%) rename {src/es_archiver => packages/kbn-es-archiver/src}/lib/records/index.ts (100%) rename {src/es_archiver => packages/kbn-es-archiver/src}/lib/stats.ts (100%) create mode 100644 packages/kbn-es-archiver/src/lib/streams.ts create mode 100644 packages/kbn-es-archiver/tsconfig.json create mode 120000 packages/kbn-es-archiver/yarn.lock delete mode 100644 src/es_archiver/cli.ts delete mode 100644 src/es_archiver/cli_help.txt diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 2ad82ded6cb3..f1a374445657 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -115,7 +115,6 @@ /src/dev/ @elastic/kibana-operations /src/setup_node_env/ @elastic/kibana-operations /src/optimize/ @elastic/kibana-operations -/src/es_archiver/ @elastic/kibana-operations /packages/*eslint*/ @elastic/kibana-operations /packages/*babel*/ @elastic/kibana-operations /packages/kbn-dev-utils*/ @elastic/kibana-operations @@ -124,6 +123,7 @@ /packages/kbn-pm/ @elastic/kibana-operations /packages/kbn-test/ @elastic/kibana-operations /packages/kbn-ui-shared-deps/ @elastic/kibana-operations +/packages/kbn-es-archiver/ @elastic/kibana-operations /src/legacy/server/keystore/ @elastic/kibana-operations /src/legacy/server/pid/ @elastic/kibana-operations /src/legacy/server/sass/ @elastic/kibana-operations diff --git a/package.json b/package.json index 2f3f95854df0..0d6bc8cc1fce 100644 --- a/package.json +++ b/package.json @@ -300,6 +300,7 @@ "@elastic/makelogs": "^6.0.0", "@kbn/dev-utils": "1.0.0", "@kbn/es": "1.0.0", + "@kbn/es-archiver": "1.0.0", "@kbn/eslint-import-resolver-kibana": "2.0.0", "@kbn/eslint-plugin-eslint": "1.0.0", "@kbn/expect": "1.0.0", diff --git a/packages/kbn-dev-utils/src/run/run_with_commands.test.ts b/packages/kbn-dev-utils/src/run/run_with_commands.test.ts index eb7708998751..f6759b218bf3 100644 --- a/packages/kbn-dev-utils/src/run/run_with_commands.test.ts +++ b/packages/kbn-dev-utils/src/run/run_with_commands.test.ts @@ -61,9 +61,7 @@ it('extends the context using extendContext()', async () => { expect(context.flags).toMatchInlineSnapshot(` Object { - "_": Array [ - "foo", - ], + "_": Array [], "debug": false, "help": false, "quiet": false, diff --git a/packages/kbn-dev-utils/src/run/run_with_commands.ts b/packages/kbn-dev-utils/src/run/run_with_commands.ts index 9fb069e4b2d3..ca56a17b545a 100644 --- a/packages/kbn-dev-utils/src/run/run_with_commands.ts +++ b/packages/kbn-dev-utils/src/run/run_with_commands.ts @@ -91,6 +91,13 @@ export class RunWithCommands { const commandFlagOptions = mergeFlagOptions(this.options.globalFlags, command.flags); const commandFlags = getFlags(process.argv.slice(2), commandFlagOptions); + // strip command name plus "help" if we're actually executing the fake "help" command + if (isHelpCommand) { + commandFlags._.splice(0, 2); + } else { + commandFlags._.splice(0, 1); + } + const commandHelp = getCommandLevelHelp({ usage: this.options.usage, globalFlagHelp: this.options.globalFlags?.help, @@ -115,7 +122,7 @@ export class RunWithCommands { log, flags: commandFlags, procRunner, - addCleanupTask: cleanup.add, + addCleanupTask: cleanup.add.bind(cleanup), }; const extendedContext = { diff --git a/packages/kbn-es-archiver/package.json b/packages/kbn-es-archiver/package.json new file mode 100644 index 000000000000..13b5662519b1 --- /dev/null +++ b/packages/kbn-es-archiver/package.json @@ -0,0 +1,17 @@ +{ + "name": "@kbn/es-archiver", + "version": "1.0.0", + "license": "Apache-2.0", + "main": "target/index.js", + "scripts": { + "kbn:bootstrap": "tsc", + "kbn:watch": "tsc --watch" + }, + "dependencies": { + "@kbn/dev-utils": "1.0.0", + "elasticsearch": "^16.7.0" + }, + "devDependencies": { + "@types/elasticsearch": "^5.0.33" + } +} \ No newline at end of file diff --git a/src/es_archiver/actions/edit.ts b/packages/kbn-es-archiver/src/actions/edit.ts similarity index 97% rename from src/es_archiver/actions/edit.ts rename to packages/kbn-es-archiver/src/actions/edit.ts index afa51a3b9647..1194637b1ff8 100644 --- a/src/es_archiver/actions/edit.ts +++ b/packages/kbn-es-archiver/src/actions/edit.ts @@ -24,7 +24,7 @@ import { promisify } from 'util'; import globby from 'globby'; import { ToolingLog } from '@kbn/dev-utils'; -import { createPromiseFromStreams } from '../../legacy/utils'; +import { createPromiseFromStreams } from '../lib/streams'; const unlinkAsync = promisify(Fs.unlink); diff --git a/src/es_archiver/actions/empty_kibana_index.ts b/packages/kbn-es-archiver/src/actions/empty_kibana_index.ts similarity index 100% rename from src/es_archiver/actions/empty_kibana_index.ts rename to packages/kbn-es-archiver/src/actions/empty_kibana_index.ts diff --git a/src/es_archiver/actions/index.ts b/packages/kbn-es-archiver/src/actions/index.ts similarity index 100% rename from src/es_archiver/actions/index.ts rename to packages/kbn-es-archiver/src/actions/index.ts diff --git a/src/es_archiver/actions/load.ts b/packages/kbn-es-archiver/src/actions/load.ts similarity index 99% rename from src/es_archiver/actions/load.ts rename to packages/kbn-es-archiver/src/actions/load.ts index 03de8f39a7c0..efb1fe9f9ea5 100644 --- a/src/es_archiver/actions/load.ts +++ b/packages/kbn-es-archiver/src/actions/load.ts @@ -23,7 +23,7 @@ import { Readable } from 'stream'; import { ToolingLog, KbnClient } from '@kbn/dev-utils'; import { Client } from 'elasticsearch'; -import { createPromiseFromStreams, concatStreamProviders } from '../../legacy/utils'; +import { createPromiseFromStreams, concatStreamProviders } from '../lib/streams'; import { isGzip, diff --git a/src/es_archiver/actions/rebuild_all.ts b/packages/kbn-es-archiver/src/actions/rebuild_all.ts similarity index 97% rename from src/es_archiver/actions/rebuild_all.ts rename to packages/kbn-es-archiver/src/actions/rebuild_all.ts index dfbd51300e04..470a566a6eef 100644 --- a/src/es_archiver/actions/rebuild_all.ts +++ b/packages/kbn-es-archiver/src/actions/rebuild_all.ts @@ -23,7 +23,7 @@ import { Readable, Writable } from 'stream'; import { fromNode } from 'bluebird'; import { ToolingLog } from '@kbn/dev-utils'; -import { createPromiseFromStreams } from '../../legacy/utils'; +import { createPromiseFromStreams } from '../lib/streams'; import { prioritizeMappings, readDirectory, diff --git a/src/es_archiver/actions/save.ts b/packages/kbn-es-archiver/src/actions/save.ts similarity index 96% rename from src/es_archiver/actions/save.ts rename to packages/kbn-es-archiver/src/actions/save.ts index 7a3a9dd97c0a..2f87cabadee6 100644 --- a/src/es_archiver/actions/save.ts +++ b/packages/kbn-es-archiver/src/actions/save.ts @@ -23,7 +23,7 @@ import { Readable, Writable } from 'stream'; import { Client } from 'elasticsearch'; import { ToolingLog } from '@kbn/dev-utils'; -import { createListStream, createPromiseFromStreams } from '../../legacy/utils'; +import { createListStream, createPromiseFromStreams } from '../lib/streams'; import { createStats, createGenerateIndexRecordsStream, diff --git a/src/es_archiver/actions/unload.ts b/packages/kbn-es-archiver/src/actions/unload.ts similarity index 97% rename from src/es_archiver/actions/unload.ts rename to packages/kbn-es-archiver/src/actions/unload.ts index 130a6b542b21..ae23ef21fb79 100644 --- a/src/es_archiver/actions/unload.ts +++ b/packages/kbn-es-archiver/src/actions/unload.ts @@ -23,7 +23,7 @@ import { Readable, Writable } from 'stream'; import { Client } from 'elasticsearch'; import { ToolingLog, KbnClient } from '@kbn/dev-utils'; -import { createPromiseFromStreams } from '../../legacy/utils'; +import { createPromiseFromStreams } from '../lib/streams'; import { isGzip, createStats, diff --git a/packages/kbn-es-archiver/src/cli.ts b/packages/kbn-es-archiver/src/cli.ts new file mode 100644 index 000000000000..1745bd862b43 --- /dev/null +++ b/packages/kbn-es-archiver/src/cli.ts @@ -0,0 +1,244 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** *********************************************************** + * + * Run `node scripts/es_archiver --help` for usage information + * + *************************************************************/ + +import Path from 'path'; +import Url from 'url'; +import readline from 'readline'; + +import { RunWithCommands, createFlagError } from '@kbn/dev-utils'; +import { readConfigFile } from '@kbn/test'; +import legacyElasticsearch from 'elasticsearch'; + +import { EsArchiver } from './es_archiver'; + +const resolveConfigPath = (v: string) => Path.resolve(process.cwd(), v); +const defaultConfigPath = resolveConfigPath('test/functional/config.js'); + +export function runCli() { + new RunWithCommands({ + description: 'CLI to manage archiving/restoring data in elasticsearch', + globalFlags: { + string: ['es-url', 'kibana-url', 'dir', 'config'], + help: ` + --config path to an FTR config file that sets --es-url, --kibana-url, and --dir + default: ${defaultConfigPath} + --es-url url for Elasticsearch, prefer the --config flag + --kibana-url url for Kibana, prefer the --config flag + --dir where arechives are stored, prefer the --config flag + `, + }, + async extendContext({ log, flags, addCleanupTask }) { + const configPath = flags.config || defaultConfigPath; + if (typeof configPath !== 'string') { + throw createFlagError('--config must be a string'); + } + const config = await readConfigFile(log, Path.resolve(configPath)); + + let esUrl = flags['es-url']; + if (esUrl && typeof esUrl !== 'string') { + throw createFlagError('--es-url must be a string'); + } + if (!esUrl && config) { + esUrl = Url.format(config.get('servers.elasticsearch')); + } + if (!esUrl) { + throw createFlagError('--es-url or --config must be defined'); + } + + let kibanaUrl = flags['kibana-url']; + if (kibanaUrl && typeof kibanaUrl !== 'string') { + throw createFlagError('--kibana-url must be a string'); + } + if (!kibanaUrl && config) { + kibanaUrl = Url.format(config.get('servers.kibana')); + } + if (!kibanaUrl) { + throw createFlagError('--kibana-url or --config must be defined'); + } + + let dir = flags.dir; + if (dir && typeof dir !== 'string') { + throw createFlagError('--dir must be a string'); + } + if (!dir && config) { + dir = Path.resolve(config.get('esArchiver.directory')); + } + if (!dir) { + throw createFlagError('--dir or --config must be defined'); + } + + const client = new legacyElasticsearch.Client({ + host: esUrl, + log: flags.verbose ? 'trace' : [], + }); + addCleanupTask(() => client.close()); + + const esArchiver = new EsArchiver({ + log, + client, + dataDir: dir, + kibanaUrl, + }); + + return { + esArchiver, + }; + }, + }) + .command({ + name: 'save', + usage: 'save [name] [...indices]', + description: ` + archive the [indices ...] into the --dir with [name] + + Example: + Save all [logstash-*] indices from http://localhost:9200 to [snapshots/my_test_data] directory + + WARNING: If the [my_test_data] snapshot exists it will be deleted! + + $ node scripts/es_archiver save my_test_data logstash-* --dir snapshots + `, + flags: { + boolean: ['raw'], + help: ` + --raw don't gzip the archives + `, + }, + async run({ flags, esArchiver }) { + const [name, ...indices] = flags._; + if (!name) { + throw createFlagError('missing [name] argument'); + } + if (!indices.length) { + throw createFlagError('missing [...indices] arguments'); + } + + const raw = flags.raw; + if (typeof raw !== 'boolean') { + throw createFlagError('--raw does not take a value'); + } + + await esArchiver.save(name, indices, { raw }); + }, + }) + .command({ + name: 'load', + usage: 'load [name]', + description: ` + load the archive in --dir with [name] + + Example: + Load the [my_test_data] snapshot from the archive directory and elasticsearch instance defined + in the [test/functional/config.js] config file + + WARNING: If the indices exist already they will be deleted! + + $ node scripts/es_archiver load my_test_data --config test/functional/config.js + `, + flags: { + boolean: ['use-create'], + help: ` + --use-create use create instead of index for loading documents + `, + }, + async run({ flags, esArchiver }) { + const [name] = flags._; + if (!name) { + throw createFlagError('missing [name] argument'); + } + if (flags._.length > 1) { + throw createFlagError(`unknown extra arguments: [${flags._.slice(1).join(', ')}]`); + } + + const useCreate = flags['use-create']; + if (typeof useCreate !== 'boolean') { + throw createFlagError('--use-create does not take a value'); + } + + await esArchiver.load(name, { useCreate }); + }, + }) + .command({ + name: 'unload', + usage: 'unload [name]', + description: 'remove indices created by the archive in --dir with [name]', + async run({ flags, esArchiver }) { + const [name] = flags._; + if (!name) { + throw createFlagError('missing [name] argument'); + } + if (flags._.length > 1) { + throw createFlagError(`unknown extra arguments: [${flags._.slice(1).join(', ')}]`); + } + + await esArchiver.unload(name); + }, + }) + .command({ + name: 'edit', + usage: 'edit [prefix]', + description: + 'extract the archives under the prefix, wait for edits to be completed, and then recompress the archives', + async run({ flags, esArchiver }) { + const [prefix] = flags._; + if (!prefix) { + throw createFlagError('missing [prefix] argument'); + } + if (flags._.length > 1) { + throw createFlagError(`unknown extra arguments: [${flags._.slice(1).join(', ')}]`); + } + + await esArchiver.edit(prefix, async () => { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + await new Promise((resolveInput) => { + rl.question(`Press enter when you're done`, () => { + rl.close(); + resolveInput(); + }); + }); + }); + }, + }) + .command({ + name: 'empty-kibana-index', + description: + '[internal] Delete any Kibana indices, and initialize the Kibana index as Kibana would do on startup.', + async run({ esArchiver }) { + await esArchiver.emptyKibanaIndex(); + }, + }) + .command({ + name: 'rebuild-all', + description: '[internal] read and write all archives in --dir to remove any inconsistencies', + async run({ esArchiver }) { + await esArchiver.rebuildAll(); + }, + }) + .execute(); +} diff --git a/src/es_archiver/es_archiver.ts b/packages/kbn-es-archiver/src/es_archiver.ts similarity index 100% rename from src/es_archiver/es_archiver.ts rename to packages/kbn-es-archiver/src/es_archiver.ts diff --git a/src/es_archiver/index.ts b/packages/kbn-es-archiver/src/index.ts similarity index 97% rename from src/es_archiver/index.ts rename to packages/kbn-es-archiver/src/index.ts index f7a579a98a42..c00d457c939c 100644 --- a/src/es_archiver/index.ts +++ b/packages/kbn-es-archiver/src/index.ts @@ -18,3 +18,4 @@ */ export { EsArchiver } from './es_archiver'; +export * from './cli'; diff --git a/src/es_archiver/lib/__tests__/stats.ts b/packages/kbn-es-archiver/src/lib/__tests__/stats.ts similarity index 100% rename from src/es_archiver/lib/__tests__/stats.ts rename to packages/kbn-es-archiver/src/lib/__tests__/stats.ts diff --git a/src/es_archiver/lib/archives/__tests__/format.ts b/packages/kbn-es-archiver/src/lib/archives/__tests__/format.ts similarity index 96% rename from src/es_archiver/lib/archives/__tests__/format.ts rename to packages/kbn-es-archiver/src/lib/archives/__tests__/format.ts index f3829273ea80..044a0e82d9df 100644 --- a/src/es_archiver/lib/archives/__tests__/format.ts +++ b/packages/kbn-es-archiver/src/lib/archives/__tests__/format.ts @@ -22,11 +22,7 @@ import { createGunzip } from 'zlib'; import expect from '@kbn/expect'; -import { - createListStream, - createPromiseFromStreams, - createConcatStream, -} from '../../../../legacy/utils'; +import { createListStream, createPromiseFromStreams, createConcatStream } from '../../streams'; import { createFormatArchiveStreams } from '../format'; diff --git a/src/es_archiver/lib/archives/__tests__/parse.ts b/packages/kbn-es-archiver/src/lib/archives/__tests__/parse.ts similarity index 97% rename from src/es_archiver/lib/archives/__tests__/parse.ts rename to packages/kbn-es-archiver/src/lib/archives/__tests__/parse.ts index 50cbdfe06f36..25b8fe46a81f 100644 --- a/src/es_archiver/lib/archives/__tests__/parse.ts +++ b/packages/kbn-es-archiver/src/lib/archives/__tests__/parse.ts @@ -22,11 +22,7 @@ import { createGzip } from 'zlib'; import expect from '@kbn/expect'; -import { - createConcatStream, - createListStream, - createPromiseFromStreams, -} from '../../../../legacy/utils'; +import { createConcatStream, createListStream, createPromiseFromStreams } from '../../streams'; import { createParseArchiveStreams } from '../parse'; @@ -109,7 +105,7 @@ describe('esArchiver createParseArchiveStreams', () => { Buffer.from('{"a": 2}\n\n'), ]), ...createParseArchiveStreams({ gzip: false }), - createConcatStream(), + createConcatStream([]), ] as [Readable, ...Writable[]]); throw new Error('should have failed'); } catch (err) { @@ -172,7 +168,7 @@ describe('esArchiver createParseArchiveStreams', () => { await createPromiseFromStreams([ createListStream([Buffer.from('{"a": 1}')]), ...createParseArchiveStreams({ gzip: true }), - createConcatStream(), + createConcatStream([]), ] as [Readable, ...Writable[]]); throw new Error('should have failed'); } catch (err) { diff --git a/src/es_archiver/lib/archives/constants.ts b/packages/kbn-es-archiver/src/lib/archives/constants.ts similarity index 100% rename from src/es_archiver/lib/archives/constants.ts rename to packages/kbn-es-archiver/src/lib/archives/constants.ts diff --git a/src/es_archiver/lib/archives/filenames.ts b/packages/kbn-es-archiver/src/lib/archives/filenames.ts similarity index 100% rename from src/es_archiver/lib/archives/filenames.ts rename to packages/kbn-es-archiver/src/lib/archives/filenames.ts diff --git a/src/es_archiver/lib/archives/format.ts b/packages/kbn-es-archiver/src/lib/archives/format.ts similarity index 94% rename from src/es_archiver/lib/archives/format.ts rename to packages/kbn-es-archiver/src/lib/archives/format.ts index ac18147ad694..3cd698c3f82c 100644 --- a/src/es_archiver/lib/archives/format.ts +++ b/packages/kbn-es-archiver/src/lib/archives/format.ts @@ -21,7 +21,7 @@ import { createGzip, Z_BEST_COMPRESSION } from 'zlib'; import { PassThrough } from 'stream'; import stringify from 'json-stable-stringify'; -import { createMapStream, createIntersperseStream } from '../../../legacy/utils'; +import { createMapStream, createIntersperseStream } from '../streams'; import { RECORD_SEPARATOR } from './constants'; export function createFormatArchiveStreams({ gzip = false }: { gzip?: boolean } = {}) { diff --git a/src/es_archiver/lib/archives/index.ts b/packages/kbn-es-archiver/src/lib/archives/index.ts similarity index 100% rename from src/es_archiver/lib/archives/index.ts rename to packages/kbn-es-archiver/src/lib/archives/index.ts diff --git a/src/es_archiver/lib/archives/parse.ts b/packages/kbn-es-archiver/src/lib/archives/parse.ts similarity index 87% rename from src/es_archiver/lib/archives/parse.ts rename to packages/kbn-es-archiver/src/lib/archives/parse.ts index 1d650815f935..9236a618aa01 100644 --- a/src/es_archiver/lib/archives/parse.ts +++ b/packages/kbn-es-archiver/src/lib/archives/parse.ts @@ -19,8 +19,12 @@ import { createGunzip } from 'zlib'; import { PassThrough } from 'stream'; -import { createFilterStream } from '../../../legacy/utils/streams/filter_stream'; -import { createSplitStream, createReplaceStream, createMapStream } from '../../../legacy/utils'; +import { + createFilterStream, + createSplitStream, + createReplaceStream, + createMapStream, +} from '../streams'; import { RECORD_SEPARATOR } from './constants'; diff --git a/src/es_archiver/lib/directory.ts b/packages/kbn-es-archiver/src/lib/directory.ts similarity index 100% rename from src/es_archiver/lib/directory.ts rename to packages/kbn-es-archiver/src/lib/directory.ts diff --git a/src/es_archiver/lib/docs/__tests__/generate_doc_records_stream.ts b/packages/kbn-es-archiver/src/lib/docs/__tests__/generate_doc_records_stream.ts similarity index 97% rename from src/es_archiver/lib/docs/__tests__/generate_doc_records_stream.ts rename to packages/kbn-es-archiver/src/lib/docs/__tests__/generate_doc_records_stream.ts index 03599cdc9fbc..2214f7ae9f2e 100644 --- a/src/es_archiver/lib/docs/__tests__/generate_doc_records_stream.ts +++ b/packages/kbn-es-archiver/src/lib/docs/__tests__/generate_doc_records_stream.ts @@ -21,11 +21,7 @@ import sinon from 'sinon'; import expect from '@kbn/expect'; import { delay } from 'bluebird'; -import { - createListStream, - createPromiseFromStreams, - createConcatStream, -} from '../../../../legacy/utils'; +import { createListStream, createPromiseFromStreams, createConcatStream } from '../../streams'; import { createGenerateDocRecordsStream } from '../generate_doc_records_stream'; import { Progress } from '../../progress'; diff --git a/src/es_archiver/lib/docs/__tests__/index_doc_records_stream.ts b/packages/kbn-es-archiver/src/lib/docs/__tests__/index_doc_records_stream.ts similarity index 99% rename from src/es_archiver/lib/docs/__tests__/index_doc_records_stream.ts rename to packages/kbn-es-archiver/src/lib/docs/__tests__/index_doc_records_stream.ts index 35b068a69109..2b8eac5c2712 100644 --- a/src/es_archiver/lib/docs/__tests__/index_doc_records_stream.ts +++ b/packages/kbn-es-archiver/src/lib/docs/__tests__/index_doc_records_stream.ts @@ -20,7 +20,7 @@ import expect from '@kbn/expect'; import { delay } from 'bluebird'; -import { createListStream, createPromiseFromStreams } from '../../../../legacy/utils'; +import { createListStream, createPromiseFromStreams } from '../../streams'; import { Progress } from '../../progress'; import { createIndexDocRecordsStream } from '../index_doc_records_stream'; diff --git a/src/es_archiver/lib/docs/__tests__/stubs.ts b/packages/kbn-es-archiver/src/lib/docs/__tests__/stubs.ts similarity index 100% rename from src/es_archiver/lib/docs/__tests__/stubs.ts rename to packages/kbn-es-archiver/src/lib/docs/__tests__/stubs.ts diff --git a/src/es_archiver/lib/docs/generate_doc_records_stream.ts b/packages/kbn-es-archiver/src/lib/docs/generate_doc_records_stream.ts similarity index 100% rename from src/es_archiver/lib/docs/generate_doc_records_stream.ts rename to packages/kbn-es-archiver/src/lib/docs/generate_doc_records_stream.ts diff --git a/src/es_archiver/lib/docs/index.ts b/packages/kbn-es-archiver/src/lib/docs/index.ts similarity index 100% rename from src/es_archiver/lib/docs/index.ts rename to packages/kbn-es-archiver/src/lib/docs/index.ts diff --git a/src/es_archiver/lib/docs/index_doc_records_stream.ts b/packages/kbn-es-archiver/src/lib/docs/index_doc_records_stream.ts similarity index 100% rename from src/es_archiver/lib/docs/index_doc_records_stream.ts rename to packages/kbn-es-archiver/src/lib/docs/index_doc_records_stream.ts diff --git a/src/es_archiver/lib/index.ts b/packages/kbn-es-archiver/src/lib/index.ts similarity index 100% rename from src/es_archiver/lib/index.ts rename to packages/kbn-es-archiver/src/lib/index.ts diff --git a/src/es_archiver/lib/indices/__tests__/create_index_stream.ts b/packages/kbn-es-archiver/src/lib/indices/__tests__/create_index_stream.ts similarity index 98% rename from src/es_archiver/lib/indices/__tests__/create_index_stream.ts rename to packages/kbn-es-archiver/src/lib/indices/__tests__/create_index_stream.ts index c90497eded88..27c28b2229ae 100644 --- a/src/es_archiver/lib/indices/__tests__/create_index_stream.ts +++ b/packages/kbn-es-archiver/src/lib/indices/__tests__/create_index_stream.ts @@ -21,11 +21,7 @@ import expect from '@kbn/expect'; import sinon from 'sinon'; import Chance from 'chance'; -import { - createPromiseFromStreams, - createConcatStream, - createListStream, -} from '../../../../legacy/utils'; +import { createPromiseFromStreams, createConcatStream, createListStream } from '../../streams'; import { createCreateIndexStream } from '../create_index_stream'; diff --git a/src/es_archiver/lib/indices/__tests__/delete_index_stream.ts b/packages/kbn-es-archiver/src/lib/indices/__tests__/delete_index_stream.ts similarity index 99% rename from src/es_archiver/lib/indices/__tests__/delete_index_stream.ts rename to packages/kbn-es-archiver/src/lib/indices/__tests__/delete_index_stream.ts index 1c989ba158a2..551b744415c8 100644 --- a/src/es_archiver/lib/indices/__tests__/delete_index_stream.ts +++ b/packages/kbn-es-archiver/src/lib/indices/__tests__/delete_index_stream.ts @@ -19,7 +19,7 @@ import sinon from 'sinon'; -import { createListStream, createPromiseFromStreams } from '../../../../legacy/utils'; +import { createListStream, createPromiseFromStreams } from '../../streams'; import { createDeleteIndexStream } from '../delete_index_stream'; diff --git a/src/es_archiver/lib/indices/__tests__/generate_index_records_stream.ts b/packages/kbn-es-archiver/src/lib/indices/__tests__/generate_index_records_stream.ts similarity index 97% rename from src/es_archiver/lib/indices/__tests__/generate_index_records_stream.ts rename to packages/kbn-es-archiver/src/lib/indices/__tests__/generate_index_records_stream.ts index fe927483da7b..cb3746c015da 100644 --- a/src/es_archiver/lib/indices/__tests__/generate_index_records_stream.ts +++ b/packages/kbn-es-archiver/src/lib/indices/__tests__/generate_index_records_stream.ts @@ -20,11 +20,7 @@ import sinon from 'sinon'; import expect from '@kbn/expect'; -import { - createListStream, - createPromiseFromStreams, - createConcatStream, -} from '../../../../legacy/utils'; +import { createListStream, createPromiseFromStreams, createConcatStream } from '../../streams'; import { createStubClient, createStubStats } from './stubs'; diff --git a/src/es_archiver/lib/indices/__tests__/stubs.ts b/packages/kbn-es-archiver/src/lib/indices/__tests__/stubs.ts similarity index 100% rename from src/es_archiver/lib/indices/__tests__/stubs.ts rename to packages/kbn-es-archiver/src/lib/indices/__tests__/stubs.ts diff --git a/src/es_archiver/lib/indices/create_index_stream.ts b/packages/kbn-es-archiver/src/lib/indices/create_index_stream.ts similarity index 100% rename from src/es_archiver/lib/indices/create_index_stream.ts rename to packages/kbn-es-archiver/src/lib/indices/create_index_stream.ts diff --git a/src/es_archiver/lib/indices/delete_index.ts b/packages/kbn-es-archiver/src/lib/indices/delete_index.ts similarity index 100% rename from src/es_archiver/lib/indices/delete_index.ts rename to packages/kbn-es-archiver/src/lib/indices/delete_index.ts diff --git a/src/es_archiver/lib/indices/delete_index_stream.ts b/packages/kbn-es-archiver/src/lib/indices/delete_index_stream.ts similarity index 100% rename from src/es_archiver/lib/indices/delete_index_stream.ts rename to packages/kbn-es-archiver/src/lib/indices/delete_index_stream.ts diff --git a/src/es_archiver/lib/indices/generate_index_records_stream.ts b/packages/kbn-es-archiver/src/lib/indices/generate_index_records_stream.ts similarity index 100% rename from src/es_archiver/lib/indices/generate_index_records_stream.ts rename to packages/kbn-es-archiver/src/lib/indices/generate_index_records_stream.ts diff --git a/src/es_archiver/lib/indices/index.ts b/packages/kbn-es-archiver/src/lib/indices/index.ts similarity index 100% rename from src/es_archiver/lib/indices/index.ts rename to packages/kbn-es-archiver/src/lib/indices/index.ts diff --git a/src/es_archiver/lib/indices/kibana_index.ts b/packages/kbn-es-archiver/src/lib/indices/kibana_index.ts similarity index 98% rename from src/es_archiver/lib/indices/kibana_index.ts rename to packages/kbn-es-archiver/src/lib/indices/kibana_index.ts index 1867f24d6f9e..79e758f09ccf 100644 --- a/src/es_archiver/lib/indices/kibana_index.ts +++ b/packages/kbn-es-archiver/src/lib/indices/kibana_index.ts @@ -75,7 +75,7 @@ export async function migrateKibanaIndex({ }, } as any); - return await kbnClient.savedObjects.migrate(); + await kbnClient.savedObjects.migrate(); } /** diff --git a/src/es_archiver/lib/progress.ts b/packages/kbn-es-archiver/src/lib/progress.ts similarity index 100% rename from src/es_archiver/lib/progress.ts rename to packages/kbn-es-archiver/src/lib/progress.ts diff --git a/src/es_archiver/lib/records/__tests__/filter_records_stream.ts b/packages/kbn-es-archiver/src/lib/records/__tests__/filter_records_stream.ts similarity index 95% rename from src/es_archiver/lib/records/__tests__/filter_records_stream.ts rename to packages/kbn-es-archiver/src/lib/records/__tests__/filter_records_stream.ts index f4f9f32e239e..b23ff2e4e52a 100644 --- a/src/es_archiver/lib/records/__tests__/filter_records_stream.ts +++ b/packages/kbn-es-archiver/src/lib/records/__tests__/filter_records_stream.ts @@ -20,11 +20,7 @@ import Chance from 'chance'; import expect from '@kbn/expect'; -import { - createListStream, - createPromiseFromStreams, - createConcatStream, -} from '../../../../legacy/utils'; +import { createListStream, createPromiseFromStreams, createConcatStream } from '../../streams'; import { createFilterRecordsStream } from '../filter_records_stream'; diff --git a/src/es_archiver/lib/records/filter_records_stream.ts b/packages/kbn-es-archiver/src/lib/records/filter_records_stream.ts similarity index 100% rename from src/es_archiver/lib/records/filter_records_stream.ts rename to packages/kbn-es-archiver/src/lib/records/filter_records_stream.ts diff --git a/src/es_archiver/lib/records/index.ts b/packages/kbn-es-archiver/src/lib/records/index.ts similarity index 100% rename from src/es_archiver/lib/records/index.ts rename to packages/kbn-es-archiver/src/lib/records/index.ts diff --git a/src/es_archiver/lib/stats.ts b/packages/kbn-es-archiver/src/lib/stats.ts similarity index 100% rename from src/es_archiver/lib/stats.ts rename to packages/kbn-es-archiver/src/lib/stats.ts diff --git a/packages/kbn-es-archiver/src/lib/streams.ts b/packages/kbn-es-archiver/src/lib/streams.ts new file mode 100644 index 000000000000..a90afbe0c4d2 --- /dev/null +++ b/packages/kbn-es-archiver/src/lib/streams.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export * from '../../../../src/legacy/utils/streams'; diff --git a/packages/kbn-es-archiver/tsconfig.json b/packages/kbn-es-archiver/tsconfig.json new file mode 100644 index 000000000000..6ffa64d91fba --- /dev/null +++ b/packages/kbn-es-archiver/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./target", + "declaration": true, + "sourceMap": true, + "target": "ES2019" + }, + "include": [ + "src/**/*" + ] +} diff --git a/packages/kbn-es-archiver/yarn.lock b/packages/kbn-es-archiver/yarn.lock new file mode 120000 index 000000000000..3f82ebc9cdba --- /dev/null +++ b/packages/kbn-es-archiver/yarn.lock @@ -0,0 +1 @@ +../../yarn.lock \ No newline at end of file diff --git a/packages/kbn-test/src/functional_test_runner/index.ts b/packages/kbn-test/src/functional_test_runner/index.ts index cf65ceb51df8..b13c311350ff 100644 --- a/packages/kbn-test/src/functional_test_runner/index.ts +++ b/packages/kbn-test/src/functional_test_runner/index.ts @@ -18,6 +18,6 @@ */ export { FunctionalTestRunner } from './functional_test_runner'; -export { readConfigFile } from './lib'; +export { readConfigFile, Config } from './lib'; export { runFtrCli } from './cli'; export * from './lib/docker_servers'; diff --git a/scripts/es_archiver.js b/scripts/es_archiver.js index faa8d9131240..88674ce9eafb 100755 --- a/scripts/es_archiver.js +++ b/scripts/es_archiver.js @@ -18,4 +18,4 @@ */ require('../src/setup_node_env'); -require('../src/es_archiver/cli'); +require('@kbn/es-archiver').runCli(); diff --git a/src/dev/build/tasks/copy_source_task.js b/src/dev/build/tasks/copy_source_task.js index e34f05bd6cff..52809449ba33 100644 --- a/src/dev/build/tasks/copy_source_task.js +++ b/src/dev/build/tasks/copy_source_task.js @@ -37,7 +37,6 @@ export const CopySourceTask = { '!src/legacy/core_plugins/console/public/tests/**', '!src/cli/cluster/**', '!src/cli/repl/**', - '!src/es_archiver/**', '!src/functional_test_runner/**', '!src/dev/**', 'typings/**', diff --git a/src/dev/code_coverage/ingest_coverage/team_assignment/ingestion_pipeline_painless.json b/src/dev/code_coverage/ingest_coverage/team_assignment/ingestion_pipeline_painless.json index 18e88b47ec88..30e78635ec2e 100644 --- a/src/dev/code_coverage/ingest_coverage/team_assignment/ingestion_pipeline_painless.json +++ b/src/dev/code_coverage/ingest_coverage/team_assignment/ingestion_pipeline_painless.json @@ -1 +1 @@ -{"description":"Kibana code coverage team assignments","processors":[{"script":{"lang":"painless","source":"\n String path = ctx.coveredFilePath; \n if (path.indexOf('src/legacy/core_plugins/kibana/') == 0) {\n\n if (path.indexOf('src/legacy/core_plugins/kibana/common/utils') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/core_plugins/kibana/migrations') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/core_plugins/kibana/public') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/core_plugins/kibana/public/dashboard/') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/core_plugins/kibana/public/dev_tools/') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/core_plugins/kibana/public/discover/') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/core_plugins/kibana/public/home') == 0) ctx.team = 'kibana-core-ui';\n else if (path.indexOf('src/legacy/core_plugins/kibana/public/home/np_ready/') == 0) ctx.team = 'kibana-core-ui';\n else if (path.indexOf('src/legacy/core_plugins/kibana/public/local_application_service/') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/core_plugins/kibana/public/management/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/legacy/core_plugins/kibana/server/lib') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/legacy/core_plugins/kibana/server/lib/management/saved_objects') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/legacy/core_plugins/kibana/server/routes/api/management/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/legacy/core_plugins/kibana/server/routes/api/import/') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/legacy/core_plugins/kibana/server/routes/api/export/') == 0) ctx.team = 'kibana-platform';\n else ctx.team = 'unknown';\n\n } else if (path.indexOf('src/legacy/core_plugins/') == 0) {\n\n if (path.indexOf('src/legacy/core_plugins/apm_oss/') == 0) ctx.team = 'apm-ui';\n else if (path.indexOf('src/legacy/core_plugins/console_legacy') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/core_plugins/elasticsearch') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/legacy/core_plugins/embeddable_api/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/legacy/core_plugins/input_control_vis') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/core_plugins/interpreter/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/legacy/core_plugins/kibana_react/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/legacy/core_plugins/newsfeed') == 0) ctx.team = 'kibana-core-ui';\n else if (path.indexOf('src/legacy/core_plugins/region_map') == 0) ctx.team = 'maps';\n else if (path.indexOf('src/legacy/core_plugins/status_page/public') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/legacy/core_plugins/testbed') == 0) ctx.team = 'kibana-platform';\n // else if (path.indexOf('src/legacy/core_plugins/tests_bundle/') == 0) ctx.team = 'kibana-platform';\n \n else if (path.indexOf('src/legacy/core_plugins/tile_map') == 0) ctx.team = 'maps';\n else if (path.indexOf('src/legacy/core_plugins/timelion') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/core_plugins/ui_metric/') == 0) ctx.team = 'pulse';\n else if (path.indexOf('src/legacy/core_plugins/vis_type_tagcloud') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/core_plugins/vis_type_vega') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/core_plugins/vis_type_vislib/') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/core_plugins/visualizations/') == 0) ctx.team = 'kibana-app-arch';\n else ctx.team = 'unknown';\n\n } else if (path.indexOf('src/legacy/server/') == 0) {\n\n if (path.indexOf('src/legacy/server/config/') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/legacy/server/http/') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/legacy/server/i18n/') == 0) ctx.team = 'kibana-localization';\n else if (path.indexOf('src/legacy/server/index_patterns/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/legacy/server/keystore/') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('src/legacy/server/logging/') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/legacy/server/pid/') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('src/legacy/server/sample_data/') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/server/sass/') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('src/legacy/server/saved_objects/') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/legacy/server/status/') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/legacy/server/url_shortening/') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/server/utils/') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('src/legacy/server/warnings/') == 0) ctx.team = 'kibana-operations';\n else ctx.team = 'unknown';\n\n } else if (path.indexOf('src/legacy/ui') == 0) {\n\n if (path.indexOf('src/legacy/ui/public/field_editor') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/legacy/ui/public/timefilter') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/legacy/ui/public/management') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/legacy/ui/public/state_management') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/ui/public/new_platform') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/legacy/ui/public/plugin_discovery') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/legacy/ui/public/chrome') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/legacy/ui/public/notify') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/legacy/ui/public/documentation_links') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/legacy/ui/public/autoload') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/legacy/ui/public/capabilities') == 0) ctx.team = 'kibana-security';\n else if (path.indexOf('src/legacy/ui/public/apm') == 0) ctx.team = 'apm-ui';\n\n } else if (path.indexOf('src/plugins/') == 0) {\n\n if (path.indexOf('src/plugins/advanced_settings/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/apm_oss/') == 0) ctx.team = 'apm-ui';\n else if (path.indexOf('src/plugins/bfetch/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/charts/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/charts/public/static/color_maps') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/plugins/console/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('src/plugins/dashboard/') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/plugins/data/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/dev_tools/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('src/plugins/discover/') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/plugins/embeddable/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/es_ui_shared/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('src/plugins/expressions/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/home/public') == 0) ctx.team = 'kibana-core-ui';\n else if (path.indexOf('src/plugins/home/server/tutorials') == 0) ctx.team = 'observability';\n else if (path.indexOf('src/plugins/home/server/services/') == 0) ctx.team = 'kibana-core-ui';\n else if (path.indexOf('src/plugins/home/') == 0) ctx.team = 'kibana-core-ui';\n else if (path.indexOf('src/plugins/index_pattern_management/public/service') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/index_pattern_management/public') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/plugins/input_control_vis/') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/plugins/inspector/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/kibana_legacy/') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/plugins/kibana_react/public/code_editor') == 0) ctx.team = 'kibana-canvas';\n else if (path.indexOf('src/plugins/kibana_react/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/kibana_utils/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/management/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/kibana_usage_collection/') == 0) ctx.team = 'pulse';\n else if (path.indexOf('src/plugins/legacy_export/') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/plugins/maps_legacy/') == 0) ctx.team = 'maps';\n else if (path.indexOf('src/plugins/region_map/') == 0) ctx.team = 'maps';\n else if (path.indexOf('src/plugins/tile_map/') == 0) ctx.team = 'maps';\n else if (path.indexOf('src/plugins/timelion') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/plugins/navigation/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/newsfeed') == 0) ctx.team = 'kibana-core-ui';\n else if (path.indexOf('src/plugins/saved_objects_management/') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/plugins/saved_objects/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/share/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/status_page/') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/plugins/telemetry') == 0) ctx.team = 'pulse';\n else if (path.indexOf('src/plugins/testbed/server/') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/plugins/ui_actions/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/usage_collection/') == 0) ctx.team = 'pulse';\n else if (path.indexOf('src/plugins/vis_default_editor') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/vis_type') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/plugins/visualizations/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/visualize/') == 0) ctx.team = 'kibana-app';\n else ctx.team = 'unknown';\n\n } else if (path.indexOf('x-pack/legacy/') == 0) {\n\n if (path.indexOf('x-pack/legacy/plugins/actions/') == 0) ctx.team = 'kibana-alerting-services';\n else if (path.indexOf('x-pack/legacy/plugins/alerting/') == 0) ctx.team = 'kibana-alerting-services';\n else if (path.indexOf('x-pack/legacy/plugins/apm/') == 0) ctx.team = 'apm-ui';\n else if (path.indexOf('x-pack/legacy/plugins/beats_management/') == 0) ctx.team = 'beats';\n else if (path.indexOf('x-pack/legacy/plugins/canvas/') == 0) ctx.team = 'kibana-canvas';\n else if (path.indexOf('x-pack/legacy/plugins/cross_cluster_replication/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/legacy/plugins/dashboard_mode/') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('x-pack/legacy/plugins/encrypted_saved_objects/') == 0) ctx.team = 'kibana-security';\n else if (path.indexOf('x-pack/legacy/plugins/index_management/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/legacy/plugins/infra/') == 0) ctx.team = 'logs-metrics-ui';\n else if (path.indexOf('x-pack/legacy/plugins/ingest_manager/') == 0) ctx.team = 'ingest-management';\n else if (path.indexOf('x-pack/legacy/plugins/license_management/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/legacy/plugins/maps/') == 0) ctx.team = 'kibana-gis';\n else if (path.indexOf('x-pack/legacy/plugins/ml/') == 0) ctx.team = 'ml-ui';\n else if (path.indexOf('x-pack/legacy/plugins/monitoring/') == 0) ctx.team = 'stack-monitoring-ui';\n else if (path.indexOf('x-pack/legacy/plugins/reporting') == 0) ctx.team = 'kibana-reporting';\n else if (path.indexOf('x-pack/legacy/plugins/rollup/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/legacy/plugins/security/') == 0) ctx.team = 'kibana-security';\n else if (path.indexOf('x-pack/legacy/plugins/siem/') == 0) ctx.team = 'siem';\n else if (path.indexOf('x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules') == 0) ctx.team = 'security-intelligence-analytics';\n else if (path.indexOf('x-pack/legacy/plugins/snapshot_restore/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/legacy/plugins/spaces/') == 0) ctx.team = 'kibana-security';\n else if (path.indexOf('x-pack/legacy/plugins/task_manager') == 0) ctx.team = 'kibana-alerting-services';\n else if (path.indexOf('x-pack/legacy/plugins/triggers_actions_ui/') == 0) ctx.team = 'kibana-alerting-services';\n else if (path.indexOf('x-pack/legacy/plugins/upgrade_assistant/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/legacy/plugins/uptime') == 0) ctx.team = 'uptime';\n else if (path.indexOf('x-pack/legacy/plugins/xpack_main/server/') == 0) ctx.team = 'kibana-platform';\n\n else if (path.indexOf('x-pack/legacy/server/lib/create_router/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/legacy/server/lib/check_license/') == 0) ctx.team = 'es-ui'; \n else if (path.indexOf('x-pack/legacy/server/lib/') == 0) ctx.team = 'kibana-platform'; \n else ctx.team = 'unknown';\n\n } else if (path.indexOf('x-pack/plugins/') == 0) {\n\n if (path.indexOf('x-pack/plugins/actions/') == 0) ctx.team = 'kibana-alerting-services';\n else if (path.indexOf('x-pack/plugins/advanced_ui_actions/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('x-pack/plugins/alerts') == 0) ctx.team = 'kibana-alerting-services';\n else if (path.indexOf('x-pack/plugins/alerting_builtins') == 0) ctx.team = 'kibana-alerting-services';\n else if (path.indexOf('x-pack/plugins/apm/') == 0) ctx.team = 'apm-ui';\n else if (path.indexOf('x-pack/plugins/beats_management/') == 0) ctx.team = 'beats';\n else if (path.indexOf('x-pack/plugins/canvas/') == 0) ctx.team = 'kibana-canvas';\n else if (path.indexOf('x-pack/plugins/case') == 0) ctx.team = 'siem';\n else if (path.indexOf('x-pack/plugins/cloud/') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('x-pack/plugins/code/') == 0) ctx.team = 'code';\n else if (path.indexOf('x-pack/plugins/console_extensions/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/plugins/cross_cluster_replication/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/plugins/dashboard_enhanced') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('x-pack/plugins/dashboard_mode') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('x-pack/plugins/discover_enhanced') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('x-pack/plugins/embeddable_enhanced') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('x-pack/plugins/data_enhanced/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('x-pack/plugins/drilldowns/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('x-pack/plugins/encrypted_saved_objects/') == 0) ctx.team = 'kibana-security';\n else if (path.indexOf('x-pack/plugins/endpoint/') == 0) ctx.team = 'endpoint-app-team';\n else if (path.indexOf('x-pack/plugins/es_ui_shared/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/plugins/event_log/') == 0) ctx.team = 'kibana-alerting-services';\n else if (path.indexOf('x-pack/plugins/features/') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('x-pack/plugins/file_upload') == 0) ctx.team = 'kibana-gis';\n else if (path.indexOf('x-pack/plugins/global_search') == 0) ctx.team = 'kibana-platform';\n \n else if (path.indexOf('x-pack/plugins/graph/') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('x-pack/plugins/grokdebugger/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/plugins/index_lifecycle_management/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/plugins/index_management/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/plugins/infra/') == 0) ctx.team = 'logs-metrics-ui';\n else if (path.indexOf('x-pack/plugins/ingest_manager/') == 0) ctx.team = 'ingest-management';\n else if (path.indexOf('x-pack/plugins/ingest_pipelines/') == 0) ctx.team = 'es-ui';\n \n else if (path.indexOf('x-pack/plugins/lens/') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('x-pack/plugins/license_management/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/plugins/licensing/') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('x-pack/plugins/lists/') == 0) ctx.team = 'siem';\n else if (path.indexOf('x-pack/plugins/logstash') == 0) ctx.team = 'logstash';\n else if (path.indexOf('x-pack/plugins/maps/') == 0) ctx.team = 'kibana-gis';\n else if (path.indexOf('x-pack/plugins/maps_legacy_licensing') == 0) ctx.team = 'maps';\n else if (path.indexOf('x-pack/plugins/ml/') == 0) ctx.team = 'ml-ui';\n else if (path.indexOf('x-pack/plugins/monitoring') == 0) ctx.team = 'stack-monitoring-ui';\n else if (path.indexOf('x-pack/plugins/observability/') == 0) ctx.team = 'apm-ui';\n else if (path.indexOf('x-pack/plugins/oss_telemetry/') == 0) ctx.team = 'pulse';\n else if (path.indexOf('x-pack/plugins/painless_lab/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/plugins/remote_clusters/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/plugins/reporting') == 0) ctx.team = 'kibana-reporting';\n else if (path.indexOf('x-pack/plugins/rollup/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/plugins/searchprofiler/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/plugins/security/') == 0) ctx.team = 'kibana-security';\n else if (path.indexOf('x-pack/plugins/security_solution/') == 0) ctx.team = 'siem';\n \n else if (path.indexOf('x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules') == 0) ctx.team = 'security-intelligence-analytics';\n else if (path.indexOf('x-pack/plugins/siem/') == 0) ctx.team = 'siem';\n else if (path.indexOf('x-pack/plugins/snapshot_restore/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/plugins/spaces/') == 0) ctx.team = 'kibana-security';\n else if (path.indexOf('x-pack/plugins/task_manager/') == 0) ctx.team = 'kibana-alerting-services';\n else if (path.indexOf('x-pack/plugins/telemetry_collection_xpack/') == 0) ctx.team = 'pulse';\n else if (path.indexOf('x-pack/plugins/transform/') == 0) ctx.team = 'ml-ui';\n else if (path.indexOf('x-pack/plugins/translations/') == 0) ctx.team = 'kibana-localization';\n else if (path.indexOf('x-pack/plugins/triggers_actions_ui/') == 0) ctx.team = 'kibana-alerting-services';\n else if (path.indexOf('x-pack/plugins/upgrade_assistant/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/plugins/ui_actions_enhanced') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('x-pack/plugins/uptime') == 0) ctx.team = 'uptime';\n \n else if (path.indexOf('x-pack/plugins/watcher/') == 0) ctx.team = 'es-ui';\n else ctx.team = 'unknown';\n\n } else if (path.indexOf('packages') == 0) {\n\n if (path.indexOf('packages/kbn-analytics/') == 0) ctx.team = 'pulse';\n else if (path.indexOf('packages/kbn-babel') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('packages/kbn-config-schema/') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('packages/elastic-datemath') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('packages/kbn-dev-utils') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('packages/kbn-es/') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('packages/kbn-eslint') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('packages/kbn-expect') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('packages/kbn-i18n/') == 0) ctx.team = 'kibana-localization';\n else if (path.indexOf('packages/kbn-interpreter/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('packages/kbn-optimizer/') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('packages/kbn-pm/') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('packages/kbn-test/') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('packages/kbn-test-subj-selector/') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('packages/kbn-ui-framework/') == 0) ctx.team = 'kibana-design';\n else if (path.indexOf('packages/kbn-ui-shared-deps/') == 0) ctx.team = 'kibana-operations';\n else ctx.team = 'unknown';\n\n } else {\n\n if (path.indexOf('config/kibana.yml') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/apm.js') == 0) ctx.team = 'apm-ui';\n else if (path.indexOf('src/core/') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/core/public/i18n/') == 0) ctx.team = 'kibana-localization';\n else if (path.indexOf('src/core/server/csp/') == 0) ctx.team = 'kibana-security';\n else if (path.indexOf('src/dev/') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('src/dev/i18n/') == 0) ctx.team = 'kibana-localization';\n else if (path.indexOf('src/dev/run_check_published_api_changes.ts') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/es_archiver/') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('src/optimize/') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('src/setup_node_env/') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('src/test_utils/') == 0) ctx.team = 'kibana-operations'; \n else ctx.team = 'unknown';\n }"}}]} +{"description":"Kibana code coverage team assignments","processors":[{"script":{"lang":"painless","source":"\n String path = ctx.coveredFilePath; \n if (path.indexOf('src/legacy/core_plugins/kibana/') == 0) {\n\n if (path.indexOf('src/legacy/core_plugins/kibana/common/utils') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/core_plugins/kibana/migrations') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/core_plugins/kibana/public') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/core_plugins/kibana/public/dashboard/') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/core_plugins/kibana/public/dev_tools/') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/core_plugins/kibana/public/discover/') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/core_plugins/kibana/public/home') == 0) ctx.team = 'kibana-core-ui';\n else if (path.indexOf('src/legacy/core_plugins/kibana/public/home/np_ready/') == 0) ctx.team = 'kibana-core-ui';\n else if (path.indexOf('src/legacy/core_plugins/kibana/public/local_application_service/') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/core_plugins/kibana/public/management/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/legacy/core_plugins/kibana/server/lib') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/legacy/core_plugins/kibana/server/lib/management/saved_objects') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/legacy/core_plugins/kibana/server/routes/api/management/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/legacy/core_plugins/kibana/server/routes/api/import/') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/legacy/core_plugins/kibana/server/routes/api/export/') == 0) ctx.team = 'kibana-platform';\n else ctx.team = 'unknown';\n\n } else if (path.indexOf('src/legacy/core_plugins/') == 0) {\n\n if (path.indexOf('src/legacy/core_plugins/apm_oss/') == 0) ctx.team = 'apm-ui';\n else if (path.indexOf('src/legacy/core_plugins/console_legacy') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/core_plugins/elasticsearch') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/legacy/core_plugins/embeddable_api/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/legacy/core_plugins/input_control_vis') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/core_plugins/interpreter/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/legacy/core_plugins/kibana_react/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/legacy/core_plugins/newsfeed') == 0) ctx.team = 'kibana-core-ui';\n else if (path.indexOf('src/legacy/core_plugins/region_map') == 0) ctx.team = 'maps';\n else if (path.indexOf('src/legacy/core_plugins/status_page/public') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/legacy/core_plugins/testbed') == 0) ctx.team = 'kibana-platform';\n // else if (path.indexOf('src/legacy/core_plugins/tests_bundle/') == 0) ctx.team = 'kibana-platform';\n \n else if (path.indexOf('src/legacy/core_plugins/tile_map') == 0) ctx.team = 'maps';\n else if (path.indexOf('src/legacy/core_plugins/timelion') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/core_plugins/ui_metric/') == 0) ctx.team = 'pulse';\n else if (path.indexOf('src/legacy/core_plugins/vis_type_tagcloud') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/core_plugins/vis_type_vega') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/core_plugins/vis_type_vislib/') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/core_plugins/visualizations/') == 0) ctx.team = 'kibana-app-arch';\n else ctx.team = 'unknown';\n\n } else if (path.indexOf('src/legacy/server/') == 0) {\n\n if (path.indexOf('src/legacy/server/config/') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/legacy/server/http/') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/legacy/server/i18n/') == 0) ctx.team = 'kibana-localization';\n else if (path.indexOf('src/legacy/server/index_patterns/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/legacy/server/keystore/') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('src/legacy/server/logging/') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/legacy/server/pid/') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('src/legacy/server/sample_data/') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/server/sass/') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('src/legacy/server/saved_objects/') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/legacy/server/status/') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/legacy/server/url_shortening/') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/server/utils/') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('src/legacy/server/warnings/') == 0) ctx.team = 'kibana-operations';\n else ctx.team = 'unknown';\n\n } else if (path.indexOf('src/legacy/ui') == 0) {\n\n if (path.indexOf('src/legacy/ui/public/field_editor') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/legacy/ui/public/timefilter') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/legacy/ui/public/management') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/legacy/ui/public/state_management') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/legacy/ui/public/new_platform') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/legacy/ui/public/plugin_discovery') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/legacy/ui/public/chrome') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/legacy/ui/public/notify') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/legacy/ui/public/documentation_links') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/legacy/ui/public/autoload') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/legacy/ui/public/capabilities') == 0) ctx.team = 'kibana-security';\n else if (path.indexOf('src/legacy/ui/public/apm') == 0) ctx.team = 'apm-ui';\n\n } else if (path.indexOf('src/plugins/') == 0) {\n\n if (path.indexOf('src/plugins/advanced_settings/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/apm_oss/') == 0) ctx.team = 'apm-ui';\n else if (path.indexOf('src/plugins/bfetch/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/charts/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/charts/public/static/color_maps') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/plugins/console/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('src/plugins/dashboard/') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/plugins/data/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/dev_tools/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('src/plugins/discover/') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/plugins/embeddable/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/es_ui_shared/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('src/plugins/expressions/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/home/public') == 0) ctx.team = 'kibana-core-ui';\n else if (path.indexOf('src/plugins/home/server/tutorials') == 0) ctx.team = 'observability';\n else if (path.indexOf('src/plugins/home/server/services/') == 0) ctx.team = 'kibana-core-ui';\n else if (path.indexOf('src/plugins/home/') == 0) ctx.team = 'kibana-core-ui';\n else if (path.indexOf('src/plugins/index_pattern_management/public/service') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/index_pattern_management/public') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/plugins/input_control_vis/') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/plugins/inspector/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/kibana_legacy/') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/plugins/kibana_react/public/code_editor') == 0) ctx.team = 'kibana-canvas';\n else if (path.indexOf('src/plugins/kibana_react/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/kibana_utils/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/management/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/kibana_usage_collection/') == 0) ctx.team = 'pulse';\n else if (path.indexOf('src/plugins/legacy_export/') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/plugins/maps_legacy/') == 0) ctx.team = 'maps';\n else if (path.indexOf('src/plugins/region_map/') == 0) ctx.team = 'maps';\n else if (path.indexOf('src/plugins/tile_map/') == 0) ctx.team = 'maps';\n else if (path.indexOf('src/plugins/timelion') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/plugins/navigation/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/newsfeed') == 0) ctx.team = 'kibana-core-ui';\n else if (path.indexOf('src/plugins/saved_objects_management/') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/plugins/saved_objects/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/share/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/status_page/') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/plugins/telemetry') == 0) ctx.team = 'pulse';\n else if (path.indexOf('src/plugins/testbed/server/') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/plugins/ui_actions/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/usage_collection/') == 0) ctx.team = 'pulse';\n else if (path.indexOf('src/plugins/vis_default_editor') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/vis_type') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('src/plugins/visualizations/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('src/plugins/visualize/') == 0) ctx.team = 'kibana-app';\n else ctx.team = 'unknown';\n\n } else if (path.indexOf('x-pack/legacy/') == 0) {\n\n if (path.indexOf('x-pack/legacy/plugins/actions/') == 0) ctx.team = 'kibana-alerting-services';\n else if (path.indexOf('x-pack/legacy/plugins/alerting/') == 0) ctx.team = 'kibana-alerting-services';\n else if (path.indexOf('x-pack/legacy/plugins/apm/') == 0) ctx.team = 'apm-ui';\n else if (path.indexOf('x-pack/legacy/plugins/beats_management/') == 0) ctx.team = 'beats';\n else if (path.indexOf('x-pack/legacy/plugins/canvas/') == 0) ctx.team = 'kibana-canvas';\n else if (path.indexOf('x-pack/legacy/plugins/cross_cluster_replication/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/legacy/plugins/dashboard_mode/') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('x-pack/legacy/plugins/encrypted_saved_objects/') == 0) ctx.team = 'kibana-security';\n else if (path.indexOf('x-pack/legacy/plugins/index_management/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/legacy/plugins/infra/') == 0) ctx.team = 'logs-metrics-ui';\n else if (path.indexOf('x-pack/legacy/plugins/ingest_manager/') == 0) ctx.team = 'ingest-management';\n else if (path.indexOf('x-pack/legacy/plugins/license_management/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/legacy/plugins/maps/') == 0) ctx.team = 'kibana-gis';\n else if (path.indexOf('x-pack/legacy/plugins/ml/') == 0) ctx.team = 'ml-ui';\n else if (path.indexOf('x-pack/legacy/plugins/monitoring/') == 0) ctx.team = 'stack-monitoring-ui';\n else if (path.indexOf('x-pack/legacy/plugins/reporting') == 0) ctx.team = 'kibana-reporting';\n else if (path.indexOf('x-pack/legacy/plugins/rollup/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/legacy/plugins/security/') == 0) ctx.team = 'kibana-security';\n else if (path.indexOf('x-pack/legacy/plugins/siem/') == 0) ctx.team = 'siem';\n else if (path.indexOf('x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules') == 0) ctx.team = 'security-intelligence-analytics';\n else if (path.indexOf('x-pack/legacy/plugins/snapshot_restore/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/legacy/plugins/spaces/') == 0) ctx.team = 'kibana-security';\n else if (path.indexOf('x-pack/legacy/plugins/task_manager') == 0) ctx.team = 'kibana-alerting-services';\n else if (path.indexOf('x-pack/legacy/plugins/triggers_actions_ui/') == 0) ctx.team = 'kibana-alerting-services';\n else if (path.indexOf('x-pack/legacy/plugins/upgrade_assistant/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/legacy/plugins/uptime') == 0) ctx.team = 'uptime';\n else if (path.indexOf('x-pack/legacy/plugins/xpack_main/server/') == 0) ctx.team = 'kibana-platform';\n\n else if (path.indexOf('x-pack/legacy/server/lib/create_router/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/legacy/server/lib/check_license/') == 0) ctx.team = 'es-ui'; \n else if (path.indexOf('x-pack/legacy/server/lib/') == 0) ctx.team = 'kibana-platform'; \n else ctx.team = 'unknown';\n\n } else if (path.indexOf('x-pack/plugins/') == 0) {\n\n if (path.indexOf('x-pack/plugins/actions/') == 0) ctx.team = 'kibana-alerting-services';\n else if (path.indexOf('x-pack/plugins/advanced_ui_actions/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('x-pack/plugins/alerts') == 0) ctx.team = 'kibana-alerting-services';\n else if (path.indexOf('x-pack/plugins/alerting_builtins') == 0) ctx.team = 'kibana-alerting-services';\n else if (path.indexOf('x-pack/plugins/apm/') == 0) ctx.team = 'apm-ui';\n else if (path.indexOf('x-pack/plugins/beats_management/') == 0) ctx.team = 'beats';\n else if (path.indexOf('x-pack/plugins/canvas/') == 0) ctx.team = 'kibana-canvas';\n else if (path.indexOf('x-pack/plugins/case') == 0) ctx.team = 'siem';\n else if (path.indexOf('x-pack/plugins/cloud/') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('x-pack/plugins/code/') == 0) ctx.team = 'code';\n else if (path.indexOf('x-pack/plugins/console_extensions/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/plugins/cross_cluster_replication/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/plugins/dashboard_enhanced') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('x-pack/plugins/dashboard_mode') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('x-pack/plugins/discover_enhanced') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('x-pack/plugins/embeddable_enhanced') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('x-pack/plugins/data_enhanced/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('x-pack/plugins/drilldowns/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('x-pack/plugins/encrypted_saved_objects/') == 0) ctx.team = 'kibana-security';\n else if (path.indexOf('x-pack/plugins/endpoint/') == 0) ctx.team = 'endpoint-app-team';\n else if (path.indexOf('x-pack/plugins/es_ui_shared/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/plugins/event_log/') == 0) ctx.team = 'kibana-alerting-services';\n else if (path.indexOf('x-pack/plugins/features/') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('x-pack/plugins/file_upload') == 0) ctx.team = 'kibana-gis';\n else if (path.indexOf('x-pack/plugins/global_search') == 0) ctx.team = 'kibana-platform';\n \n else if (path.indexOf('x-pack/plugins/graph/') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('x-pack/plugins/grokdebugger/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/plugins/index_lifecycle_management/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/plugins/index_management/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/plugins/infra/') == 0) ctx.team = 'logs-metrics-ui';\n else if (path.indexOf('x-pack/plugins/ingest_manager/') == 0) ctx.team = 'ingest-management';\n else if (path.indexOf('x-pack/plugins/ingest_pipelines/') == 0) ctx.team = 'es-ui';\n \n else if (path.indexOf('x-pack/plugins/lens/') == 0) ctx.team = 'kibana-app';\n else if (path.indexOf('x-pack/plugins/license_management/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/plugins/licensing/') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('x-pack/plugins/lists/') == 0) ctx.team = 'siem';\n else if (path.indexOf('x-pack/plugins/logstash') == 0) ctx.team = 'logstash';\n else if (path.indexOf('x-pack/plugins/maps/') == 0) ctx.team = 'kibana-gis';\n else if (path.indexOf('x-pack/plugins/maps_legacy_licensing') == 0) ctx.team = 'maps';\n else if (path.indexOf('x-pack/plugins/ml/') == 0) ctx.team = 'ml-ui';\n else if (path.indexOf('x-pack/plugins/monitoring') == 0) ctx.team = 'stack-monitoring-ui';\n else if (path.indexOf('x-pack/plugins/observability/') == 0) ctx.team = 'apm-ui';\n else if (path.indexOf('x-pack/plugins/oss_telemetry/') == 0) ctx.team = 'pulse';\n else if (path.indexOf('x-pack/plugins/painless_lab/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/plugins/remote_clusters/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/plugins/reporting') == 0) ctx.team = 'kibana-reporting';\n else if (path.indexOf('x-pack/plugins/rollup/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/plugins/searchprofiler/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/plugins/security/') == 0) ctx.team = 'kibana-security';\n else if (path.indexOf('x-pack/plugins/security_solution/') == 0) ctx.team = 'siem';\n \n else if (path.indexOf('x-pack/plugins/siem/server/lib/detection_engine/rules/prepackaged_rules') == 0) ctx.team = 'security-intelligence-analytics';\n else if (path.indexOf('x-pack/plugins/siem/') == 0) ctx.team = 'siem';\n else if (path.indexOf('x-pack/plugins/snapshot_restore/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/plugins/spaces/') == 0) ctx.team = 'kibana-security';\n else if (path.indexOf('x-pack/plugins/task_manager/') == 0) ctx.team = 'kibana-alerting-services';\n else if (path.indexOf('x-pack/plugins/telemetry_collection_xpack/') == 0) ctx.team = 'pulse';\n else if (path.indexOf('x-pack/plugins/transform/') == 0) ctx.team = 'ml-ui';\n else if (path.indexOf('x-pack/plugins/translations/') == 0) ctx.team = 'kibana-localization';\n else if (path.indexOf('x-pack/plugins/triggers_actions_ui/') == 0) ctx.team = 'kibana-alerting-services';\n else if (path.indexOf('x-pack/plugins/upgrade_assistant/') == 0) ctx.team = 'es-ui';\n else if (path.indexOf('x-pack/plugins/ui_actions_enhanced') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('x-pack/plugins/uptime') == 0) ctx.team = 'uptime';\n \n else if (path.indexOf('x-pack/plugins/watcher/') == 0) ctx.team = 'es-ui';\n else ctx.team = 'unknown';\n\n } else if (path.indexOf('packages') == 0) {\n\n if (path.indexOf('packages/kbn-analytics/') == 0) ctx.team = 'pulse';\n else if (path.indexOf('packages/kbn-babel') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('packages/kbn-config-schema/') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('packages/elastic-datemath') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('packages/kbn-dev-utils') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('packages/kbn-es/') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('packages/kbn-eslint') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('packages/kbn-expect') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('packages/kbn-i18n/') == 0) ctx.team = 'kibana-localization';\n else if (path.indexOf('packages/kbn-interpreter/') == 0) ctx.team = 'kibana-app-arch';\n else if (path.indexOf('packages/kbn-optimizer/') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('packages/kbn-pm/') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('packages/kbn-test/') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('packages/kbn-test-subj-selector/') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('packages/kbn-ui-framework/') == 0) ctx.team = 'kibana-design';\n else if (path.indexOf('packages/kbn-ui-shared-deps/') == 0) ctx.team = 'kibana-operations';\n else ctx.team = 'unknown';\n\n } else {\n\n if (path.indexOf('config/kibana.yml') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/apm.js') == 0) ctx.team = 'apm-ui';\n else if (path.indexOf('src/core/') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('src/core/public/i18n/') == 0) ctx.team = 'kibana-localization';\n else if (path.indexOf('src/core/server/csp/') == 0) ctx.team = 'kibana-security';\n else if (path.indexOf('src/dev/') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('src/dev/i18n/') == 0) ctx.team = 'kibana-localization';\n else if (path.indexOf('src/dev/run_check_published_api_changes.ts') == 0) ctx.team = 'kibana-platform';\n else if (path.indexOf('packages/kbn-es-archiver/') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('src/optimize/') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('src/setup_node_env/') == 0) ctx.team = 'kibana-operations';\n else if (path.indexOf('src/test_utils/') == 0) ctx.team = 'kibana-operations'; \n else ctx.team = 'unknown';\n }"}}]} diff --git a/src/es_archiver/cli.ts b/src/es_archiver/cli.ts deleted file mode 100644 index 85e10b31a87e..000000000000 --- a/src/es_archiver/cli.ts +++ /dev/null @@ -1,183 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -/** *********************************************************** - * - * Run `node scripts/es_archiver --help` for usage information - * - *************************************************************/ - -import { resolve } from 'path'; -import { readFileSync } from 'fs'; -import { format as formatUrl } from 'url'; -import readline from 'readline'; -import { Command } from 'commander'; -import * as legacyElasticsearch from 'elasticsearch'; - -import { ToolingLog } from '@kbn/dev-utils'; -import { readConfigFile } from '@kbn/test'; - -import { EsArchiver } from './es_archiver'; - -const cmd = new Command('node scripts/es_archiver'); - -const resolveConfigPath = (v: string) => resolve(process.cwd(), v); -const defaultConfigPath = resolveConfigPath('test/functional/config.js'); - -cmd - .description(`CLI to manage archiving/restoring data in elasticsearch`) - .option('--es-url [url]', 'url for elasticsearch') - .option( - '--kibana-url [url]', - 'url for kibana (only necessary if using "load" or "unload" methods)' - ) - .option(`--dir [path]`, 'where archives are stored') - .option('--verbose', 'turn on verbose logging') - .option( - '--config [path]', - 'path to a functional test config file to use for default values', - resolveConfigPath, - defaultConfigPath - ) - .on('--help', () => { - // eslint-disable-next-line no-console - console.log(readFileSync(resolve(__dirname, './cli_help.txt'), 'utf8')); - }); - -cmd - .option('--raw', `don't gzip the archive`) - .command('save ') - .description('archive the into the --dir with ') - .action((name, indices) => execute((archiver, { raw }) => archiver.save(name, indices, { raw }))); - -cmd - .option('--use-create', 'use create instead of index for loading documents') - .command('load ') - .description('load the archive in --dir with ') - .action((name) => execute((archiver, { useCreate }) => archiver.load(name, { useCreate }))); - -cmd - .command('unload ') - .description('remove indices created by the archive in --dir with ') - .action((name) => execute((archiver) => archiver.unload(name))); - -cmd - .command('empty-kibana-index') - .description( - '[internal] Delete any Kibana indices, and initialize the Kibana index as Kibana would do on startup.' - ) - .action(() => execute((archiver) => archiver.emptyKibanaIndex())); - -cmd - .command('edit [prefix]') - .description( - 'extract the archives under the prefix, wait for edits to be completed, and then recompress the archives' - ) - .action((prefix) => - execute((archiver) => - archiver.edit(prefix, async () => { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - - await new Promise((resolveInput) => { - rl.question(`Press enter when you're done`, () => { - rl.close(); - resolveInput(); - }); - }); - }) - ) - ); - -cmd - .command('rebuild-all') - .description('[internal] read and write all archives in --dir to remove any inconsistencies') - .action(() => execute((archiver) => archiver.rebuildAll())); - -cmd.parse(process.argv); - -const missingCommand = cmd.args.every((a) => !((a as any) instanceof Command)); -if (missingCommand) { - execute(); -} - -async function execute(fn?: (esArchiver: EsArchiver, command: Command) => void): Promise { - try { - const log = new ToolingLog({ - level: cmd.verbose ? 'debug' : 'info', - writeTo: process.stdout, - }); - - if (cmd.config) { - // load default values from the specified config file - const config = await readConfigFile(log, resolve(cmd.config)); - if (!cmd.esUrl) cmd.esUrl = formatUrl(config.get('servers.elasticsearch')); - if (!cmd.kibanaUrl) cmd.kibanaUrl = formatUrl(config.get('servers.kibana')); - if (!cmd.dir) cmd.dir = config.get('esArchiver.directory'); - } - - // log and count all validation errors - let errorCount = 0; - const error = (msg: string) => { - errorCount++; - log.error(msg); - }; - - if (!fn) { - error(`Unknown command "${cmd.args[0]}"`); - } - - if (!cmd.esUrl) { - error('You must specify either --es-url or --config flags'); - } - - if (!cmd.dir) { - error('You must specify either --dir or --config flags'); - } - - // if there was a validation error display the help - if (errorCount) { - cmd.help(); - return; - } - - // run! - const client = new legacyElasticsearch.Client({ - host: cmd.esUrl, - log: cmd.verbose ? 'trace' : [], - }); - - try { - const esArchiver = new EsArchiver({ - log, - client, - dataDir: resolve(cmd.dir), - kibanaUrl: cmd.kibanaUrl, - }); - await fn!(esArchiver, cmd); - } finally { - await client.close(); - } - } catch (err) { - // eslint-disable-next-line no-console - console.log('FATAL ERROR', err.stack); - } -} diff --git a/src/es_archiver/cli_help.txt b/src/es_archiver/cli_help.txt deleted file mode 100644 index 1e2f8e40824b..000000000000 --- a/src/es_archiver/cli_help.txt +++ /dev/null @@ -1,15 +0,0 @@ - Examples: - Dump an index to disk: - Save all `logstash-*` indices from http://localhost:9200 to `snapshots/my_test_data` directory - - WARNING: If the `my_test_data` snapshot exists it will be deleted! - - $ node scripts/es_archiver save my_test_data logstash-* --dir snapshots - - Load an index from disk - Load the `my_test_data` snapshot from the archive directory and elasticsearch instance defined - in the `test/functional/config.js` config file - - WARNING: If the indices exist already they will be deleted! - - $ node scripts/es_archiver load my_test_data --config test/functional/config.js diff --git a/test/common/services/es_archiver.ts b/test/common/services/es_archiver.ts index cfe0610414b4..9c99445fa482 100644 --- a/test/common/services/es_archiver.ts +++ b/test/common/services/es_archiver.ts @@ -18,9 +18,9 @@ */ import { format as formatUrl } from 'url'; +import { EsArchiver } from '@kbn/es-archiver'; import { FtrProviderContext } from '../ftr_provider_context'; -import { EsArchiver } from '../../../src/es_archiver'; // @ts-ignore not TS yet import * as KibanaServer from './kibana_server'; diff --git a/x-pack/test/functional_enterprise_search/apps/enterprise_search/with_host_configured/app_search/engines.ts b/x-pack/test/functional_enterprise_search/apps/enterprise_search/with_host_configured/app_search/engines.ts index e4ebd61c0692..1742ed443984 100644 --- a/x-pack/test/functional_enterprise_search/apps/enterprise_search/with_host_configured/app_search/engines.ts +++ b/x-pack/test/functional_enterprise_search/apps/enterprise_search/with_host_configured/app_search/engines.ts @@ -5,7 +5,7 @@ */ import expect from '@kbn/expect'; -import { EsArchiver } from 'src/es_archiver'; +import { EsArchiver } from '@kbn/es-archiver'; import { AppSearchService, IEngine } from '../../../../services/app_search_service'; import { Browser } from '../../../../../../../test/functional/services/common'; import { FtrProviderContext } from '../../../../ftr_provider_context'; diff --git a/x-pack/test/spaces_api_integration/common/suites/copy_to_space.ts b/x-pack/test/spaces_api_integration/common/suites/copy_to_space.ts index ebec70793e8f..2dd4484ffcde 100644 --- a/x-pack/test/spaces_api_integration/common/suites/copy_to_space.ts +++ b/x-pack/test/spaces_api_integration/common/suites/copy_to_space.ts @@ -6,7 +6,7 @@ import expect from '@kbn/expect'; import { SuperTest } from 'supertest'; -import { EsArchiver } from 'src/es_archiver'; +import { EsArchiver } from '@kbn/es-archiver'; import { DEFAULT_SPACE_ID } from '../../../../plugins/spaces/common/constants'; import { CopyResponse } from '../../../../plugins/spaces/server/lib/copy_to_spaces'; import { getUrlPrefix } from '../lib/space_test_utils'; diff --git a/x-pack/test/spaces_api_integration/common/suites/resolve_copy_to_space_conflicts.ts b/x-pack/test/spaces_api_integration/common/suites/resolve_copy_to_space_conflicts.ts index 3529d8f3ae9c..6d80688b7a70 100644 --- a/x-pack/test/spaces_api_integration/common/suites/resolve_copy_to_space_conflicts.ts +++ b/x-pack/test/spaces_api_integration/common/suites/resolve_copy_to_space_conflicts.ts @@ -6,7 +6,7 @@ import expect from '@kbn/expect'; import { SuperTest } from 'supertest'; -import { EsArchiver } from 'src/es_archiver'; +import { EsArchiver } from '@kbn/es-archiver'; import { SavedObject } from 'src/core/server'; import { DEFAULT_SPACE_ID } from '../../../../plugins/spaces/common/constants'; import { CopyResponse } from '../../../../plugins/spaces/server/lib/copy_to_spaces'; From fa11161fd03ba401d020c540d54e8d2c528c92a0 Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Wed, 22 Jul 2020 09:04:18 -0600 Subject: [PATCH 040/142] [Maps] fix zoom in/zoom out buttons are not visible in dark mode (#72699) Co-authored-by: Elastic Machine --- x-pack/plugins/maps/public/_mapbox_hacks.scss | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/x-pack/plugins/maps/public/_mapbox_hacks.scss b/x-pack/plugins/maps/public/_mapbox_hacks.scss index a6436585847c..9b2d93986e42 100644 --- a/x-pack/plugins/maps/public/_mapbox_hacks.scss +++ b/x-pack/plugins/maps/public/_mapbox_hacks.scss @@ -25,16 +25,18 @@ // Custom SVG as background for zoom controls based off of EUI glyphs plusInCircleFilled and minusInCircleFilled // Also fixes dark mode -.mapboxgl-ctrl-icon.mapboxgl-ctrl-zoom-in { - background-repeat: no-repeat; +.mapboxgl-ctrl-zoom-in .mapboxgl-ctrl-icon { + // sass-lint:disable-block no-important + background-repeat: no-repeat !important; // sass-lint:disable-block quotes - background-image: url("data:image/svg+xml,%0A%3Csvg width='15px' height='15px' viewBox='0 0 15 15' version='1.1' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill='#{hexToRGB($euiTextColor)}' d='M8,7 L8,3.5 C8,3.22385763 7.77614237,3 7.5,3 C7.22385763,3 7,3.22385763 7,3.5 L7,7 L3.5,7 C3.22385763,7 3,7.22385763 3,7.5 C3,7.77614237 3.22385763,8 3.5,8 L7,8 L7,11.5 C7,11.7761424 7.22385763,12 7.5,12 C7.77614237,12 8,11.7761424 8,11.5 L8,8 L11.5,8 C11.7761424,8 12,7.77614237 12,7.5 C12,7.22385763 11.7761424,7 11.5,7 L8,7 Z M7.5,15 C3.35786438,15 0,11.6421356 0,7.5 C0,3.35786438 3.35786438,0 7.5,0 C11.6421356,0 15,3.35786438 15,7.5 C15,11.6421356 11.6421356,15 7.5,15 Z' /%3E%3C/svg%3E"); - background-position: center; + background-image: url("data:image/svg+xml,%0A%3Csvg width='15px' height='15px' viewBox='0 0 15 15' version='1.1' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill='#{hexToRGB($euiTextColor)}' d='M8,7 L8,3.5 C8,3.22385763 7.77614237,3 7.5,3 C7.22385763,3 7,3.22385763 7,3.5 L7,7 L3.5,7 C3.22385763,7 3,7.22385763 3,7.5 C3,7.77614237 3.22385763,8 3.5,8 L7,8 L7,11.5 C7,11.7761424 7.22385763,12 7.5,12 C7.77614237,12 8,11.7761424 8,11.5 L8,8 L11.5,8 C11.7761424,8 12,7.77614237 12,7.5 C12,7.22385763 11.7761424,7 11.5,7 L8,7 Z M7.5,15 C3.35786438,15 0,11.6421356 0,7.5 C0,3.35786438 3.35786438,0 7.5,0 C11.6421356,0 15,3.35786438 15,7.5 C15,11.6421356 11.6421356,15 7.5,15 Z' /%3E%3C/svg%3E") !important; + background-position: center !important; } -.mapboxgl-ctrl-icon.mapboxgl-ctrl-zoom-out { - background-repeat: no-repeat; +.mapboxgl-ctrl-zoom-out .mapboxgl-ctrl-icon { + // sass-lint:disable-block no-important + background-repeat: no-repeat !important; // sass-lint:disable-block quotes - background-image: url("data:image/svg+xml,%0A%3Csvg width='15px' height='15px' viewBox='0 0 15 15' version='1.1' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill='#{hexToRGB($euiTextColor)}' d='M7.5,0 C11.6355882,0 15,3.36441176 15,7.5 C15,11.6355882 11.6355882,15 7.5,15 C3.36441176,15 0,11.6355882 0,7.5 C0,3.36441176 3.36441176,0 7.5,0 Z M3.5,7 C3.22385763,7 3,7.22385763 3,7.5 C3,7.77614237 3.22385763,8 3.5,8 L11.5,8 C11.7761424,8 12,7.77614237 12,7.5 C12,7.22385763 11.7761424,7 11.5,7 L3.5,7 Z' /%3E%3C/svg%3E"); - background-position: center; + background-image: url("data:image/svg+xml,%0A%3Csvg width='15px' height='15px' viewBox='0 0 15 15' version='1.1' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill='#{hexToRGB($euiTextColor)}' d='M7.5,0 C11.6355882,0 15,3.36441176 15,7.5 C15,11.6355882 11.6355882,15 7.5,15 C3.36441176,15 0,11.6355882 0,7.5 C0,3.36441176 3.36441176,0 7.5,0 Z M3.5,7 C3.22385763,7 3,7.22385763 3,7.5 C3,7.77614237 3.22385763,8 3.5,8 L11.5,8 C11.7761424,8 12,7.77614237 12,7.5 C12,7.22385763 11.7761424,7 11.5,7 L3.5,7 Z' /%3E%3C/svg%3E") !important; + background-position: center !important; } From 0bab77147aa867c736c5ab553f2af71e7d861cb6 Mon Sep 17 00:00:00 2001 From: Marshall Main <55718608+marshallmain@users.noreply.github.com> Date: Wed, 22 Jul 2020 11:04:44 -0400 Subject: [PATCH 041/142] [Security Solution] Change query builder so N exception items don't nest N levels deep (#72224) * Change query builder so N exceptions don't nest N levels deep * Fix tests and clarify function naming * Rename evaluateEntry to buildEntry for consistency * Remove duplicate tests * more test fixes * Add tests with multiple exception list items * Chunk exception list items in query to support up to 1000000 Co-authored-by: Elastic Machine --- .../build_exceptions_query.test.ts | 334 +++++------- .../build_exceptions_query.ts | 101 ++-- .../detection_engine/get_query_filter.test.ts | 478 ++++++++++++++++-- .../detection_engine/get_query_filter.ts | 100 +++- .../signals/get_filter.test.ts | 63 +-- 5 files changed, 712 insertions(+), 364 deletions(-) diff --git a/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.test.ts b/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.test.ts index 1c7a2a5de659..2cebaacc6768 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.test.ts @@ -5,14 +5,13 @@ */ import { - buildQueryExceptions, - buildExceptionItemEntries, + buildExceptionListQueries, + buildExceptionItem, operatorBuilder, buildExists, buildMatch, buildMatchAny, - evaluateValues, - formatQuery, + buildEntry, getLanguageBooleanOperator, buildNested, } from './build_exceptions_query'; @@ -30,7 +29,6 @@ import { getEntryMatchAnyMock } from '../../../lists/common/schemas/types/entry_ import { getEntryExistsMock } from '../../../lists/common/schemas/types/entry_exists.mock'; describe('build_exceptions_query', () => { - let exclude: boolean; const makeMatchEntry = ({ field, value = 'value-1', @@ -97,10 +95,6 @@ describe('build_exceptions_query', () => { operator: 'excluded', }); - beforeEach(() => { - exclude = true; - }); - describe('getLanguageBooleanOperator', () => { test('it returns value as uppercase if language is "lucene"', () => { const result = getLanguageBooleanOperator({ language: 'lucene', value: 'not' }); @@ -143,14 +137,14 @@ describe('build_exceptions_query', () => { describe('kuery', () => { test('it returns formatted wildcard string when operator is "excluded"', () => { const query = buildExists({ - item: existsEntryWithExcluded, + entry: existsEntryWithExcluded, language: 'kuery', }); expect(query).toEqual('not host.name:*'); }); test('it returns formatted wildcard string when operator is "included"', () => { const query = buildExists({ - item: existsEntryWithIncluded, + entry: existsEntryWithIncluded, language: 'kuery', }); expect(query).toEqual('host.name:*'); @@ -160,14 +154,14 @@ describe('build_exceptions_query', () => { describe('lucene', () => { test('it returns formatted wildcard string when operator is "excluded"', () => { const query = buildExists({ - item: existsEntryWithExcluded, + entry: existsEntryWithExcluded, language: 'lucene', }); expect(query).toEqual('NOT _exists_host.name'); }); test('it returns formatted wildcard string when operator is "included"', () => { const query = buildExists({ - item: existsEntryWithIncluded, + entry: existsEntryWithIncluded, language: 'lucene', }); expect(query).toEqual('_exists_host.name'); @@ -179,14 +173,14 @@ describe('build_exceptions_query', () => { describe('kuery', () => { test('it returns formatted string when operator is "included"', () => { const query = buildMatch({ - item: matchEntryWithIncluded, + entry: matchEntryWithIncluded, language: 'kuery', }); expect(query).toEqual('host.name:"suricata"'); }); test('it returns formatted string when operator is "excluded"', () => { const query = buildMatch({ - item: matchEntryWithExcluded, + entry: matchEntryWithExcluded, language: 'kuery', }); expect(query).toEqual('not host.name:"suricata"'); @@ -196,14 +190,14 @@ describe('build_exceptions_query', () => { describe('lucene', () => { test('it returns formatted string when operator is "included"', () => { const query = buildMatch({ - item: matchEntryWithIncluded, + entry: matchEntryWithIncluded, language: 'lucene', }); expect(query).toEqual('host.name:"suricata"'); }); test('it returns formatted string when operator is "excluded"', () => { const query = buildMatch({ - item: matchEntryWithExcluded, + entry: matchEntryWithExcluded, language: 'lucene', }); expect(query).toEqual('NOT host.name:"suricata"'); @@ -229,7 +223,7 @@ describe('build_exceptions_query', () => { describe('kuery', () => { test('it returns empty string if given an empty array for "values"', () => { const exceptionSegment = buildMatchAny({ - item: entryWithIncludedAndNoValues, + entry: entryWithIncludedAndNoValues, language: 'kuery', }); expect(exceptionSegment).toEqual(''); @@ -237,7 +231,7 @@ describe('build_exceptions_query', () => { test('it returns formatted string when "values" includes only one item', () => { const exceptionSegment = buildMatchAny({ - item: entryWithIncludedAndOneValue, + entry: entryWithIncludedAndOneValue, language: 'kuery', }); @@ -246,7 +240,7 @@ describe('build_exceptions_query', () => { test('it returns formatted string when operator is "included"', () => { const exceptionSegment = buildMatchAny({ - item: matchAnyEntryWithIncludedAndTwoValues, + entry: matchAnyEntryWithIncludedAndTwoValues, language: 'kuery', }); @@ -255,7 +249,7 @@ describe('build_exceptions_query', () => { test('it returns formatted string when operator is "excluded"', () => { const exceptionSegment = buildMatchAny({ - item: entryWithExcludedAndTwoValues, + entry: entryWithExcludedAndTwoValues, language: 'kuery', }); @@ -266,7 +260,7 @@ describe('build_exceptions_query', () => { describe('lucene', () => { test('it returns formatted string when operator is "included"', () => { const exceptionSegment = buildMatchAny({ - item: matchAnyEntryWithIncludedAndTwoValues, + entry: matchAnyEntryWithIncludedAndTwoValues, language: 'lucene', }); @@ -274,7 +268,7 @@ describe('build_exceptions_query', () => { }); test('it returns formatted string when operator is "excluded"', () => { const exceptionSegment = buildMatchAny({ - item: entryWithExcludedAndTwoValues, + entry: entryWithExcludedAndTwoValues, language: 'lucene', }); @@ -282,7 +276,7 @@ describe('build_exceptions_query', () => { }); test('it returns formatted string when "values" includes only one item', () => { const exceptionSegment = buildMatchAny({ - item: entryWithIncludedAndOneValue, + entry: entryWithIncludedAndOneValue, language: 'lucene', }); @@ -295,7 +289,7 @@ describe('build_exceptions_query', () => { // NOTE: Only KQL supports nested describe('kuery', () => { test('it returns formatted query when one item in nested entry', () => { - const item: EntryNested = { + const entry: EntryNested = { field: 'parent', type: 'nested', entries: [ @@ -307,35 +301,35 @@ describe('build_exceptions_query', () => { }, ], }; - const result = buildNested({ item, language: 'kuery' }); + const result = buildNested({ entry, language: 'kuery' }); expect(result).toEqual('parent:{ nestedField:"value-1" }'); }); test('it returns formatted query when entry item is "exists"', () => { - const item: EntryNested = { + const entry: EntryNested = { field: 'parent', type: 'nested', entries: [{ ...getEntryExistsMock(), field: 'nestedField', operator: 'included' }], }; - const result = buildNested({ item, language: 'kuery' }); + const result = buildNested({ entry, language: 'kuery' }); expect(result).toEqual('parent:{ nestedField:* }'); }); test('it returns formatted query when entry item is "exists" and operator is "excluded"', () => { - const item: EntryNested = { + const entry: EntryNested = { field: 'parent', type: 'nested', entries: [{ ...getEntryExistsMock(), field: 'nestedField', operator: 'excluded' }], }; - const result = buildNested({ item, language: 'kuery' }); + const result = buildNested({ entry, language: 'kuery' }); expect(result).toEqual('parent:{ not nestedField:* }'); }); test('it returns formatted query when entry item is "match_any"', () => { - const item: EntryNested = { + const entry: EntryNested = { field: 'parent', type: 'nested', entries: [ @@ -347,13 +341,13 @@ describe('build_exceptions_query', () => { }, ], }; - const result = buildNested({ item, language: 'kuery' }); + const result = buildNested({ entry, language: 'kuery' }); expect(result).toEqual('parent:{ nestedField:("value1" or "value2") }'); }); test('it returns formatted query when entry item is "match_any" and operator is "excluded"', () => { - const item: EntryNested = { + const entry: EntryNested = { field: 'parent', type: 'nested', entries: [ @@ -365,13 +359,13 @@ describe('build_exceptions_query', () => { }, ], }; - const result = buildNested({ item, language: 'kuery' }); + const result = buildNested({ entry, language: 'kuery' }); expect(result).toEqual('parent:{ not nestedField:("value1" or "value2") }'); }); test('it returns formatted query when multiple items in nested entry', () => { - const item: EntryNested = { + const entry: EntryNested = { field: 'parent', type: 'nested', entries: [ @@ -389,34 +383,34 @@ describe('build_exceptions_query', () => { }, ], }; - const result = buildNested({ item, language: 'kuery' }); + const result = buildNested({ entry, language: 'kuery' }); expect(result).toEqual('parent:{ nestedField:"value-1" and nestedFieldB:"value-2" }'); }); }); }); - describe('evaluateValues', () => { + describe('buildEntry', () => { describe('kuery', () => { test('it returns formatted wildcard string when "type" is "exists"', () => { - const result = evaluateValues({ - item: existsEntryWithIncluded, + const result = buildEntry({ + entry: existsEntryWithIncluded, language: 'kuery', }); expect(result).toEqual('host.name:*'); }); test('it returns formatted string when "type" is "match"', () => { - const result = evaluateValues({ - item: matchEntryWithIncluded, + const result = buildEntry({ + entry: matchEntryWithIncluded, language: 'kuery', }); expect(result).toEqual('host.name:"suricata"'); }); test('it returns formatted string when "type" is "match_any"', () => { - const result = evaluateValues({ - item: matchAnyEntryWithIncludedAndTwoValues, + const result = buildEntry({ + entry: matchAnyEntryWithIncludedAndTwoValues, language: 'kuery', }); expect(result).toEqual('host.name:("suricata" or "auditd")'); @@ -424,95 +418,35 @@ describe('build_exceptions_query', () => { }); describe('lucene', () => { - describe('kuery', () => { - test('it returns formatted wildcard string when "type" is "exists"', () => { - const result = evaluateValues({ - item: existsEntryWithIncluded, - language: 'lucene', - }); - expect(result).toEqual('_exists_host.name'); - }); - - test('it returns formatted string when "type" is "match"', () => { - const result = evaluateValues({ - item: matchEntryWithIncluded, - language: 'lucene', - }); - expect(result).toEqual('host.name:"suricata"'); - }); - - test('it returns formatted string when "type" is "match_any"', () => { - const result = evaluateValues({ - item: matchAnyEntryWithIncludedAndTwoValues, - language: 'lucene', - }); - expect(result).toEqual('host.name:("suricata" OR "auditd")'); - }); - }); - }); - }); - - describe('formatQuery', () => { - describe('exclude is true', () => { - describe('when query is empty string', () => { - test('it returns empty string if "exceptions" is empty array', () => { - const formattedQuery = formatQuery({ exceptions: [], language: 'kuery', exclude: true }); - expect(formattedQuery).toEqual(''); - }); - - test('it returns expected query string when single exception in array', () => { - const formattedQuery = formatQuery({ - exceptions: ['b:("value-1" or "value-2") and not c:*'], - language: 'kuery', - exclude: true, - }); - expect(formattedQuery).toEqual('not ((b:("value-1" or "value-2") and not c:*))'); - }); - }); - - test('it returns expected query string when multiple exceptions in array', () => { - const formattedQuery = formatQuery({ - exceptions: ['b:("value-1" or "value-2") and not c:*', 'not d:*'], - language: 'kuery', - exclude: true, + test('it returns formatted wildcard string when "type" is "exists"', () => { + const result = buildEntry({ + entry: existsEntryWithIncluded, + language: 'lucene', }); - expect(formattedQuery).toEqual( - 'not ((b:("value-1" or "value-2") and not c:*) or (not d:*))' - ); + expect(result).toEqual('_exists_host.name'); }); - }); - describe('exclude is false', () => { - describe('when query is empty string', () => { - test('it returns empty string if "exceptions" is empty array', () => { - const formattedQuery = formatQuery({ exceptions: [], language: 'kuery', exclude: false }); - expect(formattedQuery).toEqual(''); - }); - - test('it returns expected query string when single exception in array', () => { - const formattedQuery = formatQuery({ - exceptions: ['b:("value-1" or "value-2") and not c:*'], - language: 'kuery', - exclude: false, - }); - expect(formattedQuery).toEqual('(b:("value-1" or "value-2") and not c:*)'); + test('it returns formatted string when "type" is "match"', () => { + const result = buildEntry({ + entry: matchEntryWithIncluded, + language: 'lucene', }); + expect(result).toEqual('host.name:"suricata"'); }); - test('it returns expected query string when multiple exceptions in array', () => { - const formattedQuery = formatQuery({ - exceptions: ['b:("value-1" or "value-2") and not c:*', 'not d:*'], - language: 'kuery', - exclude: false, + test('it returns formatted string when "type" is "match_any"', () => { + const result = buildEntry({ + entry: matchAnyEntryWithIncludedAndTwoValues, + language: 'lucene', }); - expect(formattedQuery).toEqual('(b:("value-1" or "value-2") and not c:*) or (not d:*)'); + expect(result).toEqual('host.name:("suricata" OR "auditd")'); }); }); }); - describe('buildExceptionItemEntries', () => { + describe('buildExceptionItem', () => { test('it returns empty string if empty lists array passed in', () => { - const query = buildExceptionItemEntries({ + const query = buildExceptionItem({ language: 'kuery', entries: [], }); @@ -525,7 +459,7 @@ describe('build_exceptions_query', () => { makeMatchAnyEntry({ field: 'b' }), makeMatchEntry({ field: 'c', operator: 'excluded', value: 'value-3' }), ]; - const query = buildExceptionItemEntries({ + const query = buildExceptionItem({ language: 'kuery', entries: payload, }); @@ -545,7 +479,7 @@ describe('build_exceptions_query', () => { ], }, ]; - const query = buildExceptionItemEntries({ + const query = buildExceptionItem({ language: 'kuery', entries, }); @@ -566,7 +500,7 @@ describe('build_exceptions_query', () => { }, makeExistsEntry({ field: 'd' }), ]; - const query = buildExceptionItemEntries({ + const query = buildExceptionItem({ language: 'kuery', entries, }); @@ -587,7 +521,7 @@ describe('build_exceptions_query', () => { }, makeExistsEntry({ field: 'e', operator: 'excluded' }), ]; - const query = buildExceptionItemEntries({ + const query = buildExceptionItem({ language: 'lucene', entries, }); @@ -599,7 +533,7 @@ describe('build_exceptions_query', () => { describe('exists', () => { test('it returns expected query when list includes single list item with operator of "included"', () => { const entries: EntriesArray = [makeExistsEntry({ field: 'b' })]; - const query = buildExceptionItemEntries({ + const query = buildExceptionItem({ language: 'kuery', entries, }); @@ -610,7 +544,7 @@ describe('build_exceptions_query', () => { test('it returns expected query when list includes single list item with operator of "excluded"', () => { const entries: EntriesArray = [makeExistsEntry({ field: 'b', operator: 'excluded' })]; - const query = buildExceptionItemEntries({ + const query = buildExceptionItem({ language: 'kuery', entries, }); @@ -628,7 +562,7 @@ describe('build_exceptions_query', () => { entries: [makeMatchEntry({ field: 'c', operator: 'included', value: 'value-1' })], }, ]; - const query = buildExceptionItemEntries({ + const query = buildExceptionItem({ language: 'kuery', entries, }); @@ -650,7 +584,7 @@ describe('build_exceptions_query', () => { }, makeExistsEntry({ field: 'e' }), ]; - const query = buildExceptionItemEntries({ + const query = buildExceptionItem({ language: 'kuery', entries, }); @@ -663,7 +597,7 @@ describe('build_exceptions_query', () => { describe('match', () => { test('it returns expected query when list includes single list item with operator of "included"', () => { const entries: EntriesArray = [makeMatchEntry({ field: 'b', value: 'value' })]; - const query = buildExceptionItemEntries({ + const query = buildExceptionItem({ language: 'kuery', entries, }); @@ -676,7 +610,7 @@ describe('build_exceptions_query', () => { const entries: EntriesArray = [ makeMatchEntry({ field: 'b', operator: 'excluded', value: 'value' }), ]; - const query = buildExceptionItemEntries({ + const query = buildExceptionItem({ language: 'kuery', entries, }); @@ -694,7 +628,7 @@ describe('build_exceptions_query', () => { entries: [makeMatchEntry({ field: 'c', operator: 'included', value: 'valueC' })], }, ]; - const query = buildExceptionItemEntries({ + const query = buildExceptionItem({ language: 'kuery', entries, }); @@ -716,7 +650,7 @@ describe('build_exceptions_query', () => { }, makeMatchEntry({ field: 'e', value: 'valueE' }), ]; - const query = buildExceptionItemEntries({ + const query = buildExceptionItem({ language: 'kuery', entries, }); @@ -730,7 +664,7 @@ describe('build_exceptions_query', () => { describe('match_any', () => { test('it returns expected query when list includes single list item with operator of "included"', () => { const entries: EntriesArray = [makeMatchAnyEntry({ field: 'b' })]; - const query = buildExceptionItemEntries({ + const query = buildExceptionItem({ language: 'kuery', entries, }); @@ -741,7 +675,7 @@ describe('build_exceptions_query', () => { test('it returns expected query when list includes single list item with operator of "excluded"', () => { const entries: EntriesArray = [makeMatchAnyEntry({ field: 'b', operator: 'excluded' })]; - const query = buildExceptionItemEntries({ + const query = buildExceptionItem({ language: 'kuery', entries, }); @@ -759,7 +693,7 @@ describe('build_exceptions_query', () => { entries: [makeMatchEntry({ field: 'c', operator: 'excluded', value: 'valueC' })], }, ]; - const query = buildExceptionItemEntries({ + const query = buildExceptionItem({ language: 'kuery', entries, }); @@ -773,7 +707,7 @@ describe('build_exceptions_query', () => { makeMatchAnyEntry({ field: 'b' }), makeMatchAnyEntry({ field: 'c' }), ]; - const query = buildExceptionItemEntries({ + const query = buildExceptionItem({ language: 'kuery', entries, }); @@ -784,15 +718,15 @@ describe('build_exceptions_query', () => { }); }); - describe('buildQueryExceptions', () => { + describe('buildExceptionListQueries', () => { test('it returns empty array if lists is empty array', () => { - const query = buildQueryExceptions({ language: 'kuery', lists: [] }); + const query = buildExceptionListQueries({ language: 'kuery', lists: [] }); expect(query).toEqual([]); }); test('it returns empty array if lists is undefined', () => { - const query = buildQueryExceptions({ language: 'kuery', lists: undefined }); + const query = buildExceptionListQueries({ language: 'kuery', lists: undefined }); expect(query).toEqual([]); }); @@ -812,14 +746,24 @@ describe('build_exceptions_query', () => { }, makeMatchAnyEntry({ field: 'e', operator: 'excluded' }), ]; - const query = buildQueryExceptions({ + const queries = buildExceptionListQueries({ language: 'kuery', lists: [payload, payload2], }); - const expectedQuery = - 'not ((some.parentField:{ nested.field:"some value" } and some.not.nested.field:"some value") or (b:("value-1" or "value-2") and parent:{ c:"valueC" and d:"valueD" } and not e:("value-1" or "value-2")))'; + const expectedQueries = [ + { + query: + 'some.parentField:{ nested.field:"some value" } and some.not.nested.field:"some value"', + language: 'kuery', + }, + { + query: + 'b:("value-1" or "value-2") and parent:{ c:"valueC" and d:"valueD" } and not e:("value-1" or "value-2")', + language: 'kuery', + }, + ]; - expect(query).toEqual([{ query: expectedQuery, language: 'kuery' }]); + expect(queries).toEqual(expectedQueries); }); test('it returns expected query when lists exist and language is "lucene"', () => { @@ -827,78 +771,58 @@ describe('build_exceptions_query', () => { payload.entries = [makeMatchAnyEntry({ field: 'a' }), makeMatchAnyEntry({ field: 'b' })]; const payload2 = getExceptionListItemSchemaMock(); payload2.entries = [makeMatchAnyEntry({ field: 'c' }), makeMatchAnyEntry({ field: 'd' })]; - const query = buildQueryExceptions({ + const queries = buildExceptionListQueries({ language: 'lucene', lists: [payload, payload2], }); - const expectedQuery = - 'NOT ((a:("value-1" OR "value-2") AND b:("value-1" OR "value-2")) OR (c:("value-1" OR "value-2") AND d:("value-1" OR "value-2")))'; + const expectedQueries = [ + { + query: 'a:("value-1" OR "value-2") AND b:("value-1" OR "value-2")', + language: 'lucene', + }, + { + query: 'c:("value-1" OR "value-2") AND d:("value-1" OR "value-2")', + language: 'lucene', + }, + ]; - expect(query).toEqual([{ query: expectedQuery, language: 'lucene' }]); + expect(queries).toEqual(expectedQueries); }); - describe('when "exclude" is false', () => { - beforeEach(() => { - exclude = false; + test('it builds correct queries for nested excluded fields', () => { + const payload = getExceptionListItemSchemaMock(); + const payload2 = getExceptionListItemSchemaMock(); + payload2.entries = [ + makeMatchAnyEntry({ field: 'b' }), + { + field: 'parent', + type: 'nested', + entries: [ + // TODO: these operators are not being respected. buildNested needs to be updated + makeMatchEntry({ field: 'c', operator: 'excluded', value: 'valueC' }), + makeMatchEntry({ field: 'd', operator: 'excluded', value: 'valueD' }), + ], + }, + makeMatchAnyEntry({ field: 'e' }), + ]; + const queries = buildExceptionListQueries({ + language: 'kuery', + lists: [payload, payload2], }); - - test('it returns empty array if lists is empty array', () => { - const query = buildQueryExceptions({ + const expectedQueries = [ + { + query: + 'some.parentField:{ nested.field:"some value" } and some.not.nested.field:"some value"', language: 'kuery', - lists: [], - exclude, - }); - - expect(query).toEqual([]); - }); - - test('it returns empty array if lists is undefined', () => { - const query = buildQueryExceptions({ language: 'kuery', lists: undefined, exclude }); - - expect(query).toEqual([]); - }); - - test('it returns expected query when lists exist and language is "kuery"', () => { - const payload = getExceptionListItemSchemaMock(); - const payload2 = getExceptionListItemSchemaMock(); - payload2.entries = [ - makeMatchAnyEntry({ field: 'b' }), - { - field: 'parent', - type: 'nested', - entries: [ - makeMatchEntry({ field: 'c', operator: 'excluded', value: 'valueC' }), - makeMatchEntry({ field: 'd', operator: 'excluded', value: 'valueD' }), - ], - }, - makeMatchAnyEntry({ field: 'e' }), - ]; - const query = buildQueryExceptions({ + }, + { + query: + 'b:("value-1" or "value-2") and parent:{ not c:"valueC" and not d:"valueD" } and e:("value-1" or "value-2")', language: 'kuery', - lists: [payload, payload2], - exclude, - }); - const expectedQuery = - '(some.parentField:{ nested.field:"some value" } and some.not.nested.field:"some value") or (b:("value-1" or "value-2") and parent:{ not c:"valueC" and not d:"valueD" } and e:("value-1" or "value-2"))'; - - expect(query).toEqual([{ query: expectedQuery, language: 'kuery' }]); - }); - - test('it returns expected query when lists exist and language is "lucene"', () => { - const payload = getExceptionListItemSchemaMock(); - payload.entries = [makeMatchAnyEntry({ field: 'a' }), makeMatchAnyEntry({ field: 'b' })]; - const payload2 = getExceptionListItemSchemaMock(); - payload2.entries = [makeMatchAnyEntry({ field: 'c' }), makeMatchAnyEntry({ field: 'd' })]; - const query = buildQueryExceptions({ - language: 'lucene', - lists: [payload, payload2], - exclude, - }); - const expectedQuery = - '(a:("value-1" OR "value-2") AND b:("value-1" OR "value-2")) OR (c:("value-1" OR "value-2") AND d:("value-1" OR "value-2"))'; + }, + ]; - expect(query).toEqual([{ query: expectedQuery, language: 'lucene' }]); - }); + expect(queries).toEqual(expectedQueries); }); }); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.ts b/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.ts index ff492dcda3b6..c64d0b124b67 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.ts @@ -64,13 +64,13 @@ export const operatorBuilder = ({ }; export const buildExists = ({ - item, + entry, language, }: { - item: EntryExists; + entry: EntryExists; language: Language; }): string => { - const { operator, field } = item; + const { operator, field } = entry; const exceptionOperator = operatorBuilder({ operator, language }); switch (language) { @@ -84,26 +84,26 @@ export const buildExists = ({ }; export const buildMatch = ({ - item, + entry, language, }: { - item: EntryMatch; + entry: EntryMatch; language: Language; }): string => { - const { value, operator, field } = item; + const { value, operator, field } = entry; const exceptionOperator = operatorBuilder({ operator, language }); return `${exceptionOperator}${field}:"${value}"`; }; export const buildMatchAny = ({ - item, + entry, language, }: { - item: EntryMatchAny; + entry: EntryMatchAny; language: Language; }): string => { - const { value, operator, field } = item; + const { value, operator, field } = entry; switch (value.length) { case 0: @@ -118,67 +118,40 @@ export const buildMatchAny = ({ }; export const buildNested = ({ - item, + entry, language, }: { - item: EntryNested; + entry: EntryNested; language: Language; }): string => { - const { field, entries } = item; + const { field, entries: subentries } = entry; const and = getLanguageBooleanOperator({ language, value: 'and' }); - const values = entries.map((entry) => evaluateValues({ item: entry, language })); + const values = subentries.map((subentry) => buildEntry({ entry: subentry, language })); return `${field}:{ ${values.join(` ${and} `)} }`; }; -export const evaluateValues = ({ - item, +export const buildEntry = ({ + entry, language, }: { - item: Entry | EntryNested; + entry: Entry | EntryNested; language: Language; }): string => { - if (entriesExists.is(item)) { - return buildExists({ item, language }); - } else if (entriesMatch.is(item)) { - return buildMatch({ item, language }); - } else if (entriesMatchAny.is(item)) { - return buildMatchAny({ item, language }); - } else if (entriesNested.is(item)) { - return buildNested({ item, language }); + if (entriesExists.is(entry)) { + return buildExists({ entry, language }); + } else if (entriesMatch.is(entry)) { + return buildMatch({ entry, language }); + } else if (entriesMatchAny.is(entry)) { + return buildMatchAny({ entry, language }); + } else if (entriesNested.is(entry)) { + return buildNested({ entry, language }); } else { return ''; } }; -export const formatQuery = ({ - exceptions, - language, - exclude, -}: { - exceptions: string[]; - language: Language; - exclude: boolean; -}): string => { - if (exceptions == null || (exceptions != null && exceptions.length === 0)) { - return ''; - } - - const or = getLanguageBooleanOperator({ language, value: 'or' }); - const not = getLanguageBooleanOperator({ language, value: 'not' }); - const formattedExceptionItems = exceptions.map((exceptionItem, index) => { - if (index === 0) { - return `(${exceptionItem})`; - } - - return `${or} (${exceptionItem})`; - }); - - const exceptionItemsQuery = formattedExceptionItems.join(' '); - return exclude ? `${not} (${exceptionItemsQuery})` : exceptionItemsQuery; -}; - -export const buildExceptionItemEntries = ({ +export const buildExceptionItem = ({ entries, language, }: { @@ -186,22 +159,19 @@ export const buildExceptionItemEntries = ({ language: Language; }): string => { const and = getLanguageBooleanOperator({ language, value: 'and' }); - const exceptionItemEntries = entries.reduce((accum, listItem) => { - const exceptionSegment = evaluateValues({ item: listItem, language }); - return [...accum, exceptionSegment]; - }, []); + const exceptionItemEntries = entries.map((entry) => { + return buildEntry({ entry, language }); + }); return exceptionItemEntries.join(` ${and} `); }; -export const buildQueryExceptions = ({ +export const buildExceptionListQueries = ({ language, lists, - exclude = true, }: { language: Language; lists: Array | undefined; - exclude?: boolean; }): DataQuery[] => { if (lists == null || (lists != null && lists.length === 0)) { return []; @@ -211,7 +181,7 @@ export const buildQueryExceptions = ({ const { entries } = exceptionItem; if (entries != null && entries.length > 0 && !hasLargeValueList(entries)) { - return [...acc, buildExceptionItemEntries({ entries, language })]; + return [...acc, buildExceptionItem({ entries, language })]; } else { return acc; } @@ -220,12 +190,11 @@ export const buildQueryExceptions = ({ if (exceptionItems.length === 0) { return []; } else { - const formattedQuery = formatQuery({ exceptions: exceptionItems, language, exclude }); - return [ - { - query: formattedQuery, + return exceptionItems.map((exceptionItem) => { + return { + query: exceptionItem, language, - }, - ]; + }; + }); } }; diff --git a/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.test.ts b/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.test.ts index a8eb4e7bbb15..72ef230a4234 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.test.ts @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { getQueryFilter } from './get_query_filter'; -import { Filter } from 'src/plugins/data/public'; +import { getQueryFilter, buildExceptionFilter } from './get_query_filter'; +import { Filter, EsQueryConfig } from 'src/plugins/data/public'; import { getExceptionListItemSchemaMock } from '../../../lists/common/schemas/response/exception_list_item_schema.mock'; describe('get_filter', () => { @@ -363,49 +363,151 @@ describe('get_filter', () => { bool: { filter: [ { bool: { minimum_should_match: 1, should: [{ match: { 'host.name': 'linux' } }] } }, + ], + must: [], + must_not: [ { bool: { - must_not: { - bool: { - filter: [ - { - nested: { - path: 'some.parentField', - query: { - bool: { - minimum_should_match: 1, - should: [ - { - match_phrase: { - 'some.parentField.nested.field': 'some value', + should: [ + { + bool: { + filter: [ + { + nested: { + path: 'some.parentField', + query: { + bool: { + minimum_should_match: 1, + should: [ + { + match_phrase: { + 'some.parentField.nested.field': 'some value', + }, }, + ], + }, + }, + score_mode: 'none', + }, + }, + { + bool: { + minimum_should_match: 1, + should: [ + { + match_phrase: { + 'some.not.nested.field': 'some value', }, - ], + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], + should: [], + }, + }); + }); + + test('it should work with a list with multiple items', () => { + const esQuery = getQueryFilter( + 'host.name: linux', + 'kuery', + [], + ['auditbeat-*'], + [getExceptionListItemSchemaMock(), getExceptionListItemSchemaMock()] + ); + expect(esQuery).toEqual({ + bool: { + filter: [ + { bool: { minimum_should_match: 1, should: [{ match: { 'host.name': 'linux' } }] } }, + ], + must: [], + must_not: [ + { + bool: { + should: [ + { + bool: { + filter: [ + { + nested: { + path: 'some.parentField', + query: { + bool: { + minimum_should_match: 1, + should: [ + { + match_phrase: { + 'some.parentField.nested.field': 'some value', + }, + }, + ], + }, }, + score_mode: 'none', }, - score_mode: 'none', }, - }, - { - bool: { - minimum_should_match: 1, - should: [ - { - match_phrase: { - 'some.not.nested.field': 'some value', + { + bool: { + minimum_should_match: 1, + should: [ + { + match_phrase: { + 'some.not.nested.field': 'some value', + }, + }, + ], + }, + }, + ], + }, + }, + { + bool: { + filter: [ + { + nested: { + path: 'some.parentField', + query: { + bool: { + minimum_should_match: 1, + should: [ + { + match_phrase: { + 'some.parentField.nested.field': 'some value', + }, + }, + ], }, }, - ], + score_mode: 'none', + }, }, - }, - ], + { + bool: { + minimum_should_match: 1, + should: [ + { + match_phrase: { + 'some.not.nested.field': 'some value', + }, + }, + ], + }, + }, + ], + }, }, - }, + ], }, }, ], - must: [], - must_not: [], should: [], }, }); @@ -455,32 +557,137 @@ describe('get_filter', () => { { bool: { minimum_should_match: 1, should: [{ match: { 'host.name': 'linux' } }] } }, { bool: { - filter: [ + should: [ { - nested: { - path: 'some.parentField', - query: { - bool: { - minimum_should_match: 1, - should: [ - { - match_phrase: { - 'some.parentField.nested.field': 'some value', + bool: { + filter: [ + { + nested: { + path: 'some.parentField', + query: { + bool: { + minimum_should_match: 1, + should: [ + { + match_phrase: { + 'some.parentField.nested.field': 'some value', + }, + }, + ], }, }, - ], + score_mode: 'none', + }, }, - }, - score_mode: 'none', + { + bool: { + minimum_should_match: 1, + should: [ + { + match_phrase: { + 'some.not.nested.field': 'some value', + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], + must: [], + must_not: [], + should: [], + }, + }); + }); + + test('it should work with a list with multiple items', () => { + const esQuery = getQueryFilter( + 'host.name: linux', + 'kuery', + [], + ['auditbeat-*'], + [getExceptionListItemSchemaMock(), getExceptionListItemSchemaMock()], + false + ); + expect(esQuery).toEqual({ + bool: { + filter: [ + { bool: { minimum_should_match: 1, should: [{ match: { 'host.name': 'linux' } }] } }, + { + bool: { + should: [ + { + bool: { + filter: [ + { + nested: { + path: 'some.parentField', + query: { + bool: { + minimum_should_match: 1, + should: [ + { + match_phrase: { + 'some.parentField.nested.field': 'some value', + }, + }, + ], + }, + }, + score_mode: 'none', + }, + }, + { + bool: { + minimum_should_match: 1, + should: [ + { + match_phrase: { + 'some.not.nested.field': 'some value', + }, + }, + ], + }, + }, + ], }, }, { bool: { - minimum_should_match: 1, - should: [ + filter: [ { - match_phrase: { - 'some.not.nested.field': 'some value', + nested: { + path: 'some.parentField', + query: { + bool: { + minimum_should_match: 1, + should: [ + { + match_phrase: { + 'some.parentField.nested.field': 'some value', + }, + }, + ], + }, + }, + score_mode: 'none', + }, + }, + { + bool: { + minimum_should_match: 1, + should: [ + { + match_phrase: { + 'some.not.nested.field': 'some value', + }, + }, + ], }, }, ], @@ -703,4 +910,179 @@ describe('get_filter', () => { }); }); }); + + describe('buildExceptionFilter', () => { + const config: EsQueryConfig = { + allowLeadingWildcards: true, + queryStringOptions: { analyze_wildcard: true }, + ignoreFilterIfFieldNotInIndex: false, + dateFormatTZ: 'Zulu', + }; + test('it should build a filter without chunking exception items', () => { + const exceptionFilter = buildExceptionFilter( + [ + { language: 'kuery', query: 'host.name: linux and some.field: value' }, + { language: 'kuery', query: 'user.name: name' }, + ], + { + fields: [], + title: 'auditbeat-*', + }, + config, + true, + 2 + ); + expect(exceptionFilter).toEqual({ + meta: { + alias: null, + negate: true, + disabled: false, + }, + query: { + bool: { + should: [ + { + bool: { + filter: [ + { + bool: { + minimum_should_match: 1, + should: [ + { + match: { + 'host.name': 'linux', + }, + }, + ], + }, + }, + { + bool: { + minimum_should_match: 1, + should: [ + { + match: { + 'some.field': 'value', + }, + }, + ], + }, + }, + ], + }, + }, + { + bool: { + minimum_should_match: 1, + should: [ + { + match: { + 'user.name': 'name', + }, + }, + ], + }, + }, + ], + }, + }, + }); + }); + + test('it should properly chunk exception items', () => { + const exceptionFilter = buildExceptionFilter( + [ + { language: 'kuery', query: 'host.name: linux and some.field: value' }, + { language: 'kuery', query: 'user.name: name' }, + { language: 'kuery', query: 'file.path: /safe/path' }, + ], + { + fields: [], + title: 'auditbeat-*', + }, + config, + true, + 2 + ); + expect(exceptionFilter).toEqual({ + meta: { + alias: null, + negate: true, + disabled: false, + }, + query: { + bool: { + should: [ + { + bool: { + should: [ + { + bool: { + filter: [ + { + bool: { + minimum_should_match: 1, + should: [ + { + match: { + 'host.name': 'linux', + }, + }, + ], + }, + }, + { + bool: { + minimum_should_match: 1, + should: [ + { + match: { + 'some.field': 'value', + }, + }, + ], + }, + }, + ], + }, + }, + { + bool: { + minimum_should_match: 1, + should: [ + { + match: { + 'user.name': 'name', + }, + }, + ], + }, + }, + ], + }, + }, + { + bool: { + should: [ + { + bool: { + minimum_should_match: 1, + should: [ + { + match: { + 'file.path': '/safe/path', + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + }); + }); + }); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.ts b/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.ts index a41589b5d023..466a004c14c6 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.ts @@ -6,20 +6,21 @@ import { Filter, + Query, IIndexPattern, isFilterDisabled, buildEsQuery, - Query as DataQuery, + EsQueryConfig, } from '../../../../../src/plugins/data/common'; import { ExceptionListItemSchema, CreateExceptionListItemSchema, } from '../../../lists/common/schemas'; -import { buildQueryExceptions } from './build_exceptions_query'; -import { Query, Language, Index } from './schemas/common/schemas'; +import { buildExceptionListQueries } from './build_exceptions_query'; +import { Query as QueryString, Language, Index } from './schemas/common/schemas'; export const getQueryFilter = ( - query: Query, + query: QueryString, language: Language, filters: Array>, index: Index, @@ -31,7 +32,14 @@ export const getQueryFilter = ( title: index.join(), }; - const initialQuery = [{ query, language }]; + const config: EsQueryConfig = { + allowLeadingWildcards: true, + queryStringOptions: { analyze_wildcard: true }, + ignoreFilterIfFieldNotInIndex: false, + dateFormatTZ: 'Zulu', + }; + + const enabledFilters = ((filters as unknown) as Filter[]).filter((f) => !isFilterDisabled(f)); /* * Pinning exceptions to 'kuery' because lucene * does not support nested queries, while our exceptions @@ -39,16 +47,78 @@ export const getQueryFilter = ( * buildEsQuery, this allows us to offer nested queries * regardless */ - const exceptions = buildQueryExceptions({ language: 'kuery', lists, exclude: excludeExceptions }); - const queries: DataQuery[] = [...initialQuery, ...exceptions]; + const exceptionQueries = buildExceptionListQueries({ language: 'kuery', lists }); + if (exceptionQueries.length > 0) { + // Assume that `indices.query.bool.max_clause_count` is at least 1024 (the default value), + // allowing us to make 1024-item chunks of exception list items. + // Discussion at https://issues.apache.org/jira/browse/LUCENE-4835 indicates that 1024 is a + // very conservative value. + const exceptionFilter = buildExceptionFilter( + exceptionQueries, + indexPattern, + config, + excludeExceptions, + 1024 + ); + enabledFilters.push(exceptionFilter); + } + const initialQuery = { query, language }; - const config = { - allowLeadingWildcards: true, - queryStringOptions: { analyze_wildcard: true }, - ignoreFilterIfFieldNotInIndex: false, - dateFormatTZ: 'Zulu', - }; + return buildEsQuery(indexPattern, initialQuery, enabledFilters, config); +}; - const enabledFilters = ((filters as unknown) as Filter[]).filter((f) => !isFilterDisabled(f)); - return buildEsQuery(indexPattern, queries, enabledFilters, config); +export const buildExceptionFilter = ( + exceptionQueries: Query[], + indexPattern: IIndexPattern, + config: EsQueryConfig, + excludeExceptions: boolean, + chunkSize: number +) => { + const exceptionFilter: Filter = { + meta: { + alias: null, + negate: excludeExceptions, + disabled: false, + }, + query: { + bool: { + should: undefined, + }, + }, + }; + if (exceptionQueries.length <= chunkSize) { + const query = buildEsQuery(indexPattern, exceptionQueries, [], config); + exceptionFilter.query.bool.should = query.bool.filter; + } else { + const chunkedFilters: Filter[] = []; + for (let index = 0; index < exceptionQueries.length; index += chunkSize) { + const exceptionQueriesChunk = exceptionQueries.slice(index, index + chunkSize); + const esQueryChunk = buildEsQuery(indexPattern, exceptionQueriesChunk, [], config); + const filterChunk: Filter = { + meta: { + alias: null, + negate: false, + disabled: false, + }, + query: { + bool: { + should: esQueryChunk.bool.filter, + }, + }, + }; + chunkedFilters.push(filterChunk); + } + // Here we build a query with only the exceptions: it will put them all in the `filter` array + // of the resulting object, which would AND the exceptions together. When creating exceptionFilter, + // we move the `filter` array to `should` so they are OR'd together instead. + // This gets around the problem with buildEsQuery not allowing callers to specify whether queries passed in + // should be ANDed or ORed together. + exceptionFilter.query.bool.should = buildEsQuery( + indexPattern, + [], + chunkedFilters, + config + ).bool.filter; + } + return exceptionFilter; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/get_filter.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/get_filter.test.ts index a5740d7719f4..56768ec78745 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/get_filter.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/get_filter.test.ts @@ -209,49 +209,52 @@ describe('get_filter', () => { minimum_should_match: 1, }, }, + ], + must_not: [ { bool: { - must_not: { - bool: { - filter: [ - { - nested: { - path: 'some.parentField', - query: { - bool: { - should: [ - { - match_phrase: { - 'some.parentField.nested.field': 'some value', + should: [ + { + bool: { + filter: [ + { + nested: { + path: 'some.parentField', + query: { + bool: { + should: [ + { + match_phrase: { + 'some.parentField.nested.field': 'some value', + }, }, - }, - ], - minimum_should_match: 1, + ], + minimum_should_match: 1, + }, }, + score_mode: 'none', }, - score_mode: 'none', }, - }, - { - bool: { - should: [ - { - match_phrase: { - 'some.not.nested.field': 'some value', + { + bool: { + should: [ + { + match_phrase: { + 'some.not.nested.field': 'some value', + }, }, - }, - ], - minimum_should_match: 1, + ], + minimum_should_match: 1, + }, }, - }, - ], + ], + }, }, - }, + ], }, }, ], should: [], - must_not: [], }, }); }); From 0a3170ffa09d890e1b4fd0dc024e7c9c13b1ce04 Mon Sep 17 00:00:00 2001 From: Matthias Wilhelm Date: Wed, 22 Jul 2020 17:09:38 +0200 Subject: [PATCH 042/142] Unskip dashboard filter bar code coverage test (#72799) --- test/functional/apps/dashboard/dashboard_filter_bar.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/functional/apps/dashboard/dashboard_filter_bar.js b/test/functional/apps/dashboard/dashboard_filter_bar.js index dd0318ea5c0d..273779a42d3f 100644 --- a/test/functional/apps/dashboard/dashboard_filter_bar.js +++ b/test/functional/apps/dashboard/dashboard_filter_bar.js @@ -172,8 +172,6 @@ export default function ({ getService, getPageObjects }) { }); describe('saved search filtering', function () { - // https://github.com/elastic/kibana/issues/47286#issuecomment-644687577 - this.tags('skipCoverage'); before(async () => { await filterBar.ensureFieldEditorModalIsClosed(); await PageObjects.dashboard.gotoDashboardLandingPage(); From 9374a42a5c7b364337a9ee4fc53a8086c1f457b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20C=C3=B4t=C3=A9?= Date: Wed, 22 Jul 2020 11:27:38 -0400 Subject: [PATCH 043/142] Allow larger difference in index threshold jest test (#72506) * Allow large difference in index threshold jest test * Fix variable name --- .../alert_types/index_threshold/lib/date_range_info.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/date_range_info.test.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/date_range_info.test.ts index 32a5845a7e65..a0b726c2510c 100644 --- a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/date_range_info.test.ts +++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/date_range_info.test.ts @@ -132,10 +132,11 @@ describe('getRangeInfo', () => { it('should handle no dateStart, dateEnd or interval specified', async () => { const nowM0 = Date.now(); const nowM5 = nowM0 - 1000 * 60 * 5; + const digitPrecision = 1; const info = getDateRangeInfo(BaseRangeQuery); - expect(sloppyMilliDiff(nowM5, Date.parse(info.dateStart))).toBeCloseTo(0); - expect(sloppyMilliDiff(nowM0, Date.parse(info.dateEnd))).toBeCloseTo(0); + expect(sloppyMilliDiff(nowM5, Date.parse(info.dateStart))).toBeCloseTo(0, digitPrecision); + expect(sloppyMilliDiff(nowM0, Date.parse(info.dateEnd))).toBeCloseTo(0, digitPrecision); expect(info.dateRanges.length).toEqual(1); expect(info.dateRanges[0].from).toEqual(info.dateStart); expect(info.dateRanges[0].to).toEqual(info.dateEnd); From b346253a7a9888adb9b397c3390fd9e96d2aad8f Mon Sep 17 00:00:00 2001 From: Jonathan Buttner <56361221+jonathan-buttner@users.noreply.github.com> Date: Wed, 22 Jul 2020 11:32:55 -0400 Subject: [PATCH 044/142] Adding aggregations for endpoint events (#72705) --- .../server/lib/overview/query.dsl.ts | 142 ++++++++++++++++-- 1 file changed, 126 insertions(+), 16 deletions(-) diff --git a/x-pack/plugins/security_solution/server/lib/overview/query.dsl.ts b/x-pack/plugins/security_solution/server/lib/overview/query.dsl.ts index 8ac8233a86b8..b6b1cfea394f 100644 --- a/x-pack/plugins/security_solution/server/lib/overview/query.dsl.ts +++ b/x-pack/plugins/security_solution/server/lib/overview/query.dsl.ts @@ -142,57 +142,167 @@ export const buildOverviewHostQuery = ({ }, endgame_module: { filter: { - term: { - 'event.module': 'endgame', + bool: { + should: [ + { + term: { 'event.module': 'endpoint' }, + }, + { + term: { + 'event.module': 'endgame', + }, + }, + ], }, }, aggs: { dns_event_count: { filter: { - term: { - 'endgame.event_type_full': 'dns_event', + bool: { + should: [ + { + bool: { + filter: [ + { term: { 'network.protocol': 'dns' } }, + { term: { 'event.category': 'network' } }, + ], + }, + }, + { + term: { + 'endgame.event_type_full': 'dns_event', + }, + }, + ], }, }, }, file_event_count: { filter: { - term: { - 'endgame.event_type_full': 'file_event', + bool: { + should: [ + { + term: { + 'event.category': 'file', + }, + }, + { + term: { + 'endgame.event_type_full': 'file_event', + }, + }, + ], }, }, }, image_load_event_count: { filter: { - term: { - 'endgame.event_type_full': 'image_load_event', + bool: { + should: [ + { + bool: { + should: [ + { + term: { + 'event.category': 'library', + }, + }, + { + term: { + 'event.category': 'driver', + }, + }, + ], + }, + }, + { + term: { + 'endgame.event_type_full': 'image_load_event', + }, + }, + ], }, }, }, network_event_count: { filter: { - term: { - 'endgame.event_type_full': 'network_event', + bool: { + should: [ + { + bool: { + filter: [ + { + bool: { + must_not: { + term: { 'network.protocol': 'dns' }, + }, + }, + }, + { + term: { 'event.category': 'network' }, + }, + ], + }, + }, + { + term: { + 'endgame.event_type_full': 'network_event', + }, + }, + ], }, }, }, process_event_count: { filter: { - term: { - 'endgame.event_type_full': 'process_event', + bool: { + should: [ + { + term: { 'event.category': 'process' }, + }, + { + term: { + 'endgame.event_type_full': 'process_event', + }, + }, + ], }, }, }, registry_event: { filter: { - term: { - 'endgame.event_type_full': 'registry_event', + bool: { + should: [ + { + term: { 'event.category': 'registry' }, + }, + { + term: { + 'endgame.event_type_full': 'registry_event', + }, + }, + ], }, }, }, security_event_count: { filter: { - term: { - 'endgame.event_type_full': 'security_event', + bool: { + should: [ + { + bool: { + filter: [ + { term: { 'event.category': 'session' } }, + { term: { 'event.category': 'authentication' } }, + ], + }, + }, + { + term: { + 'endgame.event_type_full': 'security_event', + }, + }, + ], }, }, }, From 3b809bea912cd97c7001774df627cac927d92ff6 Mon Sep 17 00:00:00 2001 From: Pierre Gayvallet Date: Wed, 22 Jul 2020 17:38:08 +0200 Subject: [PATCH 045/142] add migration section for the new ES client (#71604) * add migration guide for the new client * add missing breaking changes * add paragraph on header override --- src/core/MIGRATION_EXAMPLES.md | 261 ++++++++++++++++++++++++++++++++- 1 file changed, 260 insertions(+), 1 deletion(-) diff --git a/src/core/MIGRATION_EXAMPLES.md b/src/core/MIGRATION_EXAMPLES.md index 6bb5a845ea2a..d630fec652a3 100644 --- a/src/core/MIGRATION_EXAMPLES.md +++ b/src/core/MIGRATION_EXAMPLES.md @@ -27,6 +27,10 @@ APIs to their New Platform equivalents. - [Changes in structure compared to legacy](#changes-in-structure-compared-to-legacy) - [Remarks](#remarks) - [UiSettings](#uisettings) + - [Elasticsearch client](#elasticsearch-client) + - [Client API Changes](#client-api-changes) + - [Accessing the client from a route handler](#accessing-the-client-from-a-route-handler) + - [Creating a custom client](#creating-a-custom-client) ## Configuration @@ -1003,4 +1007,259 @@ setup(core: CoreSetup){ }, }) } -``` \ No newline at end of file +``` + +## Elasticsearch client + +The new elasticsearch client is a thin wrapper around `@elastic/elasticsearch`'s `Client` class. Even if the API +is quite close to the legacy client Kibana was previously using, there are some subtle changes to take into account +during migration. + +[Official documentation](https://www.elastic.co/guide/en/elasticsearch/client/javascript-api/current/index.html) + +### Client API Changes + +The most significant changes for the consumers are the following: + +- internal / current user client accessors has been renamed and are now properties instead of functions + - `callAsInternalUser('ping')` -> `asInternalUser.ping()` + - `callAsCurrentUser('ping')` -> `asCurrentUser.ping()` + +- the API now reflects the `Client`'s instead of leveraging the string-based endpoint names the `LegacyAPICaller` was using + +before: + +```ts +const body = await client.callAsInternalUser('indices.get', { index: 'id' }); +``` + +after: + +```ts +const { body } = await client.asInternalUser.indices.get({ index: 'id' }); +``` + +- calling any ES endpoint now returns the whole response object instead of only the body payload + +before: + +```ts +const body = await legacyClient.callAsInternalUser('get', { id: 'id' }); +``` + +after: + +```ts +const { body } = await client.asInternalUser.get({ id: 'id' }); +``` + +Note that more information from the ES response is available: + +```ts +const { + body, // response payload + statusCode, // http status code of the response + headers, // response headers + warnings, // warnings returned from ES + meta // meta information about the request, such as request parameters, number of attempts and so on +} = await client.asInternalUser.get({ id: 'id' }); +``` + +- all API methods are now generic to allow specifying the response body type + +before: + +```ts +const body: GetResponse = await legacyClient.callAsInternalUser('get', { id: 'id' }); +``` + +after: + +```ts +// body is of type `GetResponse` +const { body } = await client.asInternalUser.get({ id: 'id' }); +// fallback to `Record` if unspecified +const { body } = await client.asInternalUser.get({ id: 'id' }); +``` + +- the returned error types changed + +There are no longer specific errors for every HTTP status code (such as `BadRequest` or `NotFound`). A generic +`ResponseError` with the specific `statusCode` is thrown instead. + +before: + +```ts +import { errors } from 'elasticsearch'; +try { + await legacyClient.callAsInternalUser('ping'); +} catch(e) { + if(e instanceof errors.NotFound) { + // do something + } +} +``` + +after: + +```ts +import { errors } from '@elastic/elasticsearch'; +try { + await client.asInternalUser.ping(); +} catch(e) { + if(e instanceof errors.ResponseError && e.statusCode === 404) { + // do something + } + // also possible, as all errors got a name property with the name of the class, + // so this slightly better in term of performances + if(e.name === 'ResponseError' && e.statusCode === 404) { + // do something + } +} +``` + +- the parameter property names changed from camelCase to snake_case + +Even if technically, the javascript client accepts both formats, the typescript definitions are only defining the snake_case +properties. + +before: + +```ts +legacyClient.callAsCurrentUser('get', { + id: 'id', + storedFields: ['some', 'fields'], +}) +``` + +after: + +```ts +client.asCurrentUser.get({ + id: 'id', + stored_fields: ['some', 'fields'], +}) +``` + +- the request abortion API changed + +All promises returned from the client API calls now have an `abort` method that can be used to cancel the request. + +before: + +```ts +const controller = new AbortController(); +legacyClient.callAsCurrentUser('ping', {}, { + signal: controller.signal, +}) +// later +controller.abort(); +``` + +after: + +```ts +const request = client.asCurrentUser.ping(); +// later +request.abort(); +``` + +- it is now possible to override headers when performing specific API calls. + +Note that doing so is strongly discouraged due to potential side effects with the ES service internal +behavior when scoping as the internal or as the current user. + +```ts +const request = client.asCurrentUser.ping({}, { + headers: { + authorization: 'foo', + custom: 'bar', + } +}); +``` + +Please refer to the [Breaking changes list](https://www.elastic.co/guide/en/elasticsearch/client/javascript-api/current/breaking-changes.html) +for more information about the changes between the legacy and new client. + +### Accessing the client from a route handler + +Apart from the API format change, accessing the client from within a route handler +did not change. As it was done for the legacy client, a preconfigured scoped client +bound to the request is accessible using `core` context provider: + +before: + +```ts +router.get( + { + path: '/my-route', + }, + async (context, req, res) => { + const { client } = context.core.elasticsearch.legacy; + // call as current user + const res = await client.callAsCurrentUser('ping'); + // call as internal user + const res2 = await client.callAsInternalUser('search', options); + return res.ok({ body: 'ok' }); + } +); +``` + +after: + +```ts +router.get( + { + path: '/my-route', + }, + async (context, req, res) => { + const { client } = context.core.elasticsearch; + // call as current user + const res = await client.asCurrentUser.ping(); + // call as internal user + const res2 = await client.asInternalUser.search(options); + return res.ok({ body: 'ok' }); + } +); +``` + +### Creating a custom client + +Note that the `plugins` option is now longer available on the new client. As the API is now exhaustive, adding custom +endpoints using plugins should no longer be necessary. + +The API to create custom clients did not change much: + +before: + +```ts +const customClient = coreStart.elasticsearch.legacy.createClient('my-custom-client', customConfig); +// do something with the client, such as +await customClient.callAsInternalUser('ping'); +// custom client are closable +customClient.close(); +``` + +after: + +```ts +const customClient = coreStart.elasticsearch.createClient('my-custom-client', customConfig); +// do something with the client, such as +await customClient.asInternalUser.ping(); +// custom client are closable +customClient.close(); +``` + +If, for any reasons, one still needs to reach an endpoint not listed on the client API, using `request.transport` +is still possible: + +```ts +const { body } = await client.asCurrentUser.transport.request({ + method: 'get', + path: '/my-custom-endpoint', + body: { my: 'payload'}, + querystring: { param: 'foo' } +}) +``` + +Remark: the new client creation API is now only available from the `start` contract of the elasticsearch service. From 8305d9f77560a8ce23c0cce29f83495f0511a0bd Mon Sep 17 00:00:00 2001 From: spalger Date: Wed, 22 Jul 2020 08:48:20 -0700 Subject: [PATCH 046/142] skip flaky suite (#72864) --- .../task_manager/server/lib/bulk_operation_buffer.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/task_manager/server/lib/bulk_operation_buffer.test.ts b/x-pack/plugins/task_manager/server/lib/bulk_operation_buffer.test.ts index 2c6d2b64f5d4..3a21f622cec1 100644 --- a/x-pack/plugins/task_manager/server/lib/bulk_operation_buffer.test.ts +++ b/x-pack/plugins/task_manager/server/lib/bulk_operation_buffer.test.ts @@ -33,7 +33,8 @@ function errorAttempts(task: TaskInstance): Err { +// FLAKY: https://github.com/elastic/kibana/issues/72864 +describe.skip('Bulk Operation Buffer', () => { describe('createBuffer()', () => { test('batches up multiple Operation calls', async () => { const bulkUpdate: jest.Mocked> = jest.fn( From b23b3d90248504274280bfebc0e218e2e0ba76aa Mon Sep 17 00:00:00 2001 From: Larry Gregory Date: Wed, 22 Jul 2020 12:03:45 -0400 Subject: [PATCH 047/142] De-duplicates dashboard feature definition (#72834) --- .../__snapshots__/oss_features.test.ts.snap | 2 +- .../plugins/features/server/oss_features.ts | 2 +- .../apis/short_urls/feature_controls.ts | 83 ++++++++++++++++++- 3 files changed, 83 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap b/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap index 2c98dc132f25..bfbc8b68c3d2 100644 --- a/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap +++ b/x-pack/plugins/features/server/__snapshots__/oss_features.test.ts.snap @@ -71,8 +71,8 @@ Array [ "savedObject": Object { "all": Array [ "dashboard", - "url", "query", + "url", ], "read": Array [ "index-pattern", diff --git a/x-pack/plugins/features/server/oss_features.ts b/x-pack/plugins/features/server/oss_features.ts index 8d40f51df576..9df042b45a32 100644 --- a/x-pack/plugins/features/server/oss_features.ts +++ b/x-pack/plugins/features/server/oss_features.ts @@ -148,7 +148,7 @@ export const buildOSSFeatures = ({ savedObjectTypes, includeTimelion }: BuildOSS app: ['dashboards', 'kibana'], catalogue: ['dashboard'], savedObject: { - all: ['dashboard', 'url', 'query'], + all: ['dashboard', 'query'], read: [ 'index-pattern', 'search', diff --git a/x-pack/test/api_integration/apis/short_urls/feature_controls.ts b/x-pack/test/api_integration/apis/short_urls/feature_controls.ts index 958a19c9d871..640c60572bf9 100644 --- a/x-pack/test/api_integration/apis/short_urls/feature_controls.ts +++ b/x-pack/test/api_integration/apis/short_urls/feature_controls.ts @@ -24,30 +24,37 @@ export default function featureControlsTests({ getService }: FtrProviderContext) { featureId: 'discover', canAccess: true, + canCreate: true, }, { featureId: 'dashboard', canAccess: true, + canCreate: true, }, { featureId: 'visualize', canAccess: true, + canCreate: true, }, { featureId: 'infrastructure', canAccess: true, + canCreate: false, }, { featureId: 'canvas', canAccess: true, + canCreate: false, }, { featureId: 'maps', canAccess: true, + canCreate: false, }, { featureId: 'unknown-feature', canAccess: false, + canCreate: false, }, ]; @@ -64,12 +71,46 @@ export default function featureControlsTests({ getService }: FtrProviderContext) }, ], }); + await security.role.create(`${feature.featureId}-minimal-role`, { + kibana: [ + { + base: [], + feature: { + [feature.featureId]: ['minimal_all'], + }, + spaces: ['*'], + }, + ], + }); + await security.role.create(`${feature.featureId}-minimal-shorten-role`, { + kibana: [ + { + base: [], + feature: { + [feature.featureId]: ['minimal_read', 'url_create'], + }, + spaces: ['*'], + }, + ], + }); await security.user.create(`${feature.featureId}-user`, { password: kibanaUserPassword, roles: [`${feature.featureId}-role`], full_name: 'a kibana user', }); + + await security.user.create(`${feature.featureId}-minimal-user`, { + password: kibanaUserPassword, + roles: [`${feature.featureId}-minimal-role`], + full_name: 'a kibana user', + }); + + await security.user.create(`${feature.featureId}-minimal-shorten-user`, { + password: kibanaUserPassword, + roles: [`${feature.featureId}-minimal-shorten-role`], + full_name: 'a kibana user', + }); } await security.user.create(kibanaUsername, { @@ -89,8 +130,16 @@ export default function featureControlsTests({ getService }: FtrProviderContext) }); after(async () => { - const users = features.map((feature) => security.user.delete(`${feature.featureId}-user`)); - const roles = features.map((feature) => security.role.delete(`${feature.featureId}-role`)); + const users = features.flatMap((feature) => [ + security.user.delete(`${feature.featureId}-user`), + security.user.delete(`${feature.featureId}-minimal-user`), + security.user.delete(`${feature.featureId}-minimal-shorten-user`), + ]); + const roles = features.flatMap((feature) => [ + security.role.delete(`${feature.featureId}-role`), + security.role.delete(`${feature.featureId}-minimal-role`), + security.role.delete(`${feature.featureId}-minimal-shorten-role`), + ]); await Promise.all([...users, ...roles]); await security.user.delete(kibanaUsername); }); @@ -112,6 +161,36 @@ export default function featureControlsTests({ getService }: FtrProviderContext) } }); }); + + it(`users with "minimal_all" access to ${feature.featureId} should not be able to create short-urls`, async () => { + await supertest + .post(`/api/shorten_url`) + .auth(`${feature.featureId}-minimal-user`, kibanaUserPassword) + .set('kbn-xsrf', 'foo') + .send({ url: '/app/dashboard' }) + .then((resp: Record) => { + expect(resp.status).to.eql(403); + expect(resp.body.message).to.eql('Unable to create url'); + }); + }); + + it(`users with "url_create" access to ${feature.featureId} ${ + feature.canCreate ? 'should' : 'should not' + } be able to create short-urls`, async () => { + await supertest + .post(`/api/shorten_url`) + .auth(`${feature.featureId}-minimal-shorten-user`, kibanaUserPassword) + .set('kbn-xsrf', 'foo') + .send({ url: '/app/dashboard' }) + .then((resp: Record) => { + if (feature.canCreate) { + expect(resp.status).to.eql(200); + } else { + expect(resp.status).to.eql(403); + expect(resp.body.message).to.eql('Unable to create url'); + } + }); + }); }); }); } From b12d19f8faa230c117ec060f95d76dc62ac40ede Mon Sep 17 00:00:00 2001 From: spalger Date: Wed, 22 Jul 2020 09:11:20 -0700 Subject: [PATCH 048/142] skip flaky suite (#72803) --- .../security_and_spaces/tests/alerting/update.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/update.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/update.ts index ab3a92d0b3f7..cac6355409ac 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/update.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/update.ts @@ -31,7 +31,8 @@ export default function createUpdateTests({ getService }: FtrProviderContext) { .then((response: SupertestResponse) => response.body); } - describe('update', () => { + // FLAKY: https://github.com/elastic/kibana/issues/72803 + describe.skip('update', () => { const objectRemover = new ObjectRemover(supertest); after(() => objectRemover.removeAll()); From 0ed55f2e182e18d43cba009a697b9d7dae225b8c Mon Sep 17 00:00:00 2001 From: Andrew Cholakian Date: Wed, 22 Jul 2020 11:16:51 -0500 Subject: [PATCH 049/142] [Uptime] Stop indexing saved object fields. (#72787) Fixes https://github.com/elastic/kibana/issues/72782 --- x-pack/plugins/uptime/server/lib/saved_objects.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/x-pack/plugins/uptime/server/lib/saved_objects.ts b/x-pack/plugins/uptime/server/lib/saved_objects.ts index 5a61eb859c28..8024aba19805 100644 --- a/x-pack/plugins/uptime/server/lib/saved_objects.ts +++ b/x-pack/plugins/uptime/server/lib/saved_objects.ts @@ -22,7 +22,11 @@ export const umDynamicSettings: SavedObjectsType = { hidden: false, namespaceType: 'single', mappings: { + dynamic: false, properties: { + /* Leaving these commented to make it clear that these fields exist, even though we don't want them indexed. + When adding new fields please add them here. If they need to be searchable put them in the uncommented + part of properties. heartbeatIndices: { type: 'keyword', }, @@ -32,6 +36,7 @@ export const umDynamicSettings: SavedObjectsType = { certExpirationThreshold: { type: 'long', }, + */ }, }, }; From fe5b4b81a2467f2cce3cc79d593411f2bda76a8b Mon Sep 17 00:00:00 2001 From: gchaps <33642766+gchaps@users.noreply.github.com> Date: Wed, 22 Jul 2020 09:17:33 -0700 Subject: [PATCH 050/142] [DOCS] Updates content in Introduction (#72545) * [DOCS] Updates content in Introduction * Update docs/user/introduction.asciidoc Co-authored-by: Kaarina Tungseth * Update docs/user/introduction.asciidoc Co-authored-by: Kaarina Tungseth Co-authored-by: Kaarina Tungseth --- docs/user/introduction.asciidoc | 35 +++++++++--------- .../images/intro-data-tutorial.png | Bin 155429 -> 120824 bytes .../introduction/images/intro-help-icon.png | Bin 0 -> 772 bytes .../user/introduction/images/intro-kibana.png | Bin 367348 -> 596958 bytes .../introduction/images/intro-management.png | Bin 190000 -> 190393 bytes 5 files changed, 18 insertions(+), 17 deletions(-) create mode 100644 docs/user/introduction/images/intro-help-icon.png diff --git a/docs/user/introduction.asciidoc b/docs/user/introduction.asciidoc index 6438098ad2c6..ff936fb4d556 100644 --- a/docs/user/introduction.asciidoc +++ b/docs/user/introduction.asciidoc @@ -4,9 +4,9 @@ What is Kibana? ++++ -**_Explore and visualize your data and manage all things Elastic Stack._** +**_Visualize and analyze your data and manage all things Elastic Stack._** -Whether you’re a user or admin, {kib} makes your data actionable by providing +Whether you’re an analyst or an admin, {kib} makes your data actionable by providing three key functions. Kibana is: * **An open-source analytics and visualization platform.** @@ -24,20 +24,20 @@ image::images/intro-kibana.png[] [float] [[get-data-into-kibana]] -=== Getting data into {kib} +=== Add data {kib} is designed to use {es} as a data source. Think of Elasticsearch as the engine that stores and processes the data, with {kib} sitting on top. -From the home page, {kib} provides these options for getting data in: +From the home page, {kib} provides these options for adding data: +* Import data using the +https://www.elastic.co/blog/importing-csv-and-log-data-into-elasticsearch-with-file-data-visualizer[File Data visualizer]. * Set up a data flow to Elasticsearch using our built-in tutorials. -(If a tutorial doesn’t exist for your data, go to the +If a tutorial doesn’t exist for your data, go to the {beats-ref}/beats-reference.html[Beats overview] to learn about other data shippers -in the {beats} family.) +in the {beats} family. * <> and take {kib} for a test drive without loading data yourself. -* Import static data using the -https://www.elastic.co/blog/importing-csv-and-log-data-into-elasticsearch-with-file-data-visualizer[file upload feature]. * Index your data into Elasticsearch with {ref}/getting-started-index.html[REST APIs] or https://www.elastic.co/guide/en/elasticsearch/client/index.html[client libraries]. + @@ -47,9 +47,9 @@ image::images/intro-data-tutorial.png[Ways to get data in from the home page] {kib} uses an <> to tell it which {es} indices to explore. -If you add sample data or run a built-in tutorial, you get an index pattern for free, +If you add upload a file, run a built-in tutorial, or add sample data, you get an index pattern for free, and are good to start exploring. If you load your own data, you can create -an index pattern in <>. +an index pattern in <>. [float] [[explore-and-query]] @@ -84,14 +84,14 @@ image::images/intro-dashboard.png[] {kib} also offers these visualization features: * <> allows you to display your data in -line charts, bar graphs, pie charts, histograms, and tables -(just to name a few). It's also home to Lens, the drag-and-drop interface. +charts, graphs, and tables +(just to name a few). It's also home to Lens. Visualize supports the ability to add interactive -controls to your dashboard, and filter dashboard content in real time. +controls to your dashboard, filter dashboard content in real time, and add your own images and logos for your brand. * <> gives you the ability to present your data in a visually compelling, pixel-perfect report. Give your data the “wow” factor -needed to impress your CEO or to captivate people with a big-screen display. +needed to impress your CEO or to captivate coworkers with a big-screen display. * <> enables you to ask (and answer) meaningful questions of your location-based data. Maps supports multiple @@ -99,7 +99,7 @@ layers and data sources, mapping of individual geo points and shapes, and dynamic client-side styling. * <> allows you to combine -an infinite number of aggregations to display complex data in a meaningful way. +an infinite number of aggregations to display complex data. With TSVB, you can analyze multiple index patterns and customize every aspect of your visualization. Choose your own date format and color gradients, and easily switch your data view between time series, metric, @@ -129,7 +129,7 @@ dashboards in one space, but full access to all of Kibana’s features in anothe [[manage-all-things-stack]] === Manage all things Elastic Stack -<> provides guided processes for managing all +<> provides guided processes for managing all things Elastic Stack — indices, clusters, licenses, UI settings, index patterns, and more. Want to update your {es} indices? Set user roles and privileges? Turn on dark mode? Kibana has UIs for all that. @@ -162,4 +162,5 @@ You can also <> — no code, no addi infrastructure required. Our <> and in-product guidance can -help you get up and running, faster. Use our Help menu if you have questions or feedback. +help you get up and running, faster. Click the help icon image:images/intro-help-icon.png[] +in the top navigation bar for help with questions or to provide feedback. diff --git a/docs/user/introduction/images/intro-data-tutorial.png b/docs/user/introduction/images/intro-data-tutorial.png index fd469919593af7965068c16814920438c920aeb6..2882a092fbb0bca530bff95aff72fc157a56267a 100644 GIT binary patch literal 120824 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

&9Xjvi>>d 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

t_#uxo>~u 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

Egi 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%0DFg-`V3KAuFvXyV}Xd1N4=` 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

HU^{Zh1uZP3`d;uPN4|5HUk0b6SSk44M6rE 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
O*m+Ro~r#P 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%0D_SK5+31t&D0Kx&f0=s&M2P?351wP*F}Z2 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 R8NS2rqZZ%i1sBjT}!0>=| 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
fY`A4pp(R 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 TABDL7exshYkrqzl;j8}v!&q{q literal 367348 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%0AJ`!qupE(&pzK2HL0;yS;Ur>OFi650% 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 diff --git a/docs/user/introduction/images/intro-management.png b/docs/user/introduction/images/intro-management.png index 4f32bfa80c3fd8dc8b00510a93e42fb4356be069..539deab2e3f34a9791083da89a3a3ba59aa2b2d9 100644 GIT binary patch literal 190393 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̗bVC6F)CjqDdg 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 z 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

ls{*c{U-rjI}_dis89A!ewLm{(wDKXJ_^n2W=Z0 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 @yl5e1+W4xi`T-n-066KsCd6)1%irghta@okg3E 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 From ba55ca9e86ef251a92022da4416464fce79b9eb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20St=C3=BCrmer?= Date: Wed, 22 Jul 2020 18:32:17 +0200 Subject: [PATCH 051/142] [Logs UI] Add missing ML capabilities checks (#72606) This adds several missing Machine Learning capabilities checks to the UI to make sure the user doesn't run into downstream errors resulting from the lack of permissions. It also updates the messages of the permission prompt screens to refer to the new Kibana Machine Learning permissions instead of the old built-in roles. --- .../logging/log_analysis_job_status/index.ts | 1 - .../job_configuration_outdated_callout.tsx | 4 +- .../job_definition_outdated_callout.tsx | 4 +- .../log_analysis_job_problem_indicator.tsx | 4 ++ .../notices_section.tsx | 3 ++ .../recreate_job_button.tsx | 18 ------- .../recreate_job_callout.tsx | 14 ++++-- .../log_analysis_setup/create_job_button.tsx | 49 +++++++++++++++++++ .../missing_privileges_messages.ts | 30 ++++++++++++ .../missing_results_privileges_prompt.tsx | 29 +++-------- .../missing_setup_privileges_prompt.tsx | 29 +++-------- .../missing_setup_privileges_tooltip.tsx | 23 +++++++++ .../setup_flyout/module_list.tsx | 4 ++ .../setup_flyout/module_list_card.tsx | 23 ++++----- .../user_management_link.tsx | 4 +- .../page_results_content.tsx | 5 ++ .../top_categories/top_categories_section.tsx | 10 +++- .../log_entry_rate/page_results_content.tsx | 7 ++- .../translations/translations/ja-JP.json | 4 -- .../translations/translations/zh-CN.json | 4 -- 20 files changed, 175 insertions(+), 94 deletions(-) delete mode 100644 x-pack/plugins/infra/public/components/logging/log_analysis_job_status/recreate_job_button.tsx create mode 100644 x-pack/plugins/infra/public/components/logging/log_analysis_setup/create_job_button.tsx create mode 100644 x-pack/plugins/infra/public/components/logging/log_analysis_setup/missing_privileges_messages.ts create mode 100644 x-pack/plugins/infra/public/components/logging/log_analysis_setup/missing_setup_privileges_tooltip.tsx diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/index.ts b/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/index.ts index afad55dd22d4..485ef71e0ca3 100644 --- a/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/index.ts +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/index.ts @@ -6,4 +6,3 @@ export * from './log_analysis_job_problem_indicator'; export * from './notices_section'; -export * from './recreate_job_button'; diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/job_configuration_outdated_callout.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/job_configuration_outdated_callout.tsx index a8a7ec4f5f44..0489bd7d9929 100644 --- a/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/job_configuration_outdated_callout.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/job_configuration_outdated_callout.tsx @@ -11,10 +11,12 @@ import React from 'react'; import { RecreateJobCallout } from './recreate_job_callout'; export const JobConfigurationOutdatedCallout: React.FC<{ + hasSetupCapabilities: boolean; moduleName: string; onRecreateMlJob: () => void; -}> = ({ moduleName, onRecreateMlJob }) => ( +}> = ({ hasSetupCapabilities, moduleName, onRecreateMlJob }) => ( void; -}> = ({ moduleName, onRecreateMlJob }) => ( +}> = ({ hasSetupCapabilities, moduleName, onRecreateMlJob }) => ( = ({ hasOutdatedJobConfigurations, hasOutdatedJobDefinitions, + hasSetupCapabilities, hasStoppedJobs, isFirstUse, moduleName, @@ -32,12 +34,14 @@ export const LogAnalysisJobProblemIndicator: React.FC<{ <> {hasOutdatedJobDefinitions ? ( ) : null} {hasOutdatedJobConfigurations ? ( diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/notices_section.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/notices_section.tsx index aa72281b9fbd..2535058322cb 100644 --- a/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/notices_section.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/notices_section.tsx @@ -12,6 +12,7 @@ import { CategoryQualityWarnings } from './quality_warning_notices'; export const CategoryJobNoticesSection: React.FC<{ hasOutdatedJobConfigurations: boolean; hasOutdatedJobDefinitions: boolean; + hasSetupCapabilities: boolean; hasStoppedJobs: boolean; isFirstUse: boolean; moduleName: string; @@ -21,6 +22,7 @@ export const CategoryJobNoticesSection: React.FC<{ }> = ({ hasOutdatedJobConfigurations, hasOutdatedJobDefinitions, + hasSetupCapabilities, hasStoppedJobs, isFirstUse, moduleName, @@ -32,6 +34,7 @@ export const CategoryJobNoticesSection: React.FC<{ > = (props) => ( - - - -); diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/recreate_job_callout.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/recreate_job_callout.tsx index 5b872d4ee514..cdf030a849fa 100644 --- a/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/recreate_job_callout.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_job_status/recreate_job_callout.tsx @@ -4,17 +4,21 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; import { EuiCallOut } from '@elastic/eui'; - -import { RecreateJobButton } from './recreate_job_button'; +import React from 'react'; +import { RecreateJobButton } from '../log_analysis_setup/create_job_button'; export const RecreateJobCallout: React.FC<{ + hasSetupCapabilities?: boolean; onRecreateMlJob: () => void; title?: React.ReactNode; -}> = ({ children, onRecreateMlJob, title }) => ( +}> = ({ children, hasSetupCapabilities, onRecreateMlJob, title }) => (

{children}

- + ); diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/create_job_button.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/create_job_button.tsx new file mode 100644 index 000000000000..1e4473d359bb --- /dev/null +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/create_job_button.tsx @@ -0,0 +1,49 @@ +/* + * 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 { EuiButton, PropsOf } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React from 'react'; +import { MissingSetupPrivilegesToolTip } from './missing_setup_privileges_tooltip'; + +export const CreateJobButton: React.FunctionComponent< + { + hasSetupCapabilities?: boolean; + } & PropsOf +> = ({ hasSetupCapabilities = true, children, ...buttonProps }) => { + const button = ( + + {children ?? ( + + )} + + ); + + return hasSetupCapabilities ? ( + button + ) : ( + + {button} + + ); +}; + +export const RecreateJobButton: React.FunctionComponent> = ({ + children, + ...otherProps +}) => ( + + {children ?? ( + + )} + +); diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/missing_privileges_messages.ts b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/missing_privileges_messages.ts new file mode 100644 index 000000000000..cca8fc03f7c2 --- /dev/null +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/missing_privileges_messages.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const missingMlPrivilegesTitle = i18n.translate( + 'xpack.infra.logs.analysis.missingMlPrivilegesTitle', + { + defaultMessage: 'Additional Machine Learning privileges required', + } +); + +export const missingMlResultsPrivilegesDescription = i18n.translate( + 'xpack.infra.logs.analysis.missingMlResultsPrivilegesDescription', + { + defaultMessage: + 'This feature makes use of Machine Learning jobs, which require at least the read permission for the Machine Learning app in order to access their status and results.', + } +); + +export const missingMlSetupPrivilegesDescription = i18n.translate( + 'xpack.infra.logs.analysis.missingMlSetupPrivilegesDescription', + { + defaultMessage: + 'This feature makes use of Machine Learning jobs, which require all permissions for the Machine Learning app in order to be set up.', + } +); diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/missing_results_privileges_prompt.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/missing_results_privileges_prompt.tsx index 2d378508e2b5..3aa8b544b7b5 100644 --- a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/missing_results_privileges_prompt.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/missing_results_privileges_prompt.tsx @@ -4,34 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiEmptyPrompt, EuiCode } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiEmptyPrompt } from '@elastic/eui'; import React from 'react'; - import { euiStyled } from '../../../../../observability/public'; +import { + missingMlPrivilegesTitle, + missingMlResultsPrivilegesDescription, +} from './missing_privileges_messages'; import { UserManagementLink } from './user_management_link'; export const MissingResultsPrivilegesPrompt: React.FunctionComponent = () => ( - - - } - body={ -

- machine_learning_user, - }} - /> -

- } + title={

{missingMlPrivilegesTitle}

} + body={

{missingMlResultsPrivilegesDescription}

} actions={} /> ); diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/missing_setup_privileges_prompt.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/missing_setup_privileges_prompt.tsx index db89ff415a6f..6a5a1da89041 100644 --- a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/missing_setup_privileges_prompt.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/missing_setup_privileges_prompt.tsx @@ -4,34 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiEmptyPrompt, EuiCode } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiEmptyPrompt } from '@elastic/eui'; import React from 'react'; - import { euiStyled } from '../../../../../observability/public'; +import { + missingMlPrivilegesTitle, + missingMlSetupPrivilegesDescription, +} from './missing_privileges_messages'; import { UserManagementLink } from './user_management_link'; export const MissingSetupPrivilegesPrompt: React.FunctionComponent = () => ( - - - } - body={ -

- machine_learning_admin, - }} - /> -

- } + title={

{missingMlPrivilegesTitle}

} + body={

{missingMlSetupPrivilegesDescription}

} actions={} /> ); diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/missing_setup_privileges_tooltip.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/missing_setup_privileges_tooltip.tsx new file mode 100644 index 000000000000..ccd207129e47 --- /dev/null +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/missing_setup_privileges_tooltip.tsx @@ -0,0 +1,23 @@ +/* + * 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 { EuiToolTip, PropsOf } from '@elastic/eui'; +import React from 'react'; +import { + missingMlPrivilegesTitle, + missingMlSetupPrivilegesDescription, +} from './missing_privileges_messages'; + +export const MissingSetupPrivilegesToolTip: React.FC, + 'content' | 'title' +>> = (props) => ( + +); diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/module_list.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/module_list.tsx index 8239ab4a730f..2c68aceccaa4 100644 --- a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/module_list.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/module_list.tsx @@ -6,6 +6,7 @@ import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import React, { useCallback } from 'react'; +import { useLogAnalysisCapabilitiesContext } from '../../../../containers/logs/log_analysis'; import { logEntryCategoriesModule, useLogEntryCategoriesModuleContext, @@ -20,6 +21,7 @@ import type { ModuleId } from './setup_flyout_state'; export const LogAnalysisModuleList: React.FC<{ onViewModuleSetup: (module: ModuleId) => void; }> = ({ onViewModuleSetup }) => { + const { hasLogAnalysisSetupCapabilities } = useLogAnalysisCapabilitiesContext(); const { setupStatus: logEntryRateSetupStatus } = useLogEntryRateModuleContext(); const { setupStatus: logEntryCategoriesSetupStatus } = useLogEntryCategoriesModuleContext(); @@ -35,6 +37,7 @@ export const LogAnalysisModuleList: React.FC<{ void; -}> = ({ moduleDescription, moduleName, moduleStatus, onViewSetup }) => { - const icon = +}> = ({ hasSetupCapabilities, moduleDescription, moduleName, moduleStatus, onViewSetup }) => { + const moduleIcon = moduleStatus.type === 'required' ? ( ) : ( ); - const footerContent = + + const moduleSetupButton = moduleStatus.type === 'required' ? ( - + - + ) : ( - + ); return ( {footerContent}

;w^On!>;liUW$G|tdEZMSV5emc3 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 YV;bC(4bsGkKt6xs!ZJct0($=cAFdTZSpWb4 diff --git a/docs/user/introduction/images/intro-help-icon.png b/docs/user/introduction/images/intro-help-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..7766434b8e364c60ca38b5214c1d72089f843418 GIT binary patch literal 772 zcmV+f1N;1mP)Px%zDYzuR5%f>Q(Z`tVHAFrg00qsib&m7m(^MY=3MiC6^a)XHNlJWrn<<)i|FSn z2)e6_ELrlKjb7UXO)m;zld*5@;bIyCt z**OYH0RI8me~YoWv;>#ihhQ*-NHhk8AZfJ)Fc|bGFEt}KSDoRe*qG7a;0HW^`5HU7 z??7>JA#^$|l*-MRo&AO}j}HqAi)gq|kK>k#KZ8-A6xv>ONJS=#WV1U{s*JO@Z$L5% z4sp{Vmo_vsiia(&NG6jQ8XiS{ejfb(0H#Bq_+`UEy~yw=`rf`nLU4$y(G;MnvW)Fq z8ujE#<2BS-&tQ1OC4O$`L{CUI8Vi}|9}l3*?nERKgQcnr77?KHO*fuAdH}Ur^o-JT z_V!Cn%{MvIiNK^(Q)`t%p_%l|`A)j(+H=yx#3aj2%{QbT;Y-*=&jqKZQDQdo%kD9s z$mBHi`h6^?e@JINdkS8kpVh}qMffOu2^=UAo#T2*5em=9<&eA)CgB{O`NC?gRwKq^ zj_({up-@nXtXN(BCEqs&A(?n9D=TcGJNXhgkVcb_s2DtgLBAh9(b#`s%rnj$vPU9O zXoW9<106YRfXBODcV$H>Y&IvRrb0|e|B%k%=tX&139G%n0LYh+bw?m?_b%8RPOPo1 z!O`8vv*Qm;VmY2bR(2NV=fA^l??KDsr-;Vp#B~2aBC*VmU2VLCoSYoUE8=o_asAe9 zK}d@6FGs`qdW?*^5e|Rm714{5%_bvWw6%+Ybz$okCGOw5E3z|lMm$HNX^i%cE~(gj zLh9`5PN_0ZvJr>4>5wy84-2vN9<)AdgIcu>G$(uZ>e%#EEG_*BOYxt$e6d09Q_3$R z5E CWN4`X literal 0 HcmV?d00001 diff --git a/docs/user/introduction/images/intro-kibana.png b/docs/user/introduction/images/intro-kibana.png index 1a59230f2f1665340f870fee2a574cf0a2c20e83..62c2c9982613172bda93f23a35fbe6455386aff9 100644 GIT binary patch literal 596958 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

} - icon={icon} + footer={
{moduleSetupButton}
} + icon={moduleIcon} title={moduleName} /> ); diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/user_management_link.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/user_management_link.tsx index 49ab25297c68..66fac524b023 100644 --- a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/user_management_link.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/user_management_link.tsx @@ -11,8 +11,8 @@ import { useLinkProps } from '../../../hooks/use_link_props'; export const UserManagementLink: React.FunctionComponent = (props) => { const linkProps = useLinkProps({ - app: 'kibana', - hash: '/management/security/users', + app: 'management', + pathname: '/security/users', }); return ( diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx index 5e602e1f6386..028dd0d3a1a7 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx @@ -23,6 +23,7 @@ import { StringTimeRange, useLogEntryCategoriesResultsUrlState, } from './use_log_entry_categories_results_url_state'; +import { useLogAnalysisCapabilitiesContext } from '../../../containers/logs/log_analysis/log_analysis_capabilities'; const JOB_STATUS_POLLING_INTERVAL = 30000; @@ -36,6 +37,8 @@ export const LogEntryCategoriesResultsContent: React.FunctionComponent = ({ availableDatasets, + hasSetupCapabilities, isLoadingDatasets = false, isLoadingTopCategories = false, jobId, @@ -51,7 +53,11 @@ export const TopCategoriesSection: React.FunctionComponent<{ - + diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx index fb1dc7717fed..65cc4a6c4a70 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx @@ -15,7 +15,9 @@ import { CategoryJobNoticesSection, LogAnalysisJobProblemIndicator, } from '../../../components/logging/log_analysis_job_status'; +import { DatasetsSelector } from '../../../components/logging/log_analysis_results/datasets_selector'; import { useLogAnalysisSetupFlyoutStateContext } from '../../../components/logging/log_analysis_setup/setup_flyout'; +import { useLogAnalysisCapabilitiesContext } from '../../../containers/logs/log_analysis/log_analysis_capabilities'; import { useLogEntryCategoriesModuleContext } from '../../../containers/logs/log_analysis/modules/log_entry_categories'; import { useLogEntryRateModuleContext } from '../../../containers/logs/log_analysis/modules/log_entry_rate'; import { useLogSourceContext } from '../../../containers/logs/log_source'; @@ -27,7 +29,6 @@ import { StringTimeRange, useLogAnalysisResultsUrlState, } from './use_log_entry_rate_results_url_state'; -import { DatasetsSelector } from '../../../components/logging/log_analysis_results/datasets_selector'; export const SORT_DEFAULTS = { direction: 'desc' as const, @@ -44,6 +45,8 @@ export const LogEntryRateResultsContent: React.FunctionComponent = () => { const { sourceId } = useLogSourceContext(); + const { hasLogAnalysisSetupCapabilities } = useLogAnalysisCapabilitiesContext(); + const { hasOutdatedJobConfigurations: hasOutdatedLogEntryRateJobConfigurations, hasOutdatedJobDefinitions: hasOutdatedLogEntryRateJobDefinitions, @@ -223,6 +226,7 @@ export const LogEntryRateResultsContent: React.FunctionComponent = () => { { Date: Wed, 22 Jul 2020 12:39:29 -0400 Subject: [PATCH 052/142] [SIEM] [Detections] Fixes filtering with large value lists to use "ands" between lists (#72304) * wip - comment and sample json for exceptions * promise.all for OR-ing exception items and quick-start script * logging, added/updated json sample scripts, fixed missing await on filter with lists * WIP * bug fix where two lists when 'anded' together were not filtering down result set * undo changes from testing * fix changes to example json and fixes missed conflict with master * update log message and fix type errors * change log statement and add unit test for when exception items without a value list are passed in to the filter function * fix failing test * update expect on one test and adds a new test to ensure anding of value lists when appearing in different exception items * update test after rebasing with master * properly ands exception item entries together with proper test cases * fix test (log statement tests - need to come up with a better way to cover these) * cleans up json examples * rename test and use 'every' in lieu of 'some' when determining if the filter logic should execute --- .../new/exception_list_item.json | 2 +- .../exception_list_item_with_bad_ip_list.json | 24 ++ .../scripts/lists/new/list_ip_item.json | 4 + .../scripts/lists/new/list_keyword_item.json | 4 + .../lists/server/scripts/quick_start.sh | 5 + .../signals/__mocks__/es_results.ts | 15 +- .../signals/filter_events_with_list.test.ts | 293 ++++++++++++++++++ .../signals/filter_events_with_list.ts | 181 +++++++---- .../signals/search_after_bulk_create.test.ts | 4 +- .../signals/single_bulk_create.ts | 5 +- 10 files changed, 467 insertions(+), 70 deletions(-) create mode 100644 x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_with_bad_ip_list.json create mode 100644 x-pack/plugins/lists/server/scripts/lists/new/list_ip_item.json create mode 100644 x-pack/plugins/lists/server/scripts/lists/new/list_keyword_item.json create mode 100755 x-pack/plugins/lists/server/scripts/quick_start.sh diff --git a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item.json b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item.json index 5fbfcc10bcc3..eede855aab19 100644 --- a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item.json +++ b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item.json @@ -8,7 +8,7 @@ "name": "Sample Endpoint Exception List", "entries": [ { - "field": "host.ip", + "field": "actingProcess.file.signer", "operator": "excluded", "type": "exists" }, diff --git a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_with_bad_ip_list.json b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_with_bad_ip_list.json new file mode 100644 index 000000000000..bab435487ec2 --- /dev/null +++ b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_with_bad_ip_list.json @@ -0,0 +1,24 @@ +{ + "list_id": "endpoint_list", + "item_id": "endpoint_list_item_good_rock01", + "_tags": ["endpoint", "process", "malware", "os:windows"], + "tags": ["user added string for a tag", "malware"], + "type": "simple", + "description": "Don't signal when agent.name is rock01 and source.ip is in the goodguys.txt list", + "name": "Filter out good guys ip and agent.name rock01", + "comments": [], + "entries": [ + { + "field": "agent.name", + "operator": "excluded", + "type": "match", + "value": ["rock01"] + }, + { + "field": "source.ip", + "operator": "excluded", + "type": "list", + "list": { "id": "goodguys.txt", "type": "ip" } + } + ] +} diff --git a/x-pack/plugins/lists/server/scripts/lists/new/list_ip_item.json b/x-pack/plugins/lists/server/scripts/lists/new/list_ip_item.json new file mode 100644 index 000000000000..e932892b517a --- /dev/null +++ b/x-pack/plugins/lists/server/scripts/lists/new/list_ip_item.json @@ -0,0 +1,4 @@ +{ + "id": "hand_inserted_item_id", + "value": "127.0.0.1" +} diff --git a/x-pack/plugins/lists/server/scripts/lists/new/list_keyword_item.json b/x-pack/plugins/lists/server/scripts/lists/new/list_keyword_item.json new file mode 100644 index 000000000000..ed798a1dc079 --- /dev/null +++ b/x-pack/plugins/lists/server/scripts/lists/new/list_keyword_item.json @@ -0,0 +1,4 @@ +{ + "list_id": "keyword_list", + "value": "sh" +} diff --git a/x-pack/plugins/lists/server/scripts/quick_start.sh b/x-pack/plugins/lists/server/scripts/quick_start.sh new file mode 100755 index 000000000000..d09370bd46a5 --- /dev/null +++ b/x-pack/plugins/lists/server/scripts/quick_start.sh @@ -0,0 +1,5 @@ +./hard_reset.sh && \ +./post_list.sh lists/new/lists/keyword.json && \ +./post_list_item.sh lists/new/list_keyword_item.json && \ +./post_exception_list.sh && \ +./post_exception_list_item.sh ./exception_lists/new/exception_list_item_with_list.json diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts index 19fcf65ec0c5..513d6a93d1b5 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts @@ -69,7 +69,8 @@ export const sampleDocNoSortIdNoVersion = (someUuid: string = sampleIdGuid): Sig export const sampleDocWithSortId = ( someUuid: string = sampleIdGuid, - ip?: string + ip?: string, + destIp?: string ): SignalSourceHit => ({ _index: 'myFakeSignalIndex', _type: 'doc', @@ -82,6 +83,9 @@ export const sampleDocWithSortId = ( source: { ip: ip ?? '127.0.0.1', }, + destination: { + ip: destIp ?? '127.0.0.1', + }, }, sort: ['1234567891111'], }); @@ -307,7 +311,8 @@ export const repeatedSearchResultsWithSortId = ( total: number, pageSize: number, guids: string[], - ips?: string[] + ips?: string[], + destIps?: string[] ) => ({ took: 10, timed_out: false, @@ -321,7 +326,11 @@ export const repeatedSearchResultsWithSortId = ( total, max_score: 100, hits: Array.from({ length: pageSize }).map((x, index) => ({ - ...sampleDocWithSortId(guids[index], ips ? ips[index] : '127.0.0.1'), + ...sampleDocWithSortId( + guids[index], + ips ? ips[index] : '127.0.0.1', + destIps ? destIps[index] : '127.0.0.1' + ), })), }, }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filter_events_with_list.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filter_events_with_list.test.ts index 9eebb91c3265..8c39a254e426 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filter_events_with_list.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filter_events_with_list.test.ts @@ -44,6 +44,25 @@ describe('filterEventsAgainstList', () => { expect(res.hits.hits.length).toEqual(4); }); + it('should respond with eventSearchResult if exceptionList does not contain value list exceptions', async () => { + const res = await filterEventsAgainstList({ + logger: mockLogger, + listClient, + exceptionsList: [getExceptionListItemSchemaMock()], + eventSearchResult: repeatedSearchResultsWithSortId(4, 4, someGuids.slice(0, 3), [ + '1.1.1.1', + '2.2.2.2', + '3.3.3.3', + '7.7.7.7', + ]), + buildRuleMessage, + }); + expect(res.hits.hits.length).toEqual(4); + expect(((mockLogger.debug as unknown) as jest.Mock).mock.calls[0][0]).toContain( + 'no exception items of type list found - returning original search result' + ); + }); + describe('operator_type is included', () => { it('should respond with same list if no items match value list', async () => { const exceptionItem = getExceptionListItemSchemaMock(); @@ -106,6 +125,280 @@ describe('filterEventsAgainstList', () => { 'ci-badguys.txt' ); expect(res.hits.hits.length).toEqual(2); + + // @ts-ignore + const ipVals = res.hits.hits.map((item) => item._source.source.ip); + expect(['3.3.3.3', '7.7.7.7']).toEqual(ipVals); + }); + + it('should respond with less items in the list given two exception items with entries of type list if some values match', async () => { + const exceptionItem = getExceptionListItemSchemaMock(); + exceptionItem.entries = [ + { + field: 'source.ip', + operator: 'included', + type: 'list', + list: { + id: 'ci-badguys.txt', + type: 'ip', + }, + }, + ]; + + const exceptionItemAgain = getExceptionListItemSchemaMock(); + exceptionItemAgain.entries = [ + { + field: 'source.ip', + operator: 'included', + type: 'list', + list: { + id: 'ci-badguys-again.txt', + type: 'ip', + }, + }, + ]; + + // this call represents an exception list with a value list containing ['2.2.2.2', '4.4.4.4'] + (listClient.getListItemByValues as jest.Mock).mockResolvedValueOnce([ + { ...getListItemResponseMock(), value: '2.2.2.2' }, + { ...getListItemResponseMock(), value: '4.4.4.4' }, + ]); + // this call represents an exception list with a value list containing ['6.6.6.6'] + (listClient.getListItemByValues as jest.Mock).mockResolvedValueOnce([ + { ...getListItemResponseMock(), value: '6.6.6.6' }, + ]); + + const res = await filterEventsAgainstList({ + logger: mockLogger, + listClient, + exceptionsList: [exceptionItem, exceptionItemAgain], + eventSearchResult: repeatedSearchResultsWithSortId(9, 9, someGuids.slice(0, 9), [ + '1.1.1.1', + '2.2.2.2', + '3.3.3.3', + '4.4.4.4', + '5.5.5.5', + '6.6.6.6', + '7.7.7.7', + '8.8.8.8', + '9.9.9.9', + ]), + buildRuleMessage, + }); + expect(listClient.getListItemByValues as jest.Mock).toHaveBeenCalledTimes(2); + expect(res.hits.hits.length).toEqual(6); + + // @ts-ignore + const ipVals = res.hits.hits.map((item) => item._source.source.ip); + expect(['1.1.1.1', '3.3.3.3', '5.5.5.5', '7.7.7.7', '8.8.8.8', '9.9.9.9']).toEqual(ipVals); + }); + + it('should respond with less items in the list given two exception items, each with one entry of type list if some values match', async () => { + const exceptionItem = getExceptionListItemSchemaMock(); + exceptionItem.entries = [ + { + field: 'source.ip', + operator: 'included', + type: 'list', + list: { + id: 'ci-badguys.txt', + type: 'ip', + }, + }, + ]; + + const exceptionItemAgain = getExceptionListItemSchemaMock(); + exceptionItemAgain.entries = [ + { + field: 'source.ip', + operator: 'included', + type: 'list', + list: { + id: 'ci-badguys-again.txt', + type: 'ip', + }, + }, + ]; + + // this call represents an exception list with a value list containing ['2.2.2.2', '4.4.4.4'] + (listClient.getListItemByValues as jest.Mock).mockResolvedValueOnce([ + { ...getListItemResponseMock(), value: '2.2.2.2' }, + ]); + // this call represents an exception list with a value list containing ['6.6.6.6'] + (listClient.getListItemByValues as jest.Mock).mockResolvedValueOnce([ + { ...getListItemResponseMock(), value: '6.6.6.6' }, + ]); + + const res = await filterEventsAgainstList({ + logger: mockLogger, + listClient, + exceptionsList: [exceptionItem, exceptionItemAgain], + eventSearchResult: repeatedSearchResultsWithSortId(9, 9, someGuids.slice(0, 9), [ + '1.1.1.1', + '2.2.2.2', + '3.3.3.3', + '4.4.4.4', + '5.5.5.5', + '6.6.6.6', + '7.7.7.7', + '8.8.8.8', + '9.9.9.9', + ]), + buildRuleMessage, + }); + expect(listClient.getListItemByValues as jest.Mock).toHaveBeenCalledTimes(2); + // @ts-ignore + const ipVals = res.hits.hits.map((item) => item._source.source.ip); + expect(res.hits.hits.length).toEqual(7); + + expect(['1.1.1.1', '3.3.3.3', '4.4.4.4', '5.5.5.5', '7.7.7.7', '8.8.8.8', '9.9.9.9']).toEqual( + ipVals + ); + }); + + it('should respond with less items in the list given one exception item with two entries of type list only if source.ip and destination.ip are in the events', async () => { + const exceptionItem = getExceptionListItemSchemaMock(); + exceptionItem.entries = [ + { + field: 'source.ip', + operator: 'included', + type: 'list', + list: { + id: 'ci-badguys.txt', + type: 'ip', + }, + }, + { + field: 'destination.ip', + operator: 'included', + type: 'list', + list: { + id: 'ci-badguys-again.txt', + type: 'ip', + }, + }, + ]; + + // this call represents an exception list with a value list containing ['2.2.2.2'] + (listClient.getListItemByValues as jest.Mock).mockResolvedValueOnce([ + { ...getListItemResponseMock(), value: '2.2.2.2' }, + ]); + // this call represents an exception list with a value list containing ['4.4.4.4'] + (listClient.getListItemByValues as jest.Mock).mockResolvedValueOnce([ + { ...getListItemResponseMock(), value: '4.4.4.4' }, + ]); + + const res = await filterEventsAgainstList({ + logger: mockLogger, + listClient, + exceptionsList: [exceptionItem], + eventSearchResult: repeatedSearchResultsWithSortId( + 9, + 9, + someGuids.slice(0, 9), + [ + '1.1.1.1', + '2.2.2.2', + '3.3.3.3', + '4.4.4.4', + '5.5.5.5', + '6.6.6.6', + '2.2.2.2', + '8.8.8.8', + '9.9.9.9', + ], + [ + '2.2.2.2', + '2.2.2.2', + '2.2.2.2', + '2.2.2.2', + '2.2.2.2', + '2.2.2.2', + '4.4.4.4', + '2.2.2.2', + '2.2.2.2', + ] + ), + buildRuleMessage, + }); + expect(listClient.getListItemByValues as jest.Mock).toHaveBeenCalledTimes(2); + expect(res.hits.hits.length).toEqual(8); + + // @ts-ignore + const ipVals = res.hits.hits.map((item) => item._source.source.ip); + expect([ + '1.1.1.1', + '2.2.2.2', + '3.3.3.3', + '4.4.4.4', + '5.5.5.5', + '6.6.6.6', + '8.8.8.8', + '9.9.9.9', + ]).toEqual(ipVals); + }); + + it('should respond with the same items in the list given one exception item with two entries of type list where the entries are included and excluded', async () => { + const exceptionItem = getExceptionListItemSchemaMock(); + exceptionItem.entries = [ + { + field: 'source.ip', + operator: 'included', + type: 'list', + list: { + id: 'ci-badguys.txt', + type: 'ip', + }, + }, + { + field: 'source.ip', + operator: 'excluded', + type: 'list', + list: { + id: 'ci-badguys-again.txt', + type: 'ip', + }, + }, + ]; + + // this call represents an exception list with a value list containing ['2.2.2.2', '4.4.4.4'] + (listClient.getListItemByValues as jest.Mock).mockResolvedValue([ + { ...getListItemResponseMock(), value: '2.2.2.2' }, + ]); + + const res = await filterEventsAgainstList({ + logger: mockLogger, + listClient, + exceptionsList: [exceptionItem], + eventSearchResult: repeatedSearchResultsWithSortId(9, 9, someGuids.slice(0, 9), [ + '1.1.1.1', + '2.2.2.2', + '3.3.3.3', + '4.4.4.4', + '5.5.5.5', + '6.6.6.6', + '7.7.7.7', + '8.8.8.8', + '9.9.9.9', + ]), + buildRuleMessage, + }); + expect(listClient.getListItemByValues as jest.Mock).toHaveBeenCalledTimes(2); + expect(res.hits.hits.length).toEqual(9); + + // @ts-ignore + const ipVals = res.hits.hits.map((item) => item._source.source.ip); + expect([ + '1.1.1.1', + '2.2.2.2', + '3.3.3.3', + '4.4.4.4', + '5.5.5.5', + '6.6.6.6', + '7.7.7.7', + '8.8.8.8', + '9.9.9.9', + ]).toEqual(ipVals); }); }); describe('operator type is excluded', () => { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filter_events_with_list.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filter_events_with_list.ts index ea52aecb379f..262af5d88e22 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filter_events_with_list.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/filter_events_with_list.ts @@ -10,9 +10,10 @@ import { ListClient } from '../../../../../lists/server'; import { SignalSearchResponse, SearchTypes } from './types'; import { BuildRuleMessage } from './rule_messages'; import { - entriesList, EntryList, ExceptionListItemSchema, + entriesList, + Type, } from '../../../../../lists/common/schemas'; import { hasLargeValueList } from '../../../../common/detection_engine/utils'; @@ -24,6 +25,51 @@ interface FilterEventsAgainstList { buildRuleMessage: BuildRuleMessage; } +export const createSetToFilterAgainst = async ({ + events, + field, + listId, + listType, + listClient, + logger, + buildRuleMessage, +}: { + events: SignalSearchResponse['hits']['hits']; + field: string; + listId: string; + listType: Type; + listClient: ListClient; + logger: Logger; + buildRuleMessage: BuildRuleMessage; +}): Promise> => { + // narrow unioned type to be single + const isStringableType = (val: SearchTypes) => + ['string', 'number', 'boolean'].includes(typeof val); + const valuesFromSearchResultField = events.reduce((acc, searchResultItem) => { + const valueField = get(field, searchResultItem._source); + if (valueField != null && isStringableType(valueField)) { + acc.add(valueField.toString()); + } + return acc; + }, new Set()); + logger.debug( + `number of distinct values from ${field}: ${[...valuesFromSearchResultField].length}` + ); + + // matched will contain any list items that matched with the + // values passed in from the Set. + const matchedListItems = await listClient.getListItemByValues({ + listId, + type: listType, + value: [...valuesFromSearchResultField], + }); + + logger.debug(`number of matched items from list with id ${listId}: ${matchedListItems.length}`); + // create a set of list values that were a hit - easier to work with + const matchedListItemsSet = new Set(matchedListItems.map((item) => item.value)); + return matchedListItemsSet; +}; + export const filterEventsAgainstList = async ({ listClient, exceptionsList, @@ -32,7 +78,6 @@ export const filterEventsAgainstList = async ({ buildRuleMessage, }: FilterEventsAgainstList): Promise => { try { - logger.debug(buildRuleMessage(`exceptionsList: ${JSON.stringify(exceptionsList, null, 2)}`)); if (exceptionsList == null || exceptionsList.length === 0) { logger.debug(buildRuleMessage('about to return original search result')); return eventSearchResult; @@ -51,87 +96,97 @@ export const filterEventsAgainstList = async ({ ); if (exceptionItemsWithLargeValueLists.length === 0) { - logger.debug(buildRuleMessage('about to return original search result')); + logger.debug( + buildRuleMessage('no exception items of type list found - returning original search result') + ); return eventSearchResult; } - // narrow unioned type to be single - const isStringableType = (val: SearchTypes) => - ['string', 'number', 'boolean'].includes(typeof val); - // grab the signals with values found in the given exception lists. - const filteredHitsPromises = exceptionItemsWithLargeValueLists.map( - async (exceptionItem: ExceptionListItemSchema) => { - const { entries } = exceptionItem; - - const filteredHitsEntries = entries - .filter((t): t is EntryList => entriesList.is(t)) - .map(async (entry) => { + const valueListExceptionItems = exceptionsList.filter((listItem: ExceptionListItemSchema) => { + return listItem.entries.every((entry) => entriesList.is(entry)); + }); + + // now that we have all the exception items which are value lists (whether single entry or have multiple entries) + const res = await valueListExceptionItems.reduce>( + async ( + filteredAccum: Promise, + exceptionItem: ExceptionListItemSchema + ) => { + // 1. acquire the values from the specified fields to check + // e.g. if the value list is checking against source.ip, gather + // all the values for source.ip from the search response events. + + // 2. search against the value list with the values found in the search result + // and see if there are any matches. For every match, add that value to a set + // that represents the "matched" values + + // 3. filter the search result against the set from step 2 using the + // given operator (included vs excluded). + // acquire the list values we are checking for in the field. + const filtered = await filteredAccum; + const typedEntries = exceptionItem.entries.filter((entry): entry is EntryList => + entriesList.is(entry) + ); + const fieldAndSetTuples = await Promise.all( + typedEntries.map(async (entry) => { const { list, field, operator } = entry; const { id, type } = list; - - // acquire the list values we are checking for. - const valuesOfGivenType = eventSearchResult.hits.hits.reduce( - (acc, searchResultItem) => { - const valueField = get(field, searchResultItem._source); - - if (valueField != null && isStringableType(valueField)) { - acc.add(valueField.toString()); - } - return acc; - }, - new Set() - ); - - // matched will contain any list items that matched with the - // values passed in from the Set. - const matchedListItems = await listClient.getListItemByValues({ + const matchedSet = await createSetToFilterAgainst({ + events: filtered, + field, listId: id, - type, - value: [...valuesOfGivenType], + listType: type, + listClient, + logger, + buildRuleMessage, }); - // create a set of list values that were a hit - easier to work with - const matchedListItemsSet = new Set( - matchedListItems.map((item) => item.value) - ); - - // do a single search after with these values. - // painless script to do nested query in elasticsearch - // filter out the search results that match with the values found in the list. - const filteredEvents = eventSearchResult.hits.hits.filter((item) => { - const eventItem = get(entry.field, item._source); - if (operator === 'included') { - if (eventItem != null) { - return !matchedListItemsSet.has(eventItem); - } - } else if (operator === 'excluded') { - if (eventItem != null) { - return matchedListItemsSet.has(eventItem); - } + return Promise.resolve({ field, operator, matchedSet }); + }) + ); + + // check if for each tuple, the entry is not in both for when two value list entries exist. + // need to re-write this as a reduce. + const filteredEvents = filtered.filter((item) => { + const vals = fieldAndSetTuples.map((tuple) => { + const eventItem = get(tuple.field, item._source); + if (tuple.operator === 'included') { + // only create a signal if the event is not in the value list + if (eventItem != null) { + return !tuple.matchedSet.has(eventItem); } - return false; - }); - const diff = eventSearchResult.hits.hits.length - filteredEvents.length; - logger.debug(buildRuleMessage(`Lists filtered out ${diff} events`)); - return filteredEvents; + return true; + } else if (tuple.operator === 'excluded') { + // only create a signal if the event is in the value list + if (eventItem != null) { + return tuple.matchedSet.has(eventItem); + } + return true; + } + return false; }); - - return (await Promise.all(filteredHitsEntries)).flat(); - } + return vals.some((value) => value); + }); + const diff = eventSearchResult.hits.hits.length - filteredEvents.length; + logger.debug( + buildRuleMessage(`Exception with id ${exceptionItem.id} filtered out ${diff} events`) + ); + const toReturn = filteredEvents; + return toReturn; + }, + Promise.resolve(eventSearchResult.hits.hits) ); - const filteredHits = await Promise.all(filteredHitsPromises); const toReturn: SignalSearchResponse = { took: eventSearchResult.took, timed_out: eventSearchResult.timed_out, _shards: eventSearchResult._shards, hits: { - total: filteredHits.length, + total: res.length, max_score: eventSearchResult.hits.max_score, - hits: filteredHits.flat(), + hits: res, }, }; - return toReturn; } catch (exc) { throw new Error(`Failed to query lists index. Reason: ${exc.message}`); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts index 3312191c3b41..58dcd7f6bd1c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/search_after_bulk_create.test.ts @@ -475,7 +475,7 @@ describe('searchAfterAndBulkCreate', () => { expect(lastLookBackDate).toEqual(new Date('2020-04-20T21:27:45+0000')); // I don't like testing log statements since logs change but this is the best // way I can think of to ensure this section is getting hit with this test case. - expect(((mockLogger.debug as unknown) as jest.Mock).mock.calls[7][0]).toContain( + expect(((mockLogger.debug as unknown) as jest.Mock).mock.calls[8][0]).toContain( 'sortIds was empty on searchResult' ); }); @@ -558,7 +558,7 @@ describe('searchAfterAndBulkCreate', () => { expect(lastLookBackDate).toEqual(new Date('2020-04-20T21:27:45+0000')); // I don't like testing log statements since logs change but this is the best // way I can think of to ensure this section is getting hit with this test case. - expect(((mockLogger.debug as unknown) as jest.Mock).mock.calls[12][0]).toContain( + expect(((mockLogger.debug as unknown) as jest.Mock).mock.calls[15][0]).toContain( 'sortIds was empty on filteredEvents' ); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.ts index 3d4e7384714e..74709f31563e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_bulk_create.ts @@ -83,6 +83,7 @@ export const singleBulkCreate = async ({ throttle, }: SingleBulkCreateParams): Promise => { filteredEvents.hits.hits = filterDuplicateRules(id, filteredEvents); + logger.debug(`about to bulk create ${filteredEvents.hits.hits.length} events`); if (filteredEvents.hits.hits.length === 0) { logger.debug(`all events were duplicates`); return { success: true, createdItemsCount: 0 }; @@ -135,6 +136,8 @@ export const singleBulkCreate = async ({ logger.debug(`took property says bulk took: ${response.took} milliseconds`); if (response.errors) { + const duplicateSignalsCount = countBy(response.items, 'create.status')['409']; + logger.debug(`ignored ${duplicateSignalsCount} duplicate signals`); const errorCountByMessage = errorAggregator(response, [409]); if (!isEmpty(errorCountByMessage)) { logger.error( @@ -144,6 +147,6 @@ export const singleBulkCreate = async ({ } const createdItemsCount = countBy(response.items, 'create.status')['201'] ?? 0; - + logger.debug(`bulk created ${createdItemsCount} signals`); return { success: true, bulkCreateDuration: makeFloatString(end - start), createdItemsCount }; }; From 420102cd34579fc18e44c7a0499d625c969c1433 Mon Sep 17 00:00:00 2001 From: Tre Date: Wed, 22 Jul 2020 10:51:24 -0600 Subject: [PATCH 053/142] [QA][Code Coverage] Add logging for the team assign pipeline name (#72769) so we can tell which ingestion pipe is being used. --- src/dev/code_coverage/ingest_coverage/ingest.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/dev/code_coverage/ingest_coverage/ingest.js b/src/dev/code_coverage/ingest_coverage/ingest.js index 43f0663ad035..31a94d161a3c 100644 --- a/src/dev/code_coverage/ingest_coverage/ingest.js +++ b/src/dev/code_coverage/ingest_coverage/ingest.js @@ -77,6 +77,7 @@ async function send(logF, idx, redactedEsHostUrl, client, requestBody) { const sendMsg = (actuallySent, redactedEsHostUrl, payload) => { const { index, body } = payload; return `### ${actuallySent ? 'Sent' : 'Fake Sent'}: +${payload.pipeline ? `\t### Team Assignment Pipeline: ${green(payload.pipeline)}` : ''} ${redactedEsHostUrl ? `\t### ES Host: ${redactedEsHostUrl}` : ''} \t### Index: ${green(index)} \t### payload.body: ${body} From f974c242ab7da943800c719ae9abf89746fd47a2 Mon Sep 17 00:00:00 2001 From: Patrick Mueller Date: Wed, 22 Jul 2020 13:06:28 -0400 Subject: [PATCH 054/142] [eventLog] fix FT event log tests to filter on event actions (#72445) resolves https://github.com/elastic/kibana/issues/72207 The `getEventLog()` should have been filtering the events returned by the actions requested in the parameters, but wasn't. Also un-skips the describe block that was skipped because of this failure. --- .../common/lib/get_event_log.ts | 11 +++++++---- .../security_and_spaces/tests/alerting/alerts.ts | 3 +-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/x-pack/test/alerting_api_integration/common/lib/get_event_log.ts b/x-pack/test/alerting_api_integration/common/lib/get_event_log.ts index 69eeaafbf64f..99f51ff24454 100644 --- a/x-pack/test/alerting_api_integration/common/lib/get_event_log.ts +++ b/x-pack/test/alerting_api_integration/common/lib/get_event_log.ts @@ -22,6 +22,7 @@ interface GetEventLogParams { export async function getEventLog(params: GetEventLogParams): Promise { const { getService, spaceId, type, id, provider, actions } = params; const supertest = getService('supertest'); + const actionsSet = new Set(actions); const spacePrefix = getUrlPrefix(spaceId); const url = `${spacePrefix}/api/event_log/${type}/${id}/_find`; @@ -31,11 +32,13 @@ export async function getEventLog(params: GetEventLogParams): Promise event?.event?.provider === provider - ); + // filter events to matching provider and requested actions + const events: IValidatedEvent[] = (result.data as IValidatedEvent[]) + .filter((event) => event?.event?.provider === provider) + .filter((event) => event?.event?.action) + .filter((event) => actionsSet.has(event?.event?.action!)); const foundActions = new Set( - events.map((event) => event?.event?.action).filter((event) => !!event) + events.map((event) => event?.event?.action).filter((action) => !!action) ); for (const action of actions) { diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts index ffa9855478a0..8d8bc066a9b1 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts @@ -31,8 +31,7 @@ export default function alertTests({ getService }: FtrProviderContext) { const esTestIndexTool = new ESTestIndexTool(es, retry); const taskManagerUtils = new TaskManagerUtils(es, retry); - // FLAKY: https://github.com/elastic/kibana/issues/72207 - describe.skip('alerts', () => { + describe('alerts', () => { const authorizationIndex = '.kibana-test-authorization'; const objectRemover = new ObjectRemover(supertest); From 83b5c8401bfd76e85532d8cac0c35acf99e224c3 Mon Sep 17 00:00:00 2001 From: Jen Huang Date: Wed, 22 Jul 2020 10:11:30 -0700 Subject: [PATCH 055/142] [Ingest Manager] Handle long agent config & package config names gracefully (#72761) * Truncate name in package config table * Clean up enrollment keys table * Clean up other tables * Handle long agent config names with no spaces * Handle long agent config descriptions without spaces * Fix types, add tooltips/aria labels * Fix types again --- .../components/config_copy_provider.tsx | 16 +-- .../components/confirm_deploy_modal.tsx | 16 +-- .../components/layout.tsx | 4 +- .../package_configs/package_configs_table.tsx | 11 +- .../agent_config/details_page/index.tsx | 4 +- .../sections/agent_config/list_page/index.tsx | 7 +- .../sections/data_stream/list_page/index.tsx | 4 - .../components/agent_details.tsx | 5 +- .../components/agent_events_table.tsx | 7 +- .../fleet/agent_details_page/index.tsx | 1 + .../sections/fleet/agent_list_page/index.tsx | 10 +- .../enrollment_token_list_page/index.tsx | 109 +++++++++++------- 12 files changed, 116 insertions(+), 78 deletions(-) diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/config_copy_provider.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/config_copy_provider.tsx index 9776304797fd..c1bd0846b887 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/config_copy_provider.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/config_copy_provider.tsx @@ -99,13 +99,15 @@ export const AgentConfigCopyProvider: React.FunctionComponent = ({ childr + + + } onCancel={closeModal} onConfirm={copyAgentConfig} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/confirm_deploy_modal.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/confirm_deploy_modal.tsx index a503beeffa8b..51f37f72a751 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/confirm_deploy_modal.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/confirm_deploy_modal.tsx @@ -51,15 +51,17 @@ export const ConfirmDeployConfigModal: React.FunctionComponent<{ }, })} > - + {agentConfig.name}
, - }} - /> + values={{ + configName: {agentConfig.name}, + }} + /> +
- {agentConfig?.name || '-'} + + {agentConfig?.name || '-'} + ) : undefined; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/package_configs/package_configs_table.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/package_configs/package_configs_table.tsx index 4da4e2cc68c9..1aa0fd122083 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/package_configs/package_configs_table.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/package_configs/package_configs_table.tsx @@ -104,6 +104,11 @@ export const PackageConfigsTable: React.FunctionComponent = ({ defaultMessage: 'Name', } ), + render: (value: string) => ( + + {value} + + ), }, { field: 'description', @@ -113,7 +118,11 @@ export const PackageConfigsTable: React.FunctionComponent = ({ defaultMessage: 'Description', } ), - truncateText: true, + render: (value: string) => ( + + {value} + + ), }, { field: 'packageTitle', diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/index.tsx index 4ae16eb91e58..0e65cb80f07c 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/index.tsx @@ -74,7 +74,7 @@ export const AgentConfigDetailsPage: React.FunctionComponent = () => { - +

{(agentConfig && agentConfig.name) || ( { {agentConfig && agentConfig.description ? ( - + {agentConfig.description} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/index.tsx index 4e79bd4fa799..229adb946412 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/list_page/index.tsx @@ -158,10 +158,9 @@ export const AgentConfigListPage: React.FunctionComponent<{}> = () => { defaultMessage: 'Description', }), width: '35%', - truncateText: true, - render: (description: AgentConfig['description']) => ( - - {description} + render: (value: string) => ( + + {value} ), }, diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/data_stream/list_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/data_stream/list_page/index.tsx index a6e458a4615c..39e6d90e64be 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/data_stream/list_page/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/data_stream/list_page/index.tsx @@ -75,7 +75,6 @@ export const DataStreamListPage: React.FunctionComponent<{}> = () => { field: 'dataset', sortable: true, width: '25%', - truncateText: true, name: i18n.translate('xpack.ingestManager.dataStreamList.datasetColumnTitle', { defaultMessage: 'Dataset', }), @@ -83,7 +82,6 @@ export const DataStreamListPage: React.FunctionComponent<{}> = () => { { field: 'type', sortable: true, - truncateText: true, name: i18n.translate('xpack.ingestManager.dataStreamList.typeColumnTitle', { defaultMessage: 'Type', }), @@ -91,7 +89,6 @@ export const DataStreamListPage: React.FunctionComponent<{}> = () => { { field: 'namespace', sortable: true, - truncateText: true, name: i18n.translate('xpack.ingestManager.dataStreamList.namespaceColumnTitle', { defaultMessage: 'Namespace', }), @@ -102,7 +99,6 @@ export const DataStreamListPage: React.FunctionComponent<{}> = () => { { field: 'package', sortable: true, - truncateText: true, name: i18n.translate('xpack.ingestManager.dataStreamList.integrationColumnTitle', { defaultMessage: 'Integration', }), diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/agent_details.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/agent_details.tsx index 03f1a67fe95a..63d93f14c63f 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/agent_details.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/agent_details.tsx @@ -52,7 +52,10 @@ export const AgentDetailsContent: React.FunctionComponent<{ defaultMessage: 'Agent configuration', }), description: agentConfig ? ( - + {agentConfig.name || agent.config_id} ) : ( diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/agent_events_table.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/agent_events_table.tsx index 5be728b88c3e..5806cbdcd681 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/agent_events_table.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/agent_events_table.tsx @@ -153,8 +153,11 @@ export const AgentEventsTable: React.FunctionComponent<{ agent: Agent }> = ({ ag name: i18n.translate('xpack.ingestManager.agentEventsList.messageColumnTitle', { defaultMessage: 'Message', }), - render: (message: string) => {message}, - truncateText: true, + render: (value: string) => ( + + {value} + + ), }, { align: RIGHT_ALIGNMENT, diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/index.tsx index ae9b1e1f6f43..0bd25ac8cf40 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/index.tsx @@ -135,6 +135,7 @@ export const AgentDetailsPage: React.FunctionComponent = () => { ) : agentConfigData?.item ? ( {agentConfigData.item.name || agentData.item.config_id} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/index.tsx index 3743f9b39191..f9c900745425 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/index.tsx @@ -23,7 +23,6 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage, FormattedRelative } from '@kbn/i18n/react'; -import { CSSProperties } from 'styled-components'; import { AgentEnrollmentFlyout } from '../components'; import { Agent, AgentConfig } from '../../../types'; import { @@ -40,11 +39,6 @@ import { AgentStatusKueryHelper } from '../../../services'; import { AGENT_SAVED_OBJECT_TYPE } from '../../../constants'; import { AgentReassignConfigFlyout, AgentHealth, AgentUnenrollProvider } from '../components'; -const NO_WRAP_TRUNCATE_STYLE: CSSProperties = Object.freeze({ - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', -}); const REFRESH_INTERVAL_MS = 5000; const statusFilters = [ @@ -279,10 +273,10 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { const configName = agentConfigs.find((p) => p.id === configId)?.name; return ( - + {configName || configId} diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/enrollment_token_list_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/enrollment_token_list_page/index.tsx index df0862be9a14..6e8796135214 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/enrollment_token_list_page/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/enrollment_token_list_page/index.tsx @@ -6,16 +6,17 @@ import { i18n } from '@kbn/i18n'; import React, { useState } from 'react'; -import { CSSProperties } from 'styled-components'; import { EuiSpacer, EuiBasicTable, EuiFlexGroup, EuiFlexItem, EuiButton, - EuiButtonEmpty, + EuiButtonIcon, + EuiToolTip, EuiIcon, EuiText, + HorizontalAlignment, } from '@elastic/eui'; import { FormattedMessage, FormattedDate } from '@kbn/i18n/react'; import { ENROLLMENT_API_KEYS_SAVED_OBJECT_TYPE } from '../../../constants'; @@ -33,12 +34,6 @@ import { SearchBar } from '../../../components/search_bar'; import { NewEnrollmentTokenFlyout } from './components/new_enrollment_key_flyout'; import { ConfirmEnrollmentTokenDelete } from './components/confirm_delete_modal'; -const NO_WRAP_TRUNCATE_STYLE: CSSProperties = Object.freeze({ - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', -}); - const ApiKeyField: React.FunctionComponent<{ apiKeyId: string }> = ({ apiKeyId }) => { const { notifications } = useCore(); const [state, setState] = useState<'VISIBLE' | 'HIDDEN' | 'LOADING'>('HIDDEN'); @@ -66,24 +61,42 @@ const ApiKeyField: React.FunctionComponent<{ apiKeyId: string }> = ({ apiKeyId } }; return ( - - - {state === 'VISIBLE' ? ( - - {key} - - ) : ( - ••••••••••••••••••••• - )} + + + + {state === 'VISIBLE' + ? key + : '•••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••'} + - + + + ); @@ -120,7 +133,23 @@ const DeleteButton: React.FunctionComponent<{ apiKey: EnrollmentAPIKey; refresh: onConfirm={onConfirm} /> )} - setState('CONFIRM_VISIBLE')} iconType="trash" color="danger" /> + + setState('CONFIRM_VISIBLE')} + iconType="trash" + color="danger" + /> + ); }; @@ -152,15 +181,11 @@ export const EnrollmentTokenListPage: React.FunctionComponent<{}> = () => { name: i18n.translate('xpack.ingestManager.enrollmentTokensList.nameTitle', { defaultMessage: 'Name', }), - truncateText: true, - textOnly: true, - render: (name: string) => { - return ( - - {name} - - ); - }, + render: (value: string) => ( + + {value} + + ), }, { field: 'id', @@ -179,7 +204,12 @@ export const EnrollmentTokenListPage: React.FunctionComponent<{}> = () => { }), render: (configId: string) => { const config = agentConfigs.find((c) => c.id === configId); - return <>{config ? config.name : configId}; + const value = config ? config.name : configId; + return ( + + {value} + + ); }, }, { @@ -200,12 +230,9 @@ export const EnrollmentTokenListPage: React.FunctionComponent<{}> = () => { defaultMessage: 'Active', }), width: '70px', + align: 'center' as HorizontalAlignment, render: (active: boolean) => { - return ( - - - - ); + return ; }, }, { @@ -242,7 +269,7 @@ export const EnrollmentTokenListPage: React.FunctionComponent<{}> = () => { /> - + Date: Wed, 22 Jul 2020 13:14:03 -0400 Subject: [PATCH 056/142] [ML] DF Analytics creation wizard: default destination index to job id (#72758) * wip: add destIndexSameAsId checkbox * update functional tests * switch default to false when cloned job * move switch below description --- .../details_step/details_step_form.tsx | 131 +++++++++++------- .../classification_creation.ts | 8 +- .../outlier_detection_creation.ts | 8 +- .../regression_creation.ts | 8 +- .../ml/data_frame_analytics_creation.ts | 33 ++++- 5 files changed, 136 insertions(+), 52 deletions(-) diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/details_step/details_step_form.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/details_step/details_step_form.tsx index 8442ca13910d..0ac237bb33e7 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/details_step/details_step_form.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/details_step/details_step_form.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FC, Fragment, useRef, useEffect } from 'react'; +import React, { FC, Fragment, useRef, useEffect, useState } from 'react'; import { debounce } from 'lodash'; import { EuiFieldText, @@ -25,6 +25,14 @@ import { ANALYTICS_STEPS } from '../../page'; import { ml } from '../../../../../services/ml_api_service'; import { extractErrorMessage } from '../../../../../../../common/util/errors'; +const indexNameExistsMessage = i18n.translate( + 'xpack.ml.dataframe.analytics.create.destinationIndexHelpText', + { + defaultMessage: + 'An index with this name already exists. Be aware that running this analytics job will modify this destination index.', + } +); + export const DetailsStepForm: FC = ({ actions, state, @@ -36,7 +44,7 @@ export const DetailsStepForm: FC = ({ const { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } = docLinks; const { setFormState } = actions; - const { form, isJobCreated } = state; + const { form, cloneJob, isJobCreated } = state; const { createIndexPattern, description, @@ -52,6 +60,9 @@ export const DetailsStepForm: FC = ({ jobIdValid, resultsField, } = form; + + const [destIndexSameAsId, setDestIndexSameAsId] = useState(cloneJob === undefined); + const forceInput = useRef(null); const isStepInvalid = @@ -88,6 +99,14 @@ export const DetailsStepForm: FC = ({ }; }, [destinationIndex]); + useEffect(() => { + if (destIndexSameAsId === true && !jobIdEmpty && jobIdValid) { + setFormState({ destinationIndex: jobId }); + } else if (destIndexSameAsId === false) { + setFormState({ destinationIndex: '' }); + } + }, [destIndexSameAsId, jobId]); + return ( = ({ - {i18n.translate('xpack.ml.dataframe.analytics.create.destinationIndexInvalidError', { - defaultMessage: 'Invalid destination index name.', - })} -
- - {i18n.translate( - 'xpack.ml.dataframe.stepDetailsForm.destinationIndexInvalidErrorLink', - { - defaultMessage: 'Learn more about index name limitations.', - } - )} - -
, - ] + destIndexSameAsId === true && destinationIndexNameExists && indexNameExistsMessage } > - setFormState({ destinationIndex: e.target.value })} - aria-label={i18n.translate( - 'xpack.ml.dataframe.analytics.create.destinationIndexInputAriaLabel', - { - defaultMessage: 'Choose a unique destination index name.', - } - )} - isInvalid={!destinationIndexNameEmpty && !destinationIndexNameValid} - data-test-subj="mlAnalyticsCreateJobFlyoutDestinationIndexInput" + name="mlDataFrameAnalyticsDestIndexSameAsId" + label={i18n.translate('xpack.ml.dataframe.analytics.create.DestIndexSameAsIdLabel', { + defaultMessage: 'Destination index same as job ID', + })} + checked={destIndexSameAsId === true} + onChange={() => setDestIndexSameAsId(!destIndexSameAsId)} + data-test-subj="mlAnalyticsCreateJobWizardDestIndexSameAsIdSwitch" /> + {destIndexSameAsId === false && ( + + {i18n.translate( + 'xpack.ml.dataframe.analytics.create.destinationIndexInvalidError', + { + defaultMessage: 'Invalid destination index name.', + } + )} +
+ + {i18n.translate( + 'xpack.ml.dataframe.stepDetailsForm.destinationIndexInvalidErrorLink', + { + defaultMessage: 'Learn more about index name limitations.', + } + )} + + , + ] + } + > + setFormState({ destinationIndex: e.target.value })} + aria-label={i18n.translate( + 'xpack.ml.dataframe.analytics.create.destinationIndexInputAriaLabel', + { + defaultMessage: 'Choose a unique destination index name.', + } + )} + isInvalid={!destinationIndexNameEmpty && !destinationIndexNameValid} + data-test-subj="mlAnalyticsCreateJobFlyoutDestinationIndexInput" + /> +
+ )} { + it('should default the set destination index to job id switch to true', async () => { + await ml.dataFrameAnalyticsCreation.assertDestIndexSameAsIdSwitchExists(); + await ml.dataFrameAnalyticsCreation.assertDestIndexSameAsIdCheckState(true); + }); + + it('should input the destination index', async () => { + await ml.dataFrameAnalyticsCreation.setDestIndexSameAsIdCheckState(false); await ml.dataFrameAnalyticsCreation.assertDestIndexInputExists(); await ml.dataFrameAnalyticsCreation.setDestIndex(testData.destinationIndex); }); diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation.ts index 4ae93296f9be..0320354b99ff 100644 --- a/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation.ts +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation.ts @@ -133,7 +133,13 @@ export default function ({ getService }: FtrProviderContext) { await ml.dataFrameAnalyticsCreation.setJobDescription(testData.jobDescription); }); - it('inputs the destination index', async () => { + it('should default the set destination index to job id switch to true', async () => { + await ml.dataFrameAnalyticsCreation.assertDestIndexSameAsIdSwitchExists(); + await ml.dataFrameAnalyticsCreation.assertDestIndexSameAsIdCheckState(true); + }); + + it('should input the destination index', async () => { + await ml.dataFrameAnalyticsCreation.setDestIndexSameAsIdCheckState(false); await ml.dataFrameAnalyticsCreation.assertDestIndexInputExists(); await ml.dataFrameAnalyticsCreation.setDestIndex(testData.destinationIndex); }); diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation.ts index 03117d4cc419..1aa505e26e1e 100644 --- a/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation.ts +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation.ts @@ -115,7 +115,13 @@ export default function ({ getService }: FtrProviderContext) { await ml.dataFrameAnalyticsCreation.setJobDescription(testData.jobDescription); }); - it('inputs the destination index', async () => { + it('should default the set destination index to job id switch to true', async () => { + await ml.dataFrameAnalyticsCreation.assertDestIndexSameAsIdSwitchExists(); + await ml.dataFrameAnalyticsCreation.assertDestIndexSameAsIdCheckState(true); + }); + + it('should input the destination index', async () => { + await ml.dataFrameAnalyticsCreation.setDestIndexSameAsIdCheckState(false); await ml.dataFrameAnalyticsCreation.assertDestIndexInputExists(); await ml.dataFrameAnalyticsCreation.setDestIndex(testData.destinationIndex); }); diff --git a/x-pack/test/functional/services/ml/data_frame_analytics_creation.ts b/x-pack/test/functional/services/ml/data_frame_analytics_creation.ts index e36855a4e769..5f3d21b80a83 100644 --- a/x-pack/test/functional/services/ml/data_frame_analytics_creation.ts +++ b/x-pack/test/functional/services/ml/data_frame_analytics_creation.ts @@ -199,7 +199,9 @@ export function MachineLearningDataFrameAnalyticsCreationProvider( // }, async assertDestIndexInputExists() { - await testSubjects.existOrFail('mlAnalyticsCreateJobFlyoutDestinationIndexInput'); + await retry.tryForTime(4000, async () => { + await testSubjects.existOrFail('mlAnalyticsCreateJobFlyoutDestinationIndexInput'); + }); }, async assertDestIndexValue(expectedValue: string) { @@ -417,6 +419,35 @@ export function MachineLearningDataFrameAnalyticsCreationProvider( ); }, + async getDestIndexSameAsIdSwitchCheckState(): Promise { + const state = await testSubjects.getAttribute( + 'mlAnalyticsCreateJobWizardDestIndexSameAsIdSwitch', + 'aria-checked' + ); + return state === 'true'; + }, + + async assertDestIndexSameAsIdCheckState(expectedCheckState: boolean) { + const actualCheckState = await this.getDestIndexSameAsIdSwitchCheckState(); + expect(actualCheckState).to.eql( + expectedCheckState, + `Destination index same as job id check state should be '${expectedCheckState}' (got '${actualCheckState}')` + ); + }, + + async assertDestIndexSameAsIdSwitchExists() { + await testSubjects.existOrFail(`mlAnalyticsCreateJobWizardDestIndexSameAsIdSwitch`, { + allowHidden: true, + }); + }, + + async setDestIndexSameAsIdCheckState(checkState: boolean) { + if ((await this.getDestIndexSameAsIdSwitchCheckState()) !== checkState) { + await testSubjects.click('mlAnalyticsCreateJobWizardDestIndexSameAsIdSwitch'); + } + await this.assertDestIndexSameAsIdCheckState(checkState); + }, + async setCreateIndexPatternSwitchState(checkState: boolean) { if ((await this.getCreateIndexPatternSwitchCheckState()) !== checkState) { await testSubjects.click('mlAnalyticsCreateJobWizardCreateIndexPatternSwitch'); From 8f7ccc752b91f9e42f49bc69eaaab892e5e9113a Mon Sep 17 00:00:00 2001 From: Dmitry Lemeshko Date: Wed, 22 Jul 2020 19:28:39 +0200 Subject: [PATCH 057/142] [QA] [Code Coverage] Fix maps functional test (#72848) * [test/functional] wait for rendering in maps test * move waitForRender in openNewMap --- x-pack/test/functional/page_objects/gis_page.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/x-pack/test/functional/page_objects/gis_page.js b/x-pack/test/functional/page_objects/gis_page.js index 8a0b4aaefa88..b8f4faf3ebfd 100644 --- a/x-pack/test/functional/page_objects/gis_page.js +++ b/x-pack/test/functional/page_objects/gis_page.js @@ -17,6 +17,7 @@ export function GisPageProvider({ getService, getPageObjects }) { const find = getService('find'); const queryBar = getService('queryBar'); const comboBox = getService('comboBox'); + const renderable = getService('renderable'); function escapeLayerName(layerName) { return layerName.split(' ').join('_'); @@ -135,6 +136,7 @@ export function GisPageProvider({ getService, getPageObjects }) { // Navigate directly because we don't need to go through the map listing // page. The listing page is skipped if there are no saved objects await PageObjects.common.navigateToUrlWithBrowserHistory(APP_ID, '/map'); + await renderable.waitForRender(); } async saveMap(name) { From 90c8406dcf8253e91be4a3a7d1fb96ae036eabda Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Wed, 22 Jul 2020 13:29:44 -0400 Subject: [PATCH 058/142] [Ingest Manager] Use docker registry for fleet api integration tests (#72621) --- x-pack/plugins/ingest_manager/README.md | 57 ++---------- .../apis/endpoint/artifacts/index.ts | 5 +- x-pack/test/api_integration/apis/index.js | 2 - .../apis/agent_config}/agent_config.ts | 2 +- .../apis/agent_config}/index.js | 0 .../apis/fleet/agents/acks.ts | 3 +- .../apis/fleet/agents/actions.ts | 3 +- .../apis/fleet/agents/checkin.ts | 4 +- .../apis/fleet/agents/complete_flow.ts} | 6 +- .../apis/fleet/agents/delete.ts} | 2 +- .../apis/fleet/agents/enroll.ts | 7 +- .../apis/fleet/agents/events.ts | 2 +- .../apis/fleet/agents/list.ts} | 2 +- .../apis/fleet/agents/services.ts | 2 +- .../apis/fleet/agents/unenroll.ts} | 6 +- .../apis/fleet/enrollment_api_keys/crud.ts | 8 +- .../apis/fleet/index.js | 8 +- .../apis/fleet/install.ts | 2 +- .../apis/fleet/setup.ts | 7 +- .../apis/index.js | 5 + .../apis/package_config/update.ts | 92 +++++++++---------- .../ingest_manager_api_integration/config.ts | 3 + .../ingest_manager_api_integration/helpers.ts | 15 +++ 23 files changed, 119 insertions(+), 124 deletions(-) rename x-pack/test/{api_integration/apis/ingest_manager => ingest_manager_api_integration/apis/agent_config}/agent_config.ts (97%) rename x-pack/test/{api_integration/apis/ingest_manager => ingest_manager_api_integration/apis/agent_config}/index.js (100%) rename x-pack/test/{api_integration => ingest_manager_api_integration}/apis/fleet/agents/acks.ts (98%) rename x-pack/test/{api_integration => ingest_manager_api_integration}/apis/fleet/agents/actions.ts (96%) rename x-pack/test/{api_integration => ingest_manager_api_integration}/apis/fleet/agents/checkin.ts (94%) rename x-pack/test/{api_integration/apis/fleet/agent_flow.ts => ingest_manager_api_integration/apis/fleet/agents/complete_flow.ts} (96%) rename x-pack/test/{api_integration/apis/fleet/delete_agent.ts => ingest_manager_api_integration/apis/fleet/agents/delete.ts} (97%) rename x-pack/test/{api_integration => ingest_manager_api_integration}/apis/fleet/agents/enroll.ts (96%) rename x-pack/test/{api_integration => ingest_manager_api_integration}/apis/fleet/agents/events.ts (93%) rename x-pack/test/{api_integration/apis/fleet/list_agent.ts => ingest_manager_api_integration/apis/fleet/agents/list.ts} (97%) rename x-pack/test/{api_integration => ingest_manager_api_integration}/apis/fleet/agents/services.ts (94%) rename x-pack/test/{api_integration/apis/fleet/unenroll_agent.ts => ingest_manager_api_integration/apis/fleet/agents/unenroll.ts} (93%) rename x-pack/test/{api_integration => ingest_manager_api_integration}/apis/fleet/enrollment_api_keys/crud.ts (95%) rename x-pack/test/{api_integration => ingest_manager_api_integration}/apis/fleet/index.js (77%) rename x-pack/test/{api_integration => ingest_manager_api_integration}/apis/fleet/install.ts (92%) rename x-pack/test/{api_integration => ingest_manager_api_integration}/apis/fleet/setup.ts (92%) diff --git a/x-pack/plugins/ingest_manager/README.md b/x-pack/plugins/ingest_manager/README.md index a523ddeb7c49..9fd23e3d41dd 100644 --- a/x-pack/plugins/ingest_manager/README.md +++ b/x-pack/plugins/ingest_manager/README.md @@ -45,63 +45,26 @@ One common development workflow is: This plugin follows the `common`, `server`, `public` structure from the [Architecture Style Guide ](https://github.com/elastic/kibana/blob/master/style_guides/architecture_style_guide.md#file-and-folder-structure). We also follow the pattern of developing feature branches under your personal fork of Kibana. -### API Tests +### Tests -#### Ingest & Fleet +#### API integration tests -1. In one terminal, change to the `x-pack` directory and start the test server with +You need to have `docker` to run ingest manager api integration tests - ``` - node scripts/functional_tests_server.js --config test/api_integration/config.ts - ``` +1. In one terminal, run the tests from the Kibana root directory with -1. in a second terminal, run the tests from the Kibana root directory with ``` - node scripts/functional_test_runner.js --config x-pack/test/api_integration/config.ts + INGEST_MANAGEMENT_PACKAGE_REGISTRY_PORT=12345 yarn test:ftr:server --config x-pack/test/ingest_manager_api_integration/config.ts ``` -#### EPM - -1. In one terminal, change to the `x-pack` directory and start the test server with +1. in a second terminal, run the tests from the Kibana root directory with ``` - node scripts/functional_tests_server.js --config test/epm_api_integration/config.ts + INGEST_MANAGEMENT_PACKAGE_REGISTRY_PORT=12345 yarn test:ftr:runner --config x-pack/test/ingest_manager_api_integration/config.ts ``` -1. in a second terminal, run the tests from the Kibana root directory with + Optionally you can filter which tests you want to run using `--grep` + ``` - node scripts/functional_test_runner.js --config x-pack/test/epm_api_integration/config.ts + INGEST_MANAGEMENT_PACKAGE_REGISTRY_PORT=12345 yarn test:ftr:runner --config x-pack/test/ingest_manager_api_integration/config.ts --grep='fleet' ``` - -### Staying up-to-date with `master` - -While we're developing in the `feature-ingest` feature branch, here's is more information on keeping up to date with upstream kibana. - -
- merge upstream master into feature-ingest - -```bash -## checkout feature branch to your fork -git checkout -B feature-ingest origin/feature-ingest - -## make sure your feature branch is current with upstream feature branch -git pull upstream feature-ingest - -## pull in changes from upstream master -git pull upstream master - -## push changes to your remote -git push origin - -# /!\ Open a DRAFT PR /!\ -# Normal PRs will re-notify authors of commits already merged -# Draft PR will trigger CI run. Once CI is green ... -# /!\ DO NOT USE THE GITHUB UI TO MERGE THE PR /!\ - -## push your changes to upstream feature branch from the terminal; not GitHub UI -git push upstream -``` - -
- -See https://github.com/elastic/kibana/pull/37950 for an example. diff --git a/x-pack/test/api_integration/apis/endpoint/artifacts/index.ts b/x-pack/test/api_integration/apis/endpoint/artifacts/index.ts index ba68b9b7ba6e..b37522ed52b5 100644 --- a/x-pack/test/api_integration/apis/endpoint/artifacts/index.ts +++ b/x-pack/test/api_integration/apis/endpoint/artifacts/index.ts @@ -9,7 +9,10 @@ import { createHash } from 'crypto'; import { inflateSync } from 'zlib'; import { FtrProviderContext } from '../../../ftr_provider_context'; -import { getSupertestWithoutAuth, setupIngest } from '../../fleet/agents/services'; +import { + getSupertestWithoutAuth, + setupIngest, +} from '../../../../ingest_manager_api_integration/apis/fleet/agents/services'; export default function (providerContext: FtrProviderContext) { const { getService } = providerContext; diff --git a/x-pack/test/api_integration/apis/index.js b/x-pack/test/api_integration/apis/index.js index ce0e534d8a75..05b305ccd833 100644 --- a/x-pack/test/api_integration/apis/index.js +++ b/x-pack/test/api_integration/apis/index.js @@ -26,11 +26,9 @@ export default function ({ loadTestFile }) { loadTestFile(require.resolve('./security_solution')); loadTestFile(require.resolve('./short_urls')); loadTestFile(require.resolve('./lens')); - loadTestFile(require.resolve('./fleet')); loadTestFile(require.resolve('./ml')); loadTestFile(require.resolve('./transform')); loadTestFile(require.resolve('./endpoint')); - loadTestFile(require.resolve('./ingest_manager')); loadTestFile(require.resolve('./lists')); loadTestFile(require.resolve('./upgrade_assistant')); }); diff --git a/x-pack/test/api_integration/apis/ingest_manager/agent_config.ts b/x-pack/test/ingest_manager_api_integration/apis/agent_config/agent_config.ts similarity index 97% rename from x-pack/test/api_integration/apis/ingest_manager/agent_config.ts rename to x-pack/test/ingest_manager_api_integration/apis/agent_config/agent_config.ts index 8bf3efbdaf50..89258600c85e 100644 --- a/x-pack/test/api_integration/apis/ingest_manager/agent_config.ts +++ b/x-pack/test/ingest_manager_api_integration/apis/agent_config/agent_config.ts @@ -5,7 +5,7 @@ */ import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../ftr_provider_context'; +import { FtrProviderContext } from '../../../api_integration/ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); diff --git a/x-pack/test/api_integration/apis/ingest_manager/index.js b/x-pack/test/ingest_manager_api_integration/apis/agent_config/index.js similarity index 100% rename from x-pack/test/api_integration/apis/ingest_manager/index.js rename to x-pack/test/ingest_manager_api_integration/apis/agent_config/index.js diff --git a/x-pack/test/api_integration/apis/fleet/agents/acks.ts b/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/acks.ts similarity index 98% rename from x-pack/test/api_integration/apis/fleet/agents/acks.ts rename to x-pack/test/ingest_manager_api_integration/apis/fleet/agents/acks.ts index a040ef20081a..c9fa80c88762 100644 --- a/x-pack/test/api_integration/apis/fleet/agents/acks.ts +++ b/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/acks.ts @@ -6,8 +6,7 @@ import expect from '@kbn/expect'; import uuid from 'uuid'; - -import { FtrProviderContext } from '../../../ftr_provider_context'; +import { FtrProviderContext } from '../../../../api_integration/ftr_provider_context'; import { getSupertestWithoutAuth } from './services'; export default function (providerContext: FtrProviderContext) { diff --git a/x-pack/test/api_integration/apis/fleet/agents/actions.ts b/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/actions.ts similarity index 96% rename from x-pack/test/api_integration/apis/fleet/agents/actions.ts rename to x-pack/test/ingest_manager_api_integration/apis/fleet/agents/actions.ts index c0b2aedf5c24..8dc4e5c232b8 100644 --- a/x-pack/test/api_integration/apis/fleet/agents/actions.ts +++ b/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/actions.ts @@ -5,8 +5,7 @@ */ import expect from '@kbn/expect'; - -import { FtrProviderContext } from '../../../ftr_provider_context'; +import { FtrProviderContext } from '../../../../api_integration/ftr_provider_context'; export default function (providerContext: FtrProviderContext) { const { getService } = providerContext; diff --git a/x-pack/test/api_integration/apis/fleet/agents/checkin.ts b/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/checkin.ts similarity index 94% rename from x-pack/test/api_integration/apis/fleet/agents/checkin.ts rename to x-pack/test/ingest_manager_api_integration/apis/fleet/agents/checkin.ts index 70147f602e9c..79f6cfae175e 100644 --- a/x-pack/test/api_integration/apis/fleet/agents/checkin.ts +++ b/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/checkin.ts @@ -7,8 +7,9 @@ import expect from '@kbn/expect'; import uuid from 'uuid'; -import { FtrProviderContext } from '../../../ftr_provider_context'; +import { FtrProviderContext } from '../../../../api_integration/ftr_provider_context'; import { getSupertestWithoutAuth, setupIngest } from './services'; +import { skipIfNoDockerRegistry } from '../../../helpers'; export default function (providerContext: FtrProviderContext) { const { getService } = providerContext; @@ -19,6 +20,7 @@ export default function (providerContext: FtrProviderContext) { let apiKey: { id: string; api_key: string }; describe('fleet_agents_checkin', () => { + skipIfNoDockerRegistry(providerContext); before(async () => { await esArchiver.loadIfNeeded('fleet/agents'); diff --git a/x-pack/test/api_integration/apis/fleet/agent_flow.ts b/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/complete_flow.ts similarity index 96% rename from x-pack/test/api_integration/apis/fleet/agent_flow.ts rename to x-pack/test/ingest_manager_api_integration/apis/fleet/agents/complete_flow.ts index da472ca912d4..8d7472f0ecd8 100644 --- a/x-pack/test/api_integration/apis/fleet/agent_flow.ts +++ b/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/complete_flow.ts @@ -6,8 +6,9 @@ import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../ftr_provider_context'; -import { setupIngest, getSupertestWithoutAuth } from './agents/services'; +import { FtrProviderContext } from '../../../../api_integration/ftr_provider_context'; +import { setupIngest, getSupertestWithoutAuth } from './services'; +import { skipIfNoDockerRegistry } from '../../../helpers'; export default function (providerContext: FtrProviderContext) { const { getService } = providerContext; @@ -19,6 +20,7 @@ export default function (providerContext: FtrProviderContext) { const esClient = getService('es'); describe('fleet_agent_flow', () => { + skipIfNoDockerRegistry(providerContext); before(async () => { await esArchiver.load('empty_kibana'); }); diff --git a/x-pack/test/api_integration/apis/fleet/delete_agent.ts b/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/delete.ts similarity index 97% rename from x-pack/test/api_integration/apis/fleet/delete_agent.ts rename to x-pack/test/ingest_manager_api_integration/apis/fleet/agents/delete.ts index eefdc35338cb..dc05b7a4dd79 100644 --- a/x-pack/test/api_integration/apis/fleet/delete_agent.ts +++ b/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/delete.ts @@ -5,7 +5,7 @@ */ import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../ftr_provider_context'; +import { FtrProviderContext } from '../../../../api_integration/ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); diff --git a/x-pack/test/api_integration/apis/fleet/agents/enroll.ts b/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/enroll.ts similarity index 96% rename from x-pack/test/api_integration/apis/fleet/agents/enroll.ts rename to x-pack/test/ingest_manager_api_integration/apis/fleet/agents/enroll.ts index 58440a34457d..ef9f2b2e6150 100644 --- a/x-pack/test/api_integration/apis/fleet/agents/enroll.ts +++ b/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/enroll.ts @@ -7,8 +7,9 @@ import expect from '@kbn/expect'; import uuid from 'uuid'; -import { FtrProviderContext } from '../../../ftr_provider_context'; +import { FtrProviderContext } from '../../../../api_integration/ftr_provider_context'; import { getSupertestWithoutAuth, setupIngest, getEsClientForAPIKey } from './services'; +import { skipIfNoDockerRegistry } from '../../../helpers'; export default function (providerContext: FtrProviderContext) { const { getService } = providerContext; @@ -21,8 +22,8 @@ export default function (providerContext: FtrProviderContext) { let apiKey: { id: string; api_key: string }; let kibanaVersion: string; - // Flaky: https://github.com/elastic/kibana/issues/60865 - describe.skip('fleet_agents_enroll', () => { + describe('fleet_agents_enroll', () => { + skipIfNoDockerRegistry(providerContext); before(async () => { await esArchiver.loadIfNeeded('fleet/agents'); diff --git a/x-pack/test/api_integration/apis/fleet/agents/events.ts b/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/events.ts similarity index 93% rename from x-pack/test/api_integration/apis/fleet/agents/events.ts rename to x-pack/test/ingest_manager_api_integration/apis/fleet/agents/events.ts index 44fc4389cab3..93147091dc43 100644 --- a/x-pack/test/api_integration/apis/fleet/agents/events.ts +++ b/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/events.ts @@ -6,7 +6,7 @@ import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../../ftr_provider_context'; +import { FtrProviderContext } from '../../../../api_integration/ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); diff --git a/x-pack/test/api_integration/apis/fleet/list_agent.ts b/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/list.ts similarity index 97% rename from x-pack/test/api_integration/apis/fleet/list_agent.ts rename to x-pack/test/ingest_manager_api_integration/apis/fleet/agents/list.ts index 59ecb8f2579b..23563c6f43bb 100644 --- a/x-pack/test/api_integration/apis/fleet/list_agent.ts +++ b/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/list.ts @@ -6,7 +6,7 @@ import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../ftr_provider_context'; +import { FtrProviderContext } from '../../../../api_integration/ftr_provider_context'; export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); diff --git a/x-pack/test/api_integration/apis/fleet/agents/services.ts b/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/services.ts similarity index 94% rename from x-pack/test/api_integration/apis/fleet/agents/services.ts rename to x-pack/test/ingest_manager_api_integration/apis/fleet/agents/services.ts index 86c5fb5032c7..70d59ecc0b0d 100644 --- a/x-pack/test/api_integration/apis/fleet/agents/services.ts +++ b/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/services.ts @@ -8,7 +8,7 @@ import supertestAsPromised from 'supertest-as-promised'; import { Client } from '@elastic/elasticsearch'; import { format as formatUrl } from 'url'; -import { FtrProviderContext } from '../../../ftr_provider_context'; +import { FtrProviderContext } from '../../../../api_integration/ftr_provider_context'; export function getSupertestWithoutAuth({ getService }: FtrProviderContext) { const config = getService('config'); diff --git a/x-pack/test/api_integration/apis/fleet/unenroll_agent.ts b/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/unenroll.ts similarity index 93% rename from x-pack/test/api_integration/apis/fleet/unenroll_agent.ts rename to x-pack/test/ingest_manager_api_integration/apis/fleet/agents/unenroll.ts index bbbce3314e4c..d1ff8731183b 100644 --- a/x-pack/test/api_integration/apis/fleet/unenroll_agent.ts +++ b/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/unenroll.ts @@ -7,8 +7,9 @@ import expect from '@kbn/expect'; import uuid from 'uuid'; -import { FtrProviderContext } from '../../ftr_provider_context'; -import { setupIngest } from './agents/services'; +import { FtrProviderContext } from '../../../../api_integration/ftr_provider_context'; +import { setupIngest } from './services'; +import { skipIfNoDockerRegistry } from '../../../helpers'; export default function (providerContext: FtrProviderContext) { const { getService } = providerContext; @@ -17,6 +18,7 @@ export default function (providerContext: FtrProviderContext) { const esClient = getService('es'); describe('fleet_unenroll_agent', () => { + skipIfNoDockerRegistry(providerContext); let accessAPIKeyId: string; let outputAPIKeyId: string; before(async () => { diff --git a/x-pack/test/api_integration/apis/fleet/enrollment_api_keys/crud.ts b/x-pack/test/ingest_manager_api_integration/apis/fleet/enrollment_api_keys/crud.ts similarity index 95% rename from x-pack/test/api_integration/apis/fleet/enrollment_api_keys/crud.ts rename to x-pack/test/ingest_manager_api_integration/apis/fleet/enrollment_api_keys/crud.ts index e9685d663aac..bc9182627326 100644 --- a/x-pack/test/api_integration/apis/fleet/enrollment_api_keys/crud.ts +++ b/x-pack/test/ingest_manager_api_integration/apis/fleet/enrollment_api_keys/crud.ts @@ -6,8 +6,9 @@ import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../../ftr_provider_context'; +import { FtrProviderContext } from '../../../../api_integration/ftr_provider_context'; import { setupIngest, getEsClientForAPIKey } from '../agents/services'; +import { skipIfNoDockerRegistry } from '../../../helpers'; const ENROLLMENT_KEY_ID = 'ed22ca17-e178-4cfe-8b02-54ea29fbd6d0'; @@ -21,11 +22,14 @@ export default function (providerContext: FtrProviderContext) { before(async () => { await esArchiver.loadIfNeeded('fleet/agents'); }); - setupIngest({ getService } as FtrProviderContext); + after(async () => { await esArchiver.unload('fleet/agents'); }); + skipIfNoDockerRegistry(providerContext); + setupIngest(providerContext); + describe('GET /fleet/enrollment-api-keys', async () => { it('should list existing api keys', async () => { const { body: apiResponse } = await supertest diff --git a/x-pack/test/api_integration/apis/fleet/index.js b/x-pack/test/ingest_manager_api_integration/apis/fleet/index.js similarity index 77% rename from x-pack/test/api_integration/apis/fleet/index.js rename to x-pack/test/ingest_manager_api_integration/apis/fleet/index.js index df81b826132a..3a72fe6d9f12 100644 --- a/x-pack/test/api_integration/apis/fleet/index.js +++ b/x-pack/test/ingest_manager_api_integration/apis/fleet/index.js @@ -7,16 +7,16 @@ export default function loadTests({ loadTestFile }) { describe('Fleet Endpoints', () => { loadTestFile(require.resolve('./setup')); - loadTestFile(require.resolve('./delete_agent')); - loadTestFile(require.resolve('./list_agent')); - loadTestFile(require.resolve('./unenroll_agent')); + loadTestFile(require.resolve('./agents/delete')); + loadTestFile(require.resolve('./agents/list')); loadTestFile(require.resolve('./agents/enroll')); + loadTestFile(require.resolve('./agents/unenroll')); loadTestFile(require.resolve('./agents/checkin')); loadTestFile(require.resolve('./agents/events')); loadTestFile(require.resolve('./agents/acks')); + loadTestFile(require.resolve('./agents/complete_flow')); loadTestFile(require.resolve('./enrollment_api_keys/crud')); loadTestFile(require.resolve('./install')); loadTestFile(require.resolve('./agents/actions')); - loadTestFile(require.resolve('./agent_flow')); }); } diff --git a/x-pack/test/api_integration/apis/fleet/install.ts b/x-pack/test/ingest_manager_api_integration/apis/fleet/install.ts similarity index 92% rename from x-pack/test/api_integration/apis/fleet/install.ts rename to x-pack/test/ingest_manager_api_integration/apis/fleet/install.ts index 59b040e30fb4..98758ae3ac65 100644 --- a/x-pack/test/api_integration/apis/fleet/install.ts +++ b/x-pack/test/ingest_manager_api_integration/apis/fleet/install.ts @@ -5,7 +5,7 @@ */ import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../ftr_provider_context'; +import { FtrProviderContext } from '../../../api_integration/ftr_provider_context'; import { setupIngest } from './agents/services'; export default function (providerContext: FtrProviderContext) { diff --git a/x-pack/test/api_integration/apis/fleet/setup.ts b/x-pack/test/ingest_manager_api_integration/apis/fleet/setup.ts similarity index 92% rename from x-pack/test/api_integration/apis/fleet/setup.ts rename to x-pack/test/ingest_manager_api_integration/apis/fleet/setup.ts index 4fcf39886e20..64c014dc6fb3 100644 --- a/x-pack/test/api_integration/apis/fleet/setup.ts +++ b/x-pack/test/ingest_manager_api_integration/apis/fleet/setup.ts @@ -5,13 +5,16 @@ */ import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../ftr_provider_context'; +import { FtrProviderContext } from '../../../api_integration/ftr_provider_context'; +import { skipIfNoDockerRegistry } from '../../helpers'; -export default function ({ getService }: FtrProviderContext) { +export default function (providerContext: FtrProviderContext) { + const { getService } = providerContext; const supertest = getService('supertest'); const es = getService('es'); describe('fleet_setup', () => { + skipIfNoDockerRegistry(providerContext); beforeEach(async () => { try { await es.security.deleteUser({ diff --git a/x-pack/test/ingest_manager_api_integration/apis/index.js b/x-pack/test/ingest_manager_api_integration/apis/index.js index 81848917f9b0..c0c8ce3ff082 100644 --- a/x-pack/test/ingest_manager_api_integration/apis/index.js +++ b/x-pack/test/ingest_manager_api_integration/apis/index.js @@ -8,6 +8,9 @@ export default function ({ loadTestFile }) { describe('Ingest Manager Endpoints', function () { this.tags('ciGroup7'); + // Fleet + loadTestFile(require.resolve('./fleet/index')); + // EPM loadTestFile(require.resolve('./epm/list')); loadTestFile(require.resolve('./epm/file')); @@ -18,5 +21,7 @@ export default function ({ loadTestFile }) { // Package configs loadTestFile(require.resolve('./package_config/create')); loadTestFile(require.resolve('./package_config/update')); + // Agent config + loadTestFile(require.resolve('./agent_config/index')); }); } diff --git a/x-pack/test/ingest_manager_api_integration/apis/package_config/update.ts b/x-pack/test/ingest_manager_api_integration/apis/package_config/update.ts index 0251fef5f767..7b0ad4f524ba 100644 --- a/x-pack/test/ingest_manager_api_integration/apis/package_config/update.ts +++ b/x-pack/test/ingest_manager_api_integration/apis/package_config/update.ts @@ -6,10 +6,10 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../api_integration/ftr_provider_context'; -import { warnAndSkipTest } from '../../helpers'; +import { skipIfNoDockerRegistry } from '../../helpers'; -export default function ({ getService }: FtrProviderContext) { - const log = getService('log'); +export default function (providerContext: FtrProviderContext) { + const { getService } = providerContext; const supertest = getService('supertest'); const dockerServers = getService('dockerServers'); @@ -19,11 +19,15 @@ export default function ({ getService }: FtrProviderContext) { // see https://mochajs.org/#arrow-functions describe('Package Config - update', async function () { + skipIfNoDockerRegistry(providerContext); let agentConfigId: string; let packageConfigId: string; let packageConfigId2: string; before(async function () { + if (!server.enabled) { + return; + } const { body: agentConfigResponse } = await supertest .post(`/api/ingest_manager/agent_configs`) .set('kbn-xsrf', 'xxxx') @@ -73,55 +77,47 @@ export default function ({ getService }: FtrProviderContext) { }); it('should work with valid values', async function () { - if (server.enabled) { - const { body: apiResponse } = await supertest - .put(`/api/ingest_manager/package_configs/${packageConfigId}`) - .set('kbn-xsrf', 'xxxx') - .send({ - name: 'filetest-1', - description: '', - namespace: 'updated_namespace', - config_id: agentConfigId, - enabled: true, - output_id: '', - inputs: [], - package: { - name: 'filetest', - title: 'For File Tests', - version: '0.1.0', - }, - }) - .expect(200); + const { body: apiResponse } = await supertest + .put(`/api/ingest_manager/package_configs/${packageConfigId}`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'filetest-1', + description: '', + namespace: 'updated_namespace', + config_id: agentConfigId, + enabled: true, + output_id: '', + inputs: [], + package: { + name: 'filetest', + title: 'For File Tests', + version: '0.1.0', + }, + }) + .expect(200); - expect(apiResponse.success).to.be(true); - } else { - warnAndSkipTest(this, log); - } + expect(apiResponse.success).to.be(true); }); it('should return a 500 if there is another package config with the same name', async function () { - if (server.enabled) { - await supertest - .put(`/api/ingest_manager/package_configs/${packageConfigId2}`) - .set('kbn-xsrf', 'xxxx') - .send({ - name: 'filetest-1', - description: '', - namespace: 'updated_namespace', - config_id: agentConfigId, - enabled: true, - output_id: '', - inputs: [], - package: { - name: 'filetest', - title: 'For File Tests', - version: '0.1.0', - }, - }) - .expect(500); - } else { - warnAndSkipTest(this, log); - } + await supertest + .put(`/api/ingest_manager/package_configs/${packageConfigId2}`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'filetest-1', + description: '', + namespace: 'updated_namespace', + config_id: agentConfigId, + enabled: true, + output_id: '', + inputs: [], + package: { + name: 'filetest', + title: 'For File Tests', + version: '0.1.0', + }, + }) + .expect(500); }); }); } diff --git a/x-pack/test/ingest_manager_api_integration/config.ts b/x-pack/test/ingest_manager_api_integration/config.ts index e3cdf0eff4b3..6f5d8eed4351 100644 --- a/x-pack/test/ingest_manager_api_integration/config.ts +++ b/x-pack/test/ingest_manager_api_integration/config.ts @@ -8,6 +8,7 @@ import path from 'path'; import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; import { defineDockerServersConfig } from '@kbn/test'; +import { services } from '../api_integration/services'; export default async function ({ readConfigFile }: FtrConfigProviderContext) { const xPackAPITestsConfig = await readConfigFile(require.resolve('../api_integration/config.ts')); @@ -46,7 +47,9 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { waitForLogLine: 'package manifests loaded', }, }), + esArchiver: xPackAPITestsConfig.get('esArchiver'), services: { + ...services, supertest: xPackAPITestsConfig.get('services.supertest'), es: xPackAPITestsConfig.get('services.es'), }, diff --git a/x-pack/test/ingest_manager_api_integration/helpers.ts b/x-pack/test/ingest_manager_api_integration/helpers.ts index 121630249621..b1755e30f61f 100644 --- a/x-pack/test/ingest_manager_api_integration/helpers.ts +++ b/x-pack/test/ingest_manager_api_integration/helpers.ts @@ -6,6 +6,7 @@ import { Context } from 'mocha'; import { ToolingLog } from '@kbn/dev-utils'; +import { FtrProviderContext } from '../api_integration/ftr_provider_context'; export function warnAndSkipTest(mochaContext: Context, log: ToolingLog) { log.warning( @@ -13,3 +14,17 @@ export function warnAndSkipTest(mochaContext: Context, log: ToolingLog) { ); mochaContext.skip(); } + +export function skipIfNoDockerRegistry(providerContext: FtrProviderContext) { + const { getService } = providerContext; + const dockerServers = getService('dockerServers'); + + const server = dockerServers.get('registry'); + const log = getService('log'); + + beforeEach(function beforeSetupWithDockerRegistyry() { + if (!server.enabled) { + warnAndSkipTest(this, log); + } + }); +} From 39aa1f19c984097fa473fb537e916911bf6cd9fa Mon Sep 17 00:00:00 2001 From: Larry Gregory Date: Wed, 22 Jul 2020 13:57:27 -0400 Subject: [PATCH 059/142] Unskipping DLS/FLS tests (#72858) --- .../test/functional/apps/security/doc_level_security_roles.js | 3 +-- x-pack/test/functional/apps/security/field_level_security.js | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/x-pack/test/functional/apps/security/doc_level_security_roles.js b/x-pack/test/functional/apps/security/doc_level_security_roles.js index d8a3e40ccc01..72f463be48fd 100644 --- a/x-pack/test/functional/apps/security/doc_level_security_roles.js +++ b/x-pack/test/functional/apps/security/doc_level_security_roles.js @@ -15,8 +15,7 @@ export default function ({ getService, getPageObjects }) { const screenshot = getService('screenshots'); const PageObjects = getPageObjects(['security', 'common', 'header', 'discover', 'settings']); - // Skipped as failing on ES Promotion: https://github.com/elastic/kibana/issues/70818 - describe.skip('dls', function () { + describe('dls', function () { before('initialize tests', async () => { await esArchiver.load('empty_kibana'); await esArchiver.loadIfNeeded('security/dlstest'); diff --git a/x-pack/test/functional/apps/security/field_level_security.js b/x-pack/test/functional/apps/security/field_level_security.js index 20b13ad935f9..7b22d72885c9 100644 --- a/x-pack/test/functional/apps/security/field_level_security.js +++ b/x-pack/test/functional/apps/security/field_level_security.js @@ -14,8 +14,7 @@ export default function ({ getService, getPageObjects }) { const log = getService('log'); const PageObjects = getPageObjects(['security', 'settings', 'common', 'discover', 'header']); - // Skipped as it was failing on ES Promotion: https://github.com/elastic/kibana/issues/70880 - describe.skip('field_level_security', () => { + describe('field_level_security', () => { before('initialize tests', async () => { await esArchiver.loadIfNeeded('security/flstest/data'); //( data) await esArchiver.load('security/flstest/kibana'); //(savedobject) From 7e126bfab6a3bfc44f9fa50feecfe22b4634e1a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Louv-Jansen?= Date: Wed, 22 Jul 2020 20:17:31 +0200 Subject: [PATCH 060/142] Update jobs_list.tsx (#72797) --- .../components/app/Settings/anomaly_detection/jobs_list.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/jobs_list.tsx b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/jobs_list.tsx index 67227f99cb5f..f3b8822010f5 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/jobs_list.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/jobs_list.tsx @@ -152,7 +152,7 @@ function getNoItemsMessage({ if (status === FETCH_STATUS.FAILURE) { return i18n.translate( 'xpack.apm.settings.anomalyDetection.jobList.failedFetchText', - { defaultMessage: 'Unabled to fetch anomaly detection jobs.' } + { defaultMessage: 'Unable to fetch anomaly detection jobs.' } ); } From c58def27171bf18c07d94c8681197f8c17499e48 Mon Sep 17 00:00:00 2001 From: Spencer Date: Wed, 22 Jul 2020 11:42:38 -0700 Subject: [PATCH 061/142] [renovate] simplify config, only enable specific packages (#72903) Co-authored-by: spalger --- renovate.json5 | 1075 +---------------- scripts/build_renovate_config.js | 21 - src/dev/ci_setup/setup.sh | 16 - src/dev/renovate/config.ts | 135 --- src/dev/renovate/package_globs.ts | 59 - src/dev/renovate/package_groups.ts | 230 ---- .../renovate/run_build_renovate_config_cli.ts | 49 - src/dev/renovate/utils.ts | 48 - 8 files changed, 4 insertions(+), 1629 deletions(-) delete mode 100644 scripts/build_renovate_config.js delete mode 100644 src/dev/renovate/config.ts delete mode 100644 src/dev/renovate/package_globs.ts delete mode 100644 src/dev/renovate/package_groups.ts delete mode 100644 src/dev/renovate/run_build_renovate_config_cli.ts delete mode 100644 src/dev/renovate/utils.ts diff --git a/renovate.json5 b/renovate.json5 index ae32043daaf5..67454d266c19 100644 --- a/renovate.json5 +++ b/renovate.json5 @@ -1,9 +1,3 @@ -/** - * PLEASE DO NOT MODIFY - * - * This file is automatically generated by running `node scripts/build_renovate_config` - * - */ { extends: [ 'config:base', @@ -11,11 +5,6 @@ includePaths: [ 'package.json', 'x-pack/package.json', - 'x-pack/legacy/plugins/*/package.json', - 'packages/*/package.json', - 'examples/*/package.json', - 'test/plugin_functional/plugins/*/package.json', - 'test/interpreter_functional/plugins/*/package.json', ], baseBranches: [ 'master', @@ -39,7 +28,7 @@ }, separateMajorMinor: false, masterIssue: true, - masterIssueApproval: true, + masterIssueApproval: false, rangeStrategy: 'bump', npm: { lockFileMaintenance: { @@ -48,1068 +37,12 @@ packageRules: [ { groupSlug: '@elastic/charts', - groupName: '@elastic/charts related packages', - packageNames: [ - '@elastic/charts', - '@types/elastic__charts', - ], - reviewers: [ - 'markov00', - ], - masterIssueApproval: false, - }, - { - groupSlug: '@reach/router', - groupName: '@reach/router related packages', - packageNames: [ - '@reach/router', - '@types/reach__router', - ], - }, - { - groupSlug: '@testing-library/dom', - groupName: '@testing-library/dom related packages', - packageNames: [ - '@testing-library/dom', - '@types/testing-library__dom', - ], - }, - { - groupSlug: 'angular', - groupName: 'angular related packages', - packagePatterns: [ - '(\\b|_)angular(\\b|_)', - ], - }, - { - groupSlug: 'api-documenter', - groupName: 'api-documenter related packages', - packageNames: [ - '@microsoft/api-documenter', - '@types/microsoft__api-documenter', - '@microsoft/api-extractor', - '@types/microsoft__api-extractor', - ], - enabled: false, - }, - { - groupSlug: 'archiver', - groupName: 'archiver related packages', - packageNames: [ - 'archiver', - '@types/archiver', - ], - }, - { - groupSlug: 'babel', - groupName: 'babel related packages', - packagePatterns: [ - '(\\b|_)babel(\\b|_)', - ], - packageNames: [ - 'core-js', - '@types/core-js', - '@babel/preset-react', - '@types/babel__preset-react', - '@babel/preset-typescript', - '@types/babel__preset-typescript', - ], - }, - { - groupSlug: 'base64-js', - groupName: 'base64-js related packages', - packageNames: [ - 'base64-js', - '@types/base64-js', - ], - }, - { - groupSlug: 'bluebird', - groupName: 'bluebird related packages', - packageNames: [ - 'bluebird', - '@types/bluebird', - ], - }, - { - groupSlug: 'browserslist-useragent', - groupName: 'browserslist-useragent related packages', - packageNames: [ - 'browserslist-useragent', - '@types/browserslist-useragent', - ], - }, - { - groupSlug: 'chance', - groupName: 'chance related packages', - packageNames: [ - 'chance', - '@types/chance', - ], - }, - { - groupSlug: 'cheerio', - groupName: 'cheerio related packages', - packageNames: [ - 'cheerio', - '@types/cheerio', - ], - }, - { - groupSlug: 'chroma-js', - groupName: 'chroma-js related packages', - packageNames: [ - 'chroma-js', - '@types/chroma-js', - ], - }, - { - groupSlug: 'chromedriver', - groupName: 'chromedriver related packages', - packageNames: [ - 'chromedriver', - '@types/chromedriver', - ], - }, - { - groupSlug: 'classnames', - groupName: 'classnames related packages', - packageNames: [ - 'classnames', - '@types/classnames', - ], - }, - { - groupSlug: 'cmd-shim', - groupName: 'cmd-shim related packages', - packageNames: [ - 'cmd-shim', - '@types/cmd-shim', - ], - }, - { - groupSlug: 'color', - groupName: 'color related packages', - packageNames: [ - 'color', - '@types/color', - ], - }, - { - groupSlug: 'cpy', - groupName: 'cpy related packages', - packageNames: [ - 'cpy', - '@types/cpy', - ], - }, - { - groupSlug: 'cytoscape', - groupName: 'cytoscape related packages', - packageNames: [ - 'cytoscape', - '@types/cytoscape', - ], - }, - { - groupSlug: 'd3', - groupName: 'd3 related packages', - packagePatterns: [ - '(\\b|_)d3(\\b|_)', - ], - }, - { - groupSlug: 'dedent', - groupName: 'dedent related packages', - packageNames: [ - 'dedent', - '@types/dedent', - ], - }, - { - groupSlug: 'deep-freeze-strict', - groupName: 'deep-freeze-strict related packages', - packageNames: [ - 'deep-freeze-strict', - '@types/deep-freeze-strict', - ], - }, - { - groupSlug: 'delete-empty', - groupName: 'delete-empty related packages', - packageNames: [ - 'delete-empty', - '@types/delete-empty', - ], - }, - { - groupSlug: 'dragselect', - groupName: 'dragselect related packages', - packageNames: [ - 'dragselect', - '@types/dragselect', - ], - labels: [ - 'release_note:skip', - 'Team:Operations', - 'renovate', - 'v8.0.0', - 'v7.10.0', - ':ml', - ], - }, - { - groupSlug: 'elasticsearch', - groupName: 'elasticsearch related packages', - packageNames: [ - 'elasticsearch', - '@types/elasticsearch', - ], - }, - { - groupSlug: 'eslint', - groupName: 'eslint related packages', - packagePatterns: [ - '(\\b|_)eslint(\\b|_)', - ], - }, - { - groupSlug: 'estree', - groupName: 'estree related packages', - packageNames: [ - 'estree', - '@types/estree', - ], - }, - { - groupSlug: 'fancy-log', - groupName: 'fancy-log related packages', - packageNames: [ - 'fancy-log', - '@types/fancy-log', - ], - }, - { - groupSlug: 'fetch-mock', - groupName: 'fetch-mock related packages', - packageNames: [ - 'fetch-mock', - '@types/fetch-mock', - ], - }, - { - groupSlug: 'file-saver', - groupName: 'file-saver related packages', - packageNames: [ - 'file-saver', - '@types/file-saver', - ], - }, - { - groupSlug: 'flot', - groupName: 'flot related packages', - packageNames: [ - 'flot', - '@types/flot', - ], - }, - { - groupSlug: 'geojson', - groupName: 'geojson related packages', - packageNames: [ - 'geojson', - '@types/geojson', - ], - }, - { - groupSlug: 'getopts', - groupName: 'getopts related packages', - packageNames: [ - 'getopts', - '@types/getopts', - ], - }, - { - groupSlug: 'getos', - groupName: 'getos related packages', - packageNames: [ - 'getos', - '@types/getos', - ], - }, - { - groupSlug: 'git-url-parse', - groupName: 'git-url-parse related packages', - packageNames: [ - 'git-url-parse', - '@types/git-url-parse', - ], - }, - { - groupSlug: 'glob', - groupName: 'glob related packages', - packageNames: [ - 'glob', - '@types/glob', - ], - }, - { - groupSlug: 'globby', - groupName: 'globby related packages', - packageNames: [ - 'globby', - '@types/globby', - ], - }, - { - groupSlug: 'graphql', - groupName: 'graphql related packages', - packagePatterns: [ - '(\\b|_)graphql(\\b|_)', - '(\\b|_)apollo(\\b|_)', - ], - }, - { - groupSlug: 'grunt', - groupName: 'grunt related packages', - packagePatterns: [ - '(\\b|_)grunt(\\b|_)', - ], - }, - { - groupSlug: 'gulp', - groupName: 'gulp related packages', - packagePatterns: [ - '(\\b|_)gulp(\\b|_)', - ], - }, - { - groupSlug: 'hapi', - groupName: 'hapi related packages', - packagePatterns: [ - '(\\b|_)hapi(\\b|_)', - ], - packageNames: [ - 'hapi', - '@types/hapi', - 'joi', - '@types/joi', - 'boom', - '@types/boom', - 'hoek', - '@types/hoek', - 'h2o2', - '@types/h2o2', - '@elastic/good', - '@types/elastic__good', - 'good-squeeze', - '@types/good-squeeze', - 'inert', - '@types/inert', - 'accept', - '@types/accept', - ], - }, - { - groupSlug: 'has-ansi', - groupName: 'has-ansi related packages', - packageNames: [ - 'has-ansi', - '@types/has-ansi', - ], - }, - { - groupSlug: 'he', - groupName: 'he related packages', - packageNames: [ - 'he', - '@types/he', - ], - }, - { - groupSlug: 'history', - groupName: 'history related packages', - packageNames: [ - 'history', - '@types/history', - ], - }, - { - groupSlug: 'hjson', - groupName: 'hjson related packages', - packageNames: [ - 'hjson', - '@types/hjson', - ], - }, - { - groupSlug: 'inquirer', - groupName: 'inquirer related packages', - packageNames: [ - 'inquirer', - '@types/inquirer', - ], - }, - { - groupSlug: 'intl-relativeformat', - groupName: 'intl-relativeformat related packages', - packageNames: [ - 'intl-relativeformat', - '@types/intl-relativeformat', - ], - }, - { - groupSlug: 'jest', - groupName: 'jest related packages', - packagePatterns: [ - '(\\b|_)jest(\\b|_)', - ], - }, - { - groupSlug: 'jquery', - groupName: 'jquery related packages', - packageNames: [ - 'jquery', - '@types/jquery', - ], - }, - { - groupSlug: 'js-search', - groupName: 'js-search related packages', - packageNames: [ - 'js-search', - '@types/js-search', - ], - }, - { - groupSlug: 'js-yaml', - groupName: 'js-yaml related packages', - packageNames: [ - 'js-yaml', - '@types/js-yaml', - ], - }, - { - groupSlug: 'jsdom', - groupName: 'jsdom related packages', - packageNames: [ - 'jsdom', - '@types/jsdom', - ], - }, - { - groupSlug: 'json-stable-stringify', - groupName: 'json-stable-stringify related packages', - packageNames: [ - 'json-stable-stringify', - '@types/json-stable-stringify', - ], - }, - { - groupSlug: 'json5', - groupName: 'json5 related packages', - packageNames: [ - 'json5', - '@types/json5', - ], - }, - { - groupSlug: 'jsonwebtoken', - groupName: 'jsonwebtoken related packages', - packageNames: [ - 'jsonwebtoken', - '@types/jsonwebtoken', - ], - }, - { - groupSlug: 'jsts', - groupName: 'jsts related packages', - packageNames: [ - 'jsts', - '@types/jsts', - ], - allowedVersions: '^1.6.2', - }, - { - groupSlug: 'karma', - groupName: 'karma related packages', - packagePatterns: [ - '(\\b|_)karma(\\b|_)', - ], - }, - { - groupSlug: 'language server', - groupName: 'language server related packages', - packageNames: [ - 'vscode-jsonrpc', - '@types/vscode-jsonrpc', - 'vscode-languageserver', - '@types/vscode-languageserver', - 'vscode-languageserver-types', - '@types/vscode-languageserver-types', - ], - }, - { - groupSlug: 'license-checker', - groupName: 'license-checker related packages', - packageNames: [ - 'license-checker', - '@types/license-checker', - ], - }, - { - groupSlug: 'listr', - groupName: 'listr related packages', - packageNames: [ - 'listr', - '@types/listr', - ], - }, - { - groupSlug: 'lodash', - groupName: 'lodash related packages', - packageNames: [ - 'lodash', - '@types/lodash', - ], - }, - { - groupSlug: 'log-symbols', - groupName: 'log-symbols related packages', - packageNames: [ - 'log-symbols', - '@types/log-symbols', - ], - }, - { - groupSlug: 'lru-cache', - groupName: 'lru-cache related packages', - packageNames: [ - 'lru-cache', - '@types/lru-cache', - ], - }, - { - groupSlug: 'mapbox-gl', - groupName: 'mapbox-gl related packages', - packageNames: [ - 'mapbox-gl', - '@types/mapbox-gl', - ], - }, - { - groupSlug: 'markdown-it', - groupName: 'markdown-it related packages', - packageNames: [ - 'markdown-it', - '@types/markdown-it', - ], - }, - { - groupSlug: 'memoize-one', - groupName: 'memoize-one related packages', - packageNames: [ - 'memoize-one', - '@types/memoize-one', - ], - }, - { - groupSlug: 'mime', - groupName: 'mime related packages', - packageNames: [ - 'mime', - '@types/mime', - ], - }, - { - groupSlug: 'minimatch', - groupName: 'minimatch related packages', - packageNames: [ - 'minimatch', - '@types/minimatch', - ], - }, - { - groupSlug: 'mocha', - groupName: 'mocha related packages', - packagePatterns: [ - '(\\b|_)mocha(\\b|_)', - ], - }, - { - groupSlug: 'mock-fs', - groupName: 'mock-fs related packages', - packageNames: [ - 'mock-fs', - '@types/mock-fs', - ], - }, - { - groupSlug: 'moment', - groupName: 'moment related packages', - packagePatterns: [ - '(\\b|_)moment(\\b|_)', - ], - }, - { - groupSlug: 'mustache', - groupName: 'mustache related packages', - packageNames: [ - 'mustache', - '@types/mustache', - ], - }, - { - groupSlug: 'ncp', - groupName: 'ncp related packages', - packageNames: [ - 'ncp', - '@types/ncp', - ], - }, - { - groupSlug: 'nock', - groupName: 'nock related packages', - packageNames: [ - 'nock', - '@types/nock', - ], - }, - { - groupSlug: 'node', - groupName: 'node related packages', - packageNames: [ - 'node', - '@types/node', - ], - }, - { - groupSlug: 'node-fetch', - groupName: 'node-fetch related packages', - packageNames: [ - 'node-fetch', - '@types/node-fetch', - ], - }, - { - groupSlug: 'node-forge', - groupName: 'node-forge related packages', - packageNames: [ - 'node-forge', - '@types/node-forge', - ], - }, - { - groupSlug: 'node-sass', - groupName: 'node-sass related packages', - packageNames: [ - 'node-sass', - '@types/node-sass', - ], - }, - { - groupSlug: 'nodemailer', - groupName: 'nodemailer related packages', - packageNames: [ - 'nodemailer', - '@types/nodemailer', - ], - }, - { - groupSlug: 'normalize-path', - groupName: 'normalize-path related packages', - packageNames: [ - 'normalize-path', - '@types/normalize-path', - ], - }, - { - groupSlug: 'object-hash', - groupName: 'object-hash related packages', - packageNames: [ - 'object-hash', - '@types/object-hash', - ], - }, - { - groupSlug: 'opn', - groupName: 'opn related packages', - packageNames: [ - 'opn', - '@types/opn', - ], - }, - { - groupSlug: 'ora', - groupName: 'ora related packages', - packageNames: [ - 'ora', - '@types/ora', - ], - }, - { - groupSlug: 'papaparse', - groupName: 'papaparse related packages', - packageNames: [ - 'papaparse', - '@types/papaparse', - ], - }, - { - groupSlug: 'parse-link-header', - groupName: 'parse-link-header related packages', - packageNames: [ - 'parse-link-header', - '@types/parse-link-header', - ], - }, - { - groupSlug: 'pegjs', - groupName: 'pegjs related packages', - packageNames: [ - 'pegjs', - '@types/pegjs', - ], - }, - { - groupSlug: 'pngjs', - groupName: 'pngjs related packages', - packageNames: [ - 'pngjs', - '@types/pngjs', - ], - }, - { - groupSlug: 'podium', - groupName: 'podium related packages', - packageNames: [ - 'podium', - '@types/podium', - ], - }, - { - groupSlug: 'pretty-ms', - groupName: 'pretty-ms related packages', - packageNames: [ - 'pretty-ms', - '@types/pretty-ms', - ], - }, - { - groupSlug: 'proper-lockfile', - groupName: 'proper-lockfile related packages', - packageNames: [ - 'proper-lockfile', - '@types/proper-lockfile', - ], - }, - { - groupSlug: 'puppeteer', - groupName: 'puppeteer related packages', - packageNames: [ - 'puppeteer', - '@types/puppeteer', - ], - }, - { - groupSlug: 'react', - groupName: 'react related packages', - packagePatterns: [ - '(\\b|_)react(\\b|_)', - '(\\b|_)redux(\\b|_)', - '(\\b|_)enzyme(\\b|_)', - ], - packageNames: [ - 'ngreact', - '@types/ngreact', - 'recompose', - '@types/recompose', - 'prop-types', - '@types/prop-types', - 'typescript-fsa-reducers', - '@types/typescript-fsa-reducers', - 'reselect', - '@types/reselect', - ], - }, - { - groupSlug: 'read-pkg', - groupName: 'read-pkg related packages', - packageNames: [ - 'read-pkg', - '@types/read-pkg', - ], - }, - { - groupSlug: 'reduce-reducers', - groupName: 'reduce-reducers related packages', - packageNames: [ - 'reduce-reducers', - '@types/reduce-reducers', - ], - }, - { - groupSlug: 'request', - groupName: 'request related packages', - packageNames: [ - 'request', - '@types/request', - ], - }, - { - groupSlug: 'selenium-webdriver', - groupName: 'selenium-webdriver related packages', - packageNames: [ - 'selenium-webdriver', - '@types/selenium-webdriver', - ], - }, - { - groupSlug: 'semver', - groupName: 'semver related packages', - packageNames: [ - 'semver', - '@types/semver', - ], - }, - { - groupSlug: 'set-value', - groupName: 'set-value related packages', - packageNames: [ - 'set-value', - '@types/set-value', - ], - }, - { - groupSlug: 'sinon', - groupName: 'sinon related packages', - packageNames: [ - 'sinon', - '@types/sinon', - ], - }, - { - groupSlug: 'stats-lite', - groupName: 'stats-lite related packages', - packageNames: [ - 'stats-lite', - '@types/stats-lite', - ], - }, - { - groupSlug: 'storybook', - groupName: 'storybook related packages', - packagePatterns: [ - '(\\b|_)storybook(\\b|_)', - ], - }, - { - groupSlug: 'strip-ansi', - groupName: 'strip-ansi related packages', - packageNames: [ - 'strip-ansi', - '@types/strip-ansi', - ], - }, - { - groupSlug: 'strong-log-transformer', - groupName: 'strong-log-transformer related packages', - packageNames: [ - 'strong-log-transformer', - '@types/strong-log-transformer', - ], - }, - { - groupSlug: 'styled-components', - groupName: 'styled-components related packages', - packageNames: [ - 'styled-components', - '@types/styled-components', - ], - }, - { - groupSlug: 'supertest', - groupName: 'supertest related packages', - packageNames: [ - 'supertest', - '@types/supertest', - ], - }, - { - groupSlug: 'supertest-as-promised', - groupName: 'supertest-as-promised related packages', - packageNames: [ - 'supertest-as-promised', - '@types/supertest-as-promised', - ], - }, - { - groupSlug: 'tar', - groupName: 'tar related packages', - packageNames: [ - 'tar', - '@types/tar', - ], - }, - { - groupSlug: 'tar-fs', - groupName: 'tar-fs related packages', - packageNames: [ - 'tar-fs', - '@types/tar-fs', - ], - }, - { - groupSlug: 'tempy', - groupName: 'tempy related packages', - packageNames: [ - 'tempy', - '@types/tempy', - ], - }, - { - groupSlug: 'through2', - groupName: 'through2 related packages', - packageNames: [ - 'through2', - '@types/through2', - ], - }, - { - groupSlug: 'through2-map', - groupName: 'through2-map related packages', - packageNames: [ - 'through2-map', - '@types/through2-map', - ], - }, - { - groupSlug: 'tinycolor2', - groupName: 'tinycolor2 related packages', - packageNames: [ - 'tinycolor2', - '@types/tinycolor2', - ], - }, - { - groupSlug: 'type-detect', - groupName: 'type-detect related packages', - packageNames: [ - 'type-detect', - '@types/type-detect', - ], - }, - { - groupSlug: 'typescript', - groupName: 'typescript related packages', - packagePatterns: [ - '(\\b|_)ts(\\b|_)', - '(\\b|_)typescript(\\b|_)', - ], - packageNames: [ - 'tslib', - '@types/tslib', - ], - }, - { - groupSlug: 'use-resize-observer', - groupName: 'use-resize-observer related packages', - packageNames: [ - 'use-resize-observer', - '@types/use-resize-observer', - ], - }, - { - groupSlug: 'uuid', - groupName: 'uuid related packages', - packageNames: [ - 'uuid', - '@types/uuid', - ], - }, - { - groupSlug: 'vega', - groupName: 'vega related packages', - packagePatterns: [ - '(\\b|_)vega(\\b|_)', - ], - enabled: false, - }, - { - groupSlug: 'vinyl', - groupName: 'vinyl related packages', - packageNames: [ - 'vinyl', - '@types/vinyl', - ], - }, - { - groupSlug: 'vinyl-fs', - groupName: 'vinyl-fs related packages', - packageNames: [ - 'vinyl-fs', - '@types/vinyl-fs', - ], - }, - { - groupSlug: 'watchpack', - groupName: 'watchpack related packages', - packageNames: [ - 'watchpack', - '@types/watchpack', - ], - }, - { - groupSlug: 'webpack', - groupName: 'webpack related packages', - packagePatterns: [ - '(\\b|_)webpack(\\b|_)', - '(\\b|_)loader(\\b|_)', - '(\\b|_)acorn(\\b|_)', - '(\\b|_)terser(\\b|_)', - ], - packageNames: [ - 'mini-css-extract-plugin', - '@types/mini-css-extract-plugin', - 'chokidar', - '@types/chokidar', - ], - }, - { - groupSlug: 'write-pkg', - groupName: 'write-pkg related packages', - packageNames: [ - 'write-pkg', - '@types/write-pkg', - ], - }, - { - groupSlug: 'xml-crypto', - groupName: 'xml-crypto related packages', - packageNames: [ - 'xml-crypto', - '@types/xml-crypto', - ], - }, - { - groupSlug: 'xml2js', - groupName: 'xml2js related packages', - packageNames: [ - 'xml2js', - '@types/xml2js', - ], - }, - { - groupSlug: 'zen-observable', - groupName: 'zen-observable related packages', - packageNames: [ - 'zen-observable', - '@types/zen-observable', - ], + packageNames: ['@elastic/charts'], + reviewers: ['markov00'], }, { packagePatterns: [ - '^@kbn/.*', + '.*', ], enabled: false, }, diff --git a/scripts/build_renovate_config.js b/scripts/build_renovate_config.js deleted file mode 100644 index b9171c44f4a8..000000000000 --- a/scripts/build_renovate_config.js +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -require('../src/setup_node_env'); -require('../src/dev/renovate/run_build_renovate_config_cli'); diff --git a/src/dev/ci_setup/setup.sh b/src/dev/ci_setup/setup.sh index 25d2afb00cd8..aabc1e75b902 100755 --- a/src/dev/ci_setup/setup.sh +++ b/src/dev/ci_setup/setup.sh @@ -50,22 +50,6 @@ if [ "$GIT_CHANGES" ]; then exit 1 fi -### -### rebuild kbn-pm distributable to ensure it's not out of date -### -echo " -- building renovate config" -node scripts/build_renovate_config - -### -### verify no git modifications -### -GIT_CHANGES="$(git ls-files --modified)" -if [ "$GIT_CHANGES" ]; then - echo -e "\n${RED}ERROR: 'node scripts/build_renovate_config' caused changes to the following files:${C_RESET}\n" - echo -e "$GIT_CHANGES\n" - exit 1 -fi - ### ### rebuild plugin list to ensure it's not out of date ### diff --git a/src/dev/renovate/config.ts b/src/dev/renovate/config.ts deleted file mode 100644 index c9688fc0ae0b..000000000000 --- a/src/dev/renovate/config.ts +++ /dev/null @@ -1,135 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { RENOVATE_PACKAGE_GROUPS } from './package_groups'; -import { PACKAGE_GLOBS } from './package_globs'; -import { wordRegExp, maybeFlatMap, maybeMap, getTypePackageName } from './utils'; - -const DEFAULT_LABELS = ['release_note:skip', 'Team:Operations', 'renovate', 'v8.0.0', 'v7.10.0']; - -export const RENOVATE_CONFIG = { - extends: ['config:base'], - - includePaths: PACKAGE_GLOBS, - - /** - * Only submit PRs to these branches, we will manually backport PRs for now - */ - baseBranches: ['master'], - - /** - * Labels added to PRs opened by renovate - */ - labels: DEFAULT_LABELS, - - /** - * Config customizations for major version upgrades - */ - major: { - labels: [...DEFAULT_LABELS, 'renovate:major'], - }, - - // TODO: remove this once we've caught up on upgrades - /** - * When there is a major and minor update available, only offer the major update, - * the list of all upgrades is too overwhelming for now. - */ - separateMajorMinor: false, - - /** - * Enable creation of a "Master Issue" within the repository. This - * Master Issue is akin to a mini dashboard and contains a list of all - * PRs pending, open, closed (unmerged) or in error. - */ - masterIssue: true, - - /** - * Whether updates should require manual approval from within the - * Master Issue before creation. - * - * We can turn this off once we've gotten through the backlog of - * outdated packages. - */ - masterIssueApproval: true, - - /** - * Policy for how to modify/update existing ranges - * bump = e.g. bump the range even if the new version satisifies the existing range, e.g. ^1.0.0 -> ^1.1.0 - */ - rangeStrategy: 'bump', - - npm: { - /** - * This deletes and re-creates the lock file, which we will only want - * to turn on once we've updated all our deps and enabled version pinning - */ - lockFileMaintenance: { enabled: false }, - - /** - * Define groups of packages that should be updated/configured together - */ - packageRules: [ - ...RENOVATE_PACKAGE_GROUPS.map((group) => ({ - groupSlug: group.name, - groupName: `${group.name} related packages`, - packagePatterns: maybeMap(group.packageWords, (word) => wordRegExp(word).source), - packageNames: maybeFlatMap(group.packageNames, (name) => [name, getTypePackageName(name)]), - labels: group.extraLabels && [...DEFAULT_LABELS, ...group.extraLabels], - enabled: group.enabled === false ? false : undefined, - allowedVersions: group.allowedVersions || undefined, - reviewers: group.reviewers || undefined, - masterIssueApproval: group.autoOpenPr ? false : undefined, - })).sort((a, b) => a.groupName.localeCompare(b.groupName)), - - // internal/local packages - { - packagePatterns: ['^@kbn/.*'], - enabled: false, - }, - ], - }, - - /** - * Limit the number of active PRs renovate will allow - * 0 (default) means no limit - */ - prConcurrentLimit: 0, - - /** - * Disable vulnerability alert handling, we handle that separately - */ - vulnerabilityAlerts: { - enabled: false, - }, - - /** - * Disable automatic rebase on each change to base branch - */ - rebaseStalePrs: false, - - /** - * Disable automatic rebase on conflicts with the base branch - */ - rebaseConflictedPrs: false, - - /** - * Disable semantic commit formating - */ - semanticCommits: false, -}; diff --git a/src/dev/renovate/package_globs.ts b/src/dev/renovate/package_globs.ts deleted file mode 100644 index 825e6ffed0ec..000000000000 --- a/src/dev/renovate/package_globs.ts +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { readFileSync } from 'fs'; - -import globby from 'globby'; - -import { REPO_ROOT } from '../constants'; - -export const PACKAGE_GLOBS = [ - 'package.json', - 'x-pack/package.json', - 'x-pack/legacy/plugins/*/package.json', - 'packages/*/package.json', - 'examples/*/package.json', - 'test/plugin_functional/plugins/*/package.json', - 'test/interpreter_functional/plugins/*/package.json', -]; - -export function getAllDepNames() { - const depNames = new Set(); - - for (const glob of PACKAGE_GLOBS) { - const files = globby.sync(glob, { - cwd: REPO_ROOT, - absolute: true, - }); - - for (const path of files) { - const pkg = JSON.parse(readFileSync(path, 'utf8')); - const deps = [ - ...Object.keys(pkg.dependencies || {}), - ...Object.keys(pkg.devDependencies || {}), - ]; - - for (const dep of deps) { - depNames.add(dep); - } - } - } - - return depNames; -} diff --git a/src/dev/renovate/package_groups.ts b/src/dev/renovate/package_groups.ts deleted file mode 100644 index d051f956d14d..000000000000 --- a/src/dev/renovate/package_groups.ts +++ /dev/null @@ -1,230 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { getAllDepNames } from './package_globs'; -import { wordRegExp, unwrapTypesPackage } from './utils'; - -interface PackageGroup { - /** - * The group name, will be used for the branch name and in pr titles - */ - readonly name: string; - - /** - * Specific words that, when found in the package name, identify it as part of this group - */ - readonly packageWords?: string[]; - - /** - * Exact package names that should be included in this group - */ - readonly packageNames?: string[]; - - /** - * Extra labels to apply to PRs created for packages in this group - */ - readonly extraLabels?: string[]; - - /** - * A flag that will prevent renovatebot from telling us when there - * are updates. This should only be used in very special cases, like - * when we intend to never update a package. To just prevent a version - * upgrade consider support for the `allowedVersions` config, or just - * closing PRs to communicate to renovate that the specific upgrade - * should be ignored. - */ - readonly enabled?: false; - - /** - * A semver range defining allowed versions for a package group - * https://renovatebot.com/docs/configuration-options/#allowedversions - */ - readonly allowedVersions?: string; - - /** - * An array of users to request reviews from - */ - readonly reviewers?: string[]; - - /** - * Unless this is set to true, then PRs will only be opened when - * the corresponding checkbox is ticked in the master issue. - */ - readonly autoOpenPr?: boolean; -} - -export const RENOVATE_PACKAGE_GROUPS: PackageGroup[] = [ - { - name: 'eslint', - packageWords: ['eslint'], - }, - - { - name: 'babel', - packageWords: ['babel'], - packageNames: ['core-js', '@babel/preset-react', '@babel/preset-typescript'], - }, - - { - name: 'jest', - packageWords: ['jest'], - }, - - { - name: '@elastic/charts', - packageNames: ['@elastic/charts'], - reviewers: ['markov00'], - autoOpenPr: true, - }, - - { - name: 'mocha', - packageWords: ['mocha'], - }, - - { - name: 'karma', - packageWords: ['karma'], - }, - - { - name: 'gulp', - packageWords: ['gulp'], - }, - - { - name: 'grunt', - packageWords: ['grunt'], - }, - - { - name: 'angular', - packageWords: ['angular'], - }, - - { - name: 'd3', - packageWords: ['d3'], - }, - - { - name: 'react', - packageWords: ['react', 'redux', 'enzyme'], - packageNames: ['ngreact', 'recompose', 'prop-types', 'typescript-fsa-reducers', 'reselect'], - }, - - { - name: 'moment', - packageWords: ['moment'], - }, - - { - name: 'graphql', - packageWords: ['graphql', 'apollo'], - }, - - { - name: 'webpack', - packageWords: ['webpack', 'loader', 'acorn', 'terser'], - packageNames: ['mini-css-extract-plugin', 'chokidar'], - }, - - { - name: 'vega', - packageWords: ['vega'], - enabled: false, - }, - - { - name: 'language server', - packageNames: ['vscode-jsonrpc', 'vscode-languageserver', 'vscode-languageserver-types'], - }, - - { - name: 'hapi', - packageWords: ['hapi'], - packageNames: [ - 'hapi', - 'joi', - 'boom', - 'hoek', - 'h2o2', - '@elastic/good', - 'good-squeeze', - 'inert', - 'accept', - ], - }, - - { - name: 'dragselect', - packageNames: ['dragselect'], - extraLabels: [':ml'], - }, - - { - name: 'api-documenter', - packageNames: ['@microsoft/api-documenter', '@microsoft/api-extractor'], - enabled: false, - }, - - { - name: 'jsts', - packageNames: ['jsts'], - allowedVersions: '^1.6.2', - }, - - { - name: 'storybook', - packageWords: ['storybook'], - }, - - { - name: 'typescript', - packageWords: ['ts', 'typescript'], - packageNames: ['tslib'], - }, -]; - -/** - * Auto-define package groups for any `@types/*` deps that are not already in a group - */ -for (const dep of getAllDepNames()) { - const typesFor = unwrapTypesPackage(dep); - if (!typesFor) { - continue; - } - - // determine if one of the existing groups has typesFor in its - // packageNames or if any of the packageWords is in typesFor - const existing = RENOVATE_PACKAGE_GROUPS.some( - (group) => - (group.packageNames || []).includes(typesFor) || - (group.packageWords || []).some((word) => wordRegExp(word).test(typesFor)) - ); - - if (existing) { - continue; - } - - RENOVATE_PACKAGE_GROUPS.push({ - name: typesFor, - packageNames: [typesFor], - }); -} diff --git a/src/dev/renovate/run_build_renovate_config_cli.ts b/src/dev/renovate/run_build_renovate_config_cli.ts deleted file mode 100644 index db08bbc8a8f2..000000000000 --- a/src/dev/renovate/run_build_renovate_config_cli.ts +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { writeFileSync } from 'fs'; -import { resolve } from 'path'; -import json5 from 'json5'; -import dedent from 'dedent'; - -import { run } from '@kbn/dev-utils'; -import { REPO_ROOT } from '../constants'; -import { RENOVATE_CONFIG } from './config'; - -run( - async () => { - const genInfo = dedent` - /** - * PLEASE DO NOT MODIFY - * - * This file is automatically generated by running \`node scripts/build_renovate_config\` - * - */ - `; - - writeFileSync( - resolve(REPO_ROOT, 'renovate.json5'), - `${genInfo}\n${json5.stringify(RENOVATE_CONFIG, null, 2)}\n` - ); - }, - { - description: - 'Regenerate the renovate.json5 file at the root of the repo based on the config in src/dev/renovate', - } -); diff --git a/src/dev/renovate/utils.ts b/src/dev/renovate/utils.ts deleted file mode 100644 index a3c7e1b56d7b..000000000000 --- a/src/dev/renovate/utils.ts +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -export const maybeMap = (input: T[] | undefined, fn: (i: T) => T2) => - input ? input.map(fn) : undefined; - -export const maybeFlatMap = (input: T[] | undefined, fn: (i: T) => T2[]) => - input ? input.reduce((acc, i) => [...acc, ...fn(i)], [] as T2[]) : undefined; - -export const wordRegExp = (word: string) => new RegExp(`(\\b|_)${word}(\\b|_)`); - -export const getTypePackageName = (pkgName: string) => { - const scopedPkgRe = /^@(.+?)\/(.+?)$/; - const match = pkgName.match(scopedPkgRe); - return `@types/${match ? `${match[1]}__${match[2]}` : pkgName}`; -}; - -export const unwrapTypesPackage = (pkgName: string) => { - if (!pkgName.startsWith('@types')) { - return; - } - - const typesFor = pkgName.slice('@types/'.length); - - if (!typesFor.includes('__')) { - return typesFor; - } - - // @types packages use a convention for scoped packages, @types/org__name - const [org, name] = typesFor.split('__'); - return `@${org}/${name}`; -}; From e9ec039e8e60811eced7fdc95183a64943ad3cfd Mon Sep 17 00:00:00 2001 From: Lee Drengenberg Date: Wed, 22 Jul 2020 14:00:57 -0500 Subject: [PATCH 062/142] un-revert login_page change for SAML (#72892) --- test/functional/page_objects/login_page.ts | 60 ++++++++++++++++++++-- 1 file changed, 55 insertions(+), 5 deletions(-) diff --git a/test/functional/page_objects/login_page.ts b/test/functional/page_objects/login_page.ts index c84f47a34215..350ab8be1a27 100644 --- a/test/functional/page_objects/login_page.ts +++ b/test/functional/page_objects/login_page.ts @@ -7,26 +7,76 @@ * not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + *    http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the + * KIND, either express or implied.  See the License for the * specific language governing permissions and limitations * under the License. */ +import { delay } from 'bluebird'; import { FtrProviderContext } from '../ftr_provider_context'; export function LoginPageProvider({ getService }: FtrProviderContext) { const testSubjects = getService('testSubjects'); + const log = getService('log'); + const find = getService('find'); + + const regularLogin = async (user: string, pwd: string) => { + await testSubjects.setValue('loginUsername', user); + await testSubjects.setValue('loginPassword', pwd); + await testSubjects.click('loginSubmit'); + await find.waitForDeletedByCssSelector('.kibanaWelcomeLogo'); + await find.byCssSelector('[data-test-subj="kibanaChrome"]', 60000); // 60 sec waiting + }; + + const samlLogin = async (user: string, pwd: string) => { + try { + await find.clickByButtonText('Login using SAML'); + await find.setValue('input[name="email"]', user); + await find.setValue('input[type="password"]', pwd); + await find.clickByCssSelector('.auth0-label-submit'); + await find.byCssSelector('[data-test-subj="kibanaChrome"]', 60000); // 60 sec waiting + } catch (err) { + log.debug(`${err} \nFailed to find Auth0 login page, trying the Auth0 last login page`); + await find.clickByCssSelector('.auth0-lock-social-button'); + } + }; class LoginPage { async login(user: string, pwd: string) { - await testSubjects.setValue('loginUsername', user); - await testSubjects.setValue('loginPassword', pwd); - await testSubjects.click('loginSubmit'); + if ( + process.env.VM === 'ubuntu18_deb_oidc' || + process.env.VM === 'ubuntu16_deb_desktop_saml' + ) { + await samlLogin(user, pwd); + return; + } + + await regularLogin(user, pwd); + } + + async logoutLogin(user: string, pwd: string) { + await this.logout(); + await this.sleep(3002); + await this.login(user, pwd); + } + + async logout() { + await testSubjects.click('userMenuButton'); + await this.sleep(500); + await testSubjects.click('logoutLink'); + log.debug('### found and clicked log out--------------------------'); + await this.sleep(8002); + } + + async sleep(sleepMilliseconds: number) { + log.debug(`... sleep(${sleepMilliseconds}) start`); + await delay(sleepMilliseconds); + log.debug(`... sleep(${sleepMilliseconds}) end`); } } From c9c21586828b5d3fdc83f35dc8c5bb5cd2ca8e0d Mon Sep 17 00:00:00 2001 From: Quynh Nguyen <43350163+qn895@users.noreply.github.com> Date: Wed, 22 Jul 2020 14:05:53 -0500 Subject: [PATCH 063/142] [ML] Fix deleting DFA not showing index pattern check (#72904) --- .../components/action_delete/use_delete_action.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_delete/use_delete_action.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_delete/use_delete_action.ts index 4fc7b5e1367c..461b1749c793 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_delete/use_delete_action.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_delete/use_delete_action.ts @@ -54,6 +54,8 @@ export const useDeleteAction = () => { ); if (ip !== undefined) { setIndexPatternExists(true); + } else { + setIndexPatternExists(false); } } catch (e) { const { toasts } = notifications; @@ -101,7 +103,7 @@ export const useDeleteAction = () => { // Check if an user has permission to delete the index & index pattern checkUserIndexPermission(); - }, []); + }, [isModalVisible]); const closeModal = () => setModalVisible(false); const deleteAndCloseModal = () => { From 2ef9657ecff4ce0c655fd68c798351362a8d1250 Mon Sep 17 00:00:00 2001 From: spalger Date: Wed, 22 Jul 2020 12:07:49 -0700 Subject: [PATCH 064/142] fix SIEM es_archiver command syntax --- x-pack/plugins/security_solution/cypress/tasks/es_archiver.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/security_solution/cypress/tasks/es_archiver.ts b/x-pack/plugins/security_solution/cypress/tasks/es_archiver.ts index 5a09a2f753dc..1221bdb8db47 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/es_archiver.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/es_archiver.ts @@ -6,7 +6,7 @@ export const esArchiverLoadEmptyKibana = () => { cy.exec( - `node ../../../scripts/es_archiver empty_kibana load empty--dir ../../test/security_solution_cypress/es_archives --config ../../../test/functional/config.js --es-url ${Cypress.env( + `node ../../../scripts/es_archiver load empty_kibana empty--dir ../../test/security_solution_cypress/es_archives --config ../../../test/functional/config.js --es-url ${Cypress.env( 'ELASTICSEARCH_URL' )} --kibana-url ${Cypress.config().baseUrl}` ); From ffd8ed2d975db88abd5683848121b29916153986 Mon Sep 17 00:00:00 2001 From: Justin Kambic Date: Wed, 22 Jul 2020 15:18:08 -0400 Subject: [PATCH 065/142] [Uptime] Refactor overview filters reducer to use `createAction` (#69187) * Refactor overview filters to use createAction. * Refresh snapshot. Co-authored-by: Elastic Machine --- .../overview_filters.test.ts.snap | 1 + .../public/state/actions/overview_filters.ts | 60 +++------------- .../public/state/effects/overview_filters.ts | 4 +- .../public/state/reducers/overview_filters.ts | 69 +++++++++---------- 4 files changed, 46 insertions(+), 88 deletions(-) diff --git a/x-pack/plugins/uptime/public/state/actions/__tests__/__snapshots__/overview_filters.test.ts.snap b/x-pack/plugins/uptime/public/state/actions/__tests__/__snapshots__/overview_filters.test.ts.snap index 6fe2c8eaa362..1e7ea536bae7 100644 --- a/x-pack/plugins/uptime/public/state/actions/__tests__/__snapshots__/overview_filters.test.ts.snap +++ b/x-pack/plugins/uptime/public/state/actions/__tests__/__snapshots__/overview_filters.test.ts.snap @@ -2,6 +2,7 @@ exports[`overview filters action creators creates a fail action 1`] = ` Object { + "error": true, "payload": [Error: There was an error retrieving the overview filters], "type": "FETCH_OVERVIEW_FILTERS_FAIL", } diff --git a/x-pack/plugins/uptime/public/state/actions/overview_filters.ts b/x-pack/plugins/uptime/public/state/actions/overview_filters.ts index 8eefa701a240..1dcf49414c41 100644 --- a/x-pack/plugins/uptime/public/state/actions/overview_filters.ts +++ b/x-pack/plugins/uptime/public/state/actions/overview_filters.ts @@ -4,13 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ +import { createAction } from 'redux-actions'; import { OverviewFilters } from '../../../common/runtime_types'; -export const FETCH_OVERVIEW_FILTERS = 'FETCH_OVERVIEW_FILTERS'; -export const FETCH_OVERVIEW_FILTERS_FAIL = 'FETCH_OVERVIEW_FILTERS_FAIL'; -export const FETCH_OVERVIEW_FILTERS_SUCCESS = 'FETCH_OVERVIEW_FILTERS_SUCCESS'; -export const SET_OVERVIEW_FILTERS = 'SET_OVERVIEW_FILTERS'; - export interface GetOverviewFiltersPayload { dateRangeStart: string; dateRangeEnd: string; @@ -22,52 +18,16 @@ export interface GetOverviewFiltersPayload { tags: string[]; } -interface GetOverviewFiltersFetchAction { - type: typeof FETCH_OVERVIEW_FILTERS; - payload: GetOverviewFiltersPayload; -} - -interface GetOverviewFiltersSuccessAction { - type: typeof FETCH_OVERVIEW_FILTERS_SUCCESS; - payload: OverviewFilters; -} - -interface GetOverviewFiltersFailAction { - type: typeof FETCH_OVERVIEW_FILTERS_FAIL; - payload: Error; -} - -interface SetOverviewFiltersAction { - type: typeof SET_OVERVIEW_FILTERS; - payload: OverviewFilters; -} - -export type OverviewFiltersAction = - | GetOverviewFiltersFetchAction - | GetOverviewFiltersSuccessAction - | GetOverviewFiltersFailAction - | SetOverviewFiltersAction; +export type OverviewFiltersPayload = GetOverviewFiltersPayload & Error & OverviewFilters; -export const fetchOverviewFilters = ( - payload: GetOverviewFiltersPayload -): GetOverviewFiltersFetchAction => ({ - type: FETCH_OVERVIEW_FILTERS, - payload, -}); +export const fetchOverviewFilters = createAction( + 'FETCH_OVERVIEW_FILTERS' +); -export const fetchOverviewFiltersFail = (error: Error): GetOverviewFiltersFailAction => ({ - type: FETCH_OVERVIEW_FILTERS_FAIL, - payload: error, -}); +export const fetchOverviewFiltersFail = createAction('FETCH_OVERVIEW_FILTERS_FAIL'); -export const fetchOverviewFiltersSuccess = ( - filters: OverviewFilters -): GetOverviewFiltersSuccessAction => ({ - type: FETCH_OVERVIEW_FILTERS_SUCCESS, - payload: filters, -}); +export const fetchOverviewFiltersSuccess = createAction( + 'FETCH_OVERVIEW_FILTERS_SUCCESS' +); -export const setOverviewFilters = (filters: OverviewFilters): SetOverviewFiltersAction => ({ - type: SET_OVERVIEW_FILTERS, - payload: filters, -}); +export const setOverviewFilters = createAction('SET_OVERVIEW_FILTERS'); diff --git a/x-pack/plugins/uptime/public/state/effects/overview_filters.ts b/x-pack/plugins/uptime/public/state/effects/overview_filters.ts index 92b578bafed2..9149f20f233c 100644 --- a/x-pack/plugins/uptime/public/state/effects/overview_filters.ts +++ b/x-pack/plugins/uptime/public/state/effects/overview_filters.ts @@ -6,7 +6,7 @@ import { takeLatest } from 'redux-saga/effects'; import { - FETCH_OVERVIEW_FILTERS, + fetchOverviewFilters as fetchAction, fetchOverviewFiltersFail, fetchOverviewFiltersSuccess, } from '../actions'; @@ -15,7 +15,7 @@ import { fetchEffectFactory } from './fetch_effect'; export function* fetchOverviewFiltersEffect() { yield takeLatest( - FETCH_OVERVIEW_FILTERS, + String(fetchAction), fetchEffectFactory(fetchOverviewFilters, fetchOverviewFiltersSuccess, fetchOverviewFiltersFail) ); } diff --git a/x-pack/plugins/uptime/public/state/reducers/overview_filters.ts b/x-pack/plugins/uptime/public/state/reducers/overview_filters.ts index 4548627d9dcb..702518b69cba 100644 --- a/x-pack/plugins/uptime/public/state/reducers/overview_filters.ts +++ b/x-pack/plugins/uptime/public/state/reducers/overview_filters.ts @@ -4,13 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ +import { handleActions, Action } from 'redux-actions'; import { OverviewFilters } from '../../../common/runtime_types'; import { - FETCH_OVERVIEW_FILTERS, - FETCH_OVERVIEW_FILTERS_FAIL, - FETCH_OVERVIEW_FILTERS_SUCCESS, - OverviewFiltersAction, - SET_OVERVIEW_FILTERS, + fetchOverviewFilters, + fetchOverviewFiltersFail, + fetchOverviewFiltersSuccess, + setOverviewFilters, + GetOverviewFiltersPayload, + OverviewFiltersPayload, } from '../actions'; export interface OverviewFiltersState { @@ -30,34 +32,29 @@ const initialState: OverviewFiltersState = { loading: false, }; -export function overviewFiltersReducer( - state = initialState, - action: OverviewFiltersAction -): OverviewFiltersState { - switch (action.type) { - case FETCH_OVERVIEW_FILTERS: - return { - ...state, - loading: true, - }; - case FETCH_OVERVIEW_FILTERS_SUCCESS: - return { - ...state, - filters: action.payload, - loading: false, - }; - case FETCH_OVERVIEW_FILTERS_FAIL: - return { - ...state, - errors: [...state.errors, action.payload], - loading: false, - }; - case SET_OVERVIEW_FILTERS: - return { - ...state, - filters: action.payload, - }; - default: - return state; - } -} +export const overviewFiltersReducer = handleActions( + { + [String(fetchOverviewFilters)]: (state, _action: Action) => ({ + ...state, + loading: true, + }), + + [String(fetchOverviewFiltersSuccess)]: (state, action: Action) => ({ + ...state, + filters: action.payload, + loading: false, + }), + + [String(fetchOverviewFiltersFail)]: (state, action: Action) => ({ + ...state, + errors: [...state.errors, action.payload], + loading: false, + }), + + [String(setOverviewFilters)]: (state, action: Action) => ({ + ...state, + filters: action.payload, + }), + }, + initialState +); From 4fa660c6724d087df55e4cef54ee50e86a8152bf Mon Sep 17 00:00:00 2001 From: Frank Hassanabad Date: Wed, 22 Jul 2020 13:19:27 -0600 Subject: [PATCH 066/142] Limits the upload size of lists to 9 meg size (#72898) ## Summary Limits the lists to 9 megs upload size so we don't blow up smaller Kibana installs. Users can change/override this using the switch of `xpack.lists.maxImportPayloadBytes` like so: ``` xpack.lists.maxImportPayloadBytes: 40000000 ``` That will increase the amount of bytes that can pushed through REST endpoints from 9 megs to something like 40 megs if the end users want to increase the size of their lists and have enough memory in Kibana. Metrics and suggestions from testing looks like: ```ts Kibana with 1 gig of memory can upload ~10 megs of a list before possible out of memory issue Kibana with 2 gig of memory can upload ~20 megs of a list before possible out of memory issue ``` Things can vary depending on the speed of the uploads of the lists where faster connections to Kibana but slower connections from Kibana to Elastic Search can influence the numbers. ### Checklist - [x] [Unit or functional tests](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility) were updated or added to match the most common scenarios --- x-pack/plugins/lists/common/constants.mock.ts | 2 +- x-pack/plugins/lists/server/config.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/lists/common/constants.mock.ts b/x-pack/plugins/lists/common/constants.mock.ts index 4f01d43f47ec..30f219c3ec10 100644 --- a/x-pack/plugins/lists/common/constants.mock.ts +++ b/x-pack/plugins/lists/common/constants.mock.ts @@ -41,7 +41,7 @@ export const OPERATOR = 'included'; export const ENTRY_VALUE = 'some host name'; export const MATCH = 'match'; export const MATCH_ANY = 'match_any'; -export const MAX_IMPORT_PAYLOAD_BYTES = 40000000; +export const MAX_IMPORT_PAYLOAD_BYTES = 9000000; export const IMPORT_BUFFER_SIZE = 1000; export const LIST = 'list'; export const EXISTS = 'exists'; diff --git a/x-pack/plugins/lists/server/config.ts b/x-pack/plugins/lists/server/config.ts index 0fcc68419f8f..394f85ecfb64 100644 --- a/x-pack/plugins/lists/server/config.ts +++ b/x-pack/plugins/lists/server/config.ts @@ -11,7 +11,7 @@ export const ConfigSchema = schema.object({ importBufferSize: schema.number({ defaultValue: 1000, min: 1 }), listIndex: schema.string({ defaultValue: '.lists' }), listItemIndex: schema.string({ defaultValue: '.items' }), - maxImportPayloadBytes: schema.number({ defaultValue: 40000000, min: 1 }), + maxImportPayloadBytes: schema.number({ defaultValue: 9000000, min: 1 }), }); export type ConfigType = TypeOf; From d39e97d97245eefc0026ee80df2dbdfaa4f2c2f8 Mon Sep 17 00:00:00 2001 From: spalger Date: Wed, 22 Jul 2020 12:19:19 -0700 Subject: [PATCH 067/142] fix unexpected arguments to unload command --- x-pack/plugins/security_solution/cypress/tasks/es_archiver.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/security_solution/cypress/tasks/es_archiver.ts b/x-pack/plugins/security_solution/cypress/tasks/es_archiver.ts index 1221bdb8db47..c0436603a256 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/es_archiver.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/es_archiver.ts @@ -6,7 +6,7 @@ export const esArchiverLoadEmptyKibana = () => { cy.exec( - `node ../../../scripts/es_archiver load empty_kibana empty--dir ../../test/security_solution_cypress/es_archives --config ../../../test/functional/config.js --es-url ${Cypress.env( + `node ../../../scripts/es_archiver load empty_kibana --dir ../../test/security_solution_cypress/es_archives --config ../../../test/functional/config.js --es-url ${Cypress.env( 'ELASTICSEARCH_URL' )} --kibana-url ${Cypress.config().baseUrl}` ); @@ -30,7 +30,7 @@ export const esArchiverUnload = (folder: string) => { export const esArchiverUnloadEmptyKibana = () => { cy.exec( - `node ../../../scripts/es_archiver unload empty_kibana empty--dir ../../test/security_solution_cypress/es_archives --config ../../../test/functional/config.js --es-url ${Cypress.env( + `node ../../../scripts/es_archiver unload empty_kibana --dir ../../test/security_solution_cypress/es_archives --config ../../../test/functional/config.js --es-url ${Cypress.env( 'ELASTICSEARCH_URL' )} --kibana-url ${Cypress.config().baseUrl}` ); From 80da1c6a5410d7ef19f07184106503b887d96a86 Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Wed, 22 Jul 2020 13:26:22 -0600 Subject: [PATCH 068/142] [Maps] fix blended layer aggregation error when using composite aggregation (#72759) --- .../classes/sources/es_geo_grid_source/es_geo_grid_source.js | 1 - 1 file changed, 1 deletion(-) diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.js b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.js index 92f6c258af59..a4dba71307b7 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.js +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.js @@ -161,7 +161,6 @@ export class ESGeoGridSource extends AbstractESAggSource { bounds: makeESBbox(bufferedExtent), field: this._descriptor.geoField, precision, - size: DEFAULT_MAX_BUCKETS_LIMIT, }, }, }, From 8b27b1e83c735501cce0a1d450222351e357c86b Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Wed, 22 Jul 2020 13:37:18 -0600 Subject: [PATCH 069/142] [Maps] fix removing global filter from layer can cause app to start thrashing (#72763) --- .../classes/layers/blended_vector_layer/blended_vector_layer.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts b/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts index c0b9c4553d01..da28574189e6 100644 --- a/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts +++ b/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts @@ -55,6 +55,7 @@ function getClusterSource(documentSource: IESSource, documentStyle: IVectorStyle geoField: documentSource.getGeoFieldName(), requestType: RENDER_AS.POINT, }); + clusterSourceDescriptor.applyGlobalQuery = documentSource.getApplyGlobalQuery(); clusterSourceDescriptor.metrics = [ { type: AGG_TYPE.COUNT, From 24ebe0a189e9dc21b62458572b26ed8a71ff9cfd Mon Sep 17 00:00:00 2001 From: Justin Kambic Date: Wed, 22 Jul 2020 16:49:21 -0400 Subject: [PATCH 070/142] [Uptime] Fix accessibility issue in Uptime app nav links (#72926) * Fix accessibility issue in Uptime app nav links. * Refresh outdated snapshot. * Introduce aria-label for hidden content. --- .../__snapshots__/page_header.test.tsx.snap | 29 +++++----- .../uptime/public/pages/certificates.tsx | 32 +++++++---- .../plugins/uptime/public/pages/not_found.tsx | 57 ++++++++++--------- .../uptime/public/pages/page_header.tsx | 15 +++-- .../plugins/uptime/public/pages/settings.tsx | 18 ++++-- 5 files changed, 84 insertions(+), 67 deletions(-) diff --git a/x-pack/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap b/x-pack/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap index fcf68ad97c8c..1b5856bf1f9e 100644 --- a/x-pack/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap +++ b/x-pack/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap @@ -80,28 +80,25 @@ Array [ class="euiFlexItem euiFlexItem--flexGrowZero" >
- +

{ }, [dispatch, page, search, sort.direction, sort.field, lastRefresh]); const { data: certificates } = useSelector(certificatesSelector); + const history = useHistory(); return ( <> - - - {labels.RETURN_TO_OVERVIEW} - - + + {labels.RETURN_TO_OVERVIEW} + - - - {labels.SETTINGS_ON_CERT} - - + + {labels.SETTINGS_ON_CERT} + diff --git a/x-pack/plugins/uptime/public/pages/not_found.tsx b/x-pack/plugins/uptime/public/pages/not_found.tsx index 0576a79999a5..264a2b6b682c 100644 --- a/x-pack/plugins/uptime/public/pages/not_found.tsx +++ b/x-pack/plugins/uptime/public/pages/not_found.tsx @@ -13,38 +13,39 @@ import { EuiButton, } from '@elastic/eui'; import React from 'react'; -import { Link } from 'react-router-dom'; +import { useHistory } from 'react-router-dom'; import { FormattedMessage } from '@kbn/i18n/react'; -export const NotFoundPage = () => ( - - - - -

- -

- - } - body={ - - +export const NotFoundPage = () => { + const history = useHistory(); + return ( + + + + +

+ +

+ + } + body={ + - - } - /> -
-
-
-); + } + /> +
+
+
+ ); +}; diff --git a/x-pack/plugins/uptime/public/pages/page_header.tsx b/x-pack/plugins/uptime/public/pages/page_header.tsx index 421e0e3a4ebd..16279a63b5f4 100644 --- a/x-pack/plugins/uptime/public/pages/page_header.tsx +++ b/x-pack/plugins/uptime/public/pages/page_header.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiTitle, EuiSpacer, EuiButtonEmpty } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { Link } from 'react-router-dom'; +import { useHistory } from 'react-router-dom'; import styled from 'styled-components'; import { UptimeDatePicker } from '../components/common/uptime_date_picker'; import { SETTINGS_ROUTE } from '../../common/constants'; @@ -58,6 +58,7 @@ export const PageHeader = React.memo( ) : null; const kibana = useKibana(); + const history = useHistory(); const extraLinkComponents = !extraLinks ? null : ( @@ -65,11 +66,13 @@ export const PageHeader = React.memo(
- - - {SETTINGS_LINK_TEXT} - - + + {SETTINGS_LINK_TEXT} + { ); + const history = useHistory(); + return ( <> - - - {Translations.settings.returnToOverviewLinkLabel} - - + + {Translations.settings.returnToOverviewLinkLabel} + From 1889c68c6bcdfb4b0320ea1dfb77b6adde42986c Mon Sep 17 00:00:00 2001 From: Melissa Alvarez Date: Wed, 22 Jul 2020 16:50:18 -0400 Subject: [PATCH 071/142] [ML] API integration tests for UPDATE data frame analytics endpoint (#72710) * add df analytics update api integration tests * remove unnecessary commented code * remove unused constant * fetch job to check it was updated correctly --- .../apis/ml/data_frame_analytics/index.ts | 1 + .../apis/ml/data_frame_analytics/update.ts | 275 ++++++++++++++++++ 2 files changed, 276 insertions(+) create mode 100644 x-pack/test/api_integration/apis/ml/data_frame_analytics/update.ts diff --git a/x-pack/test/api_integration/apis/ml/data_frame_analytics/index.ts b/x-pack/test/api_integration/apis/ml/data_frame_analytics/index.ts index 6693561076fd..99549be8c186 100644 --- a/x-pack/test/api_integration/apis/ml/data_frame_analytics/index.ts +++ b/x-pack/test/api_integration/apis/ml/data_frame_analytics/index.ts @@ -10,5 +10,6 @@ export default function ({ loadTestFile }: FtrProviderContext) { describe('data frame analytics', function () { loadTestFile(require.resolve('./get')); loadTestFile(require.resolve('./delete')); + loadTestFile(require.resolve('./update')); }); } diff --git a/x-pack/test/api_integration/apis/ml/data_frame_analytics/update.ts b/x-pack/test/api_integration/apis/ml/data_frame_analytics/update.ts new file mode 100644 index 000000000000..5dc781657619 --- /dev/null +++ b/x-pack/test/api_integration/apis/ml/data_frame_analytics/update.ts @@ -0,0 +1,275 @@ +/* + * 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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../ftr_provider_context'; +import { USER } from '../../../../functional/services/ml/security_common'; +import { DataFrameAnalyticsConfig } from '../../../../../plugins/ml/public/application/data_frame_analytics/common'; +import { DeepPartial } from '../../../../../plugins/ml/common/types/common'; +import { COMMON_REQUEST_HEADERS } from '../../../../functional/services/ml/common'; + +export default ({ getService }: FtrProviderContext) => { + const esArchiver = getService('esArchiver'); + const supertest = getService('supertestWithoutAuth'); + const ml = getService('ml'); + + const jobId = `bm_${Date.now()}`; + const generateDestinationIndex = (analyticsId: string) => `user-${analyticsId}`; + const commonJobConfig = { + source: { + index: ['ft_bank_marketing'], + query: { + match_all: {}, + }, + }, + analysis: { + classification: { + dependent_variable: 'y', + training_percent: 20, + }, + }, + analyzed_fields: { + includes: [], + excludes: [], + }, + model_memory_limit: '60mb', + allow_lazy_start: false, // default value + max_num_threads: 1, // default value + }; + + const testJobConfigs: Array> = [ + 'Test update job', + 'Test update job description only', + 'Test update job allow_lazy_start only', + 'Test update job model_memory_limit only', + 'Test update job max_num_threads only', + ].map((description, idx) => { + const analyticsId = `${jobId}_${idx}`; + return { + id: analyticsId, + description, + dest: { + index: generateDestinationIndex(analyticsId), + results_field: 'ml', + }, + ...commonJobConfig, + }; + }); + + const editedDescription = 'Edited description'; + + async function createJobs(mockJobConfigs: Array>) { + for (const jobConfig of mockJobConfigs) { + await ml.api.createDataFrameAnalyticsJob(jobConfig as DataFrameAnalyticsConfig); + } + } + + async function getDFAJob(id: string) { + const { body } = await supertest + .get(`/api/ml/data_frame/analytics/${id}`) + .auth(USER.ML_VIEWER, ml.securityCommon.getPasswordForUser(USER.ML_VIEWER)) + .set(COMMON_REQUEST_HEADERS); + + return body.data_frame_analytics[0]; + } + + describe('UPDATE data_frame/analytics', () => { + before(async () => { + await esArchiver.loadIfNeeded('ml/bm_classification'); + await ml.testResources.setKibanaTimeZoneToUTC(); + await createJobs(testJobConfigs); + }); + + after(async () => { + await ml.api.cleanMlIndices(); + }); + + describe('UpdateDataFrameAnalytics', () => { + it('should update all editable fields of analytics job for specified id', async () => { + const analyticsId = `${jobId}_0`; + + const requestBody = { + description: editedDescription, + model_memory_limit: '61mb', + allow_lazy_start: true, + max_num_threads: 2, + }; + + const { body } = await supertest + .post(`/api/ml/data_frame/analytics/${analyticsId}/_update`) + .auth(USER.ML_POWERUSER, ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER)) + .set(COMMON_REQUEST_HEADERS) + .send(requestBody) + .expect(200); + + expect(body).not.to.be(undefined); + + const fetchedJob = await getDFAJob(analyticsId); + + expect(fetchedJob.description).to.eql(requestBody.description); + expect(fetchedJob.allow_lazy_start).to.eql(requestBody.allow_lazy_start); + expect(fetchedJob.model_memory_limit).to.eql(requestBody.model_memory_limit); + expect(fetchedJob.max_num_threads).to.eql(requestBody.max_num_threads); + }); + + it('should only update description field of analytics job when description is sent in request', async () => { + const analyticsId = `${jobId}_1`; + + const requestBody = { + description: 'Edited description for job 1', + }; + + const { body } = await supertest + .post(`/api/ml/data_frame/analytics/${analyticsId}/_update`) + .auth(USER.ML_POWERUSER, ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER)) + .set(COMMON_REQUEST_HEADERS) + .send(requestBody) + .expect(200); + + expect(body).not.to.be(undefined); + + const fetchedJob = await getDFAJob(analyticsId); + + expect(fetchedJob.description).to.eql(requestBody.description); + expect(fetchedJob.allow_lazy_start).to.eql(commonJobConfig.allow_lazy_start); + expect(fetchedJob.model_memory_limit).to.eql(commonJobConfig.model_memory_limit); + expect(fetchedJob.max_num_threads).to.eql(commonJobConfig.max_num_threads); + }); + + it('should only update allow_lazy_start field of analytics job when allow_lazy_start is sent in request', async () => { + const analyticsId = `${jobId}_2`; + + const requestBody = { + allow_lazy_start: true, + }; + + const { body } = await supertest + .post(`/api/ml/data_frame/analytics/${analyticsId}/_update`) + .auth(USER.ML_POWERUSER, ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER)) + .set(COMMON_REQUEST_HEADERS) + .send(requestBody) + .expect(200); + + expect(body).not.to.be(undefined); + + const fetchedJob = await getDFAJob(analyticsId); + + expect(fetchedJob.allow_lazy_start).to.eql(requestBody.allow_lazy_start); + expect(fetchedJob.description).to.eql(testJobConfigs[2].description); + expect(fetchedJob.model_memory_limit).to.eql(commonJobConfig.model_memory_limit); + expect(fetchedJob.max_num_threads).to.eql(commonJobConfig.max_num_threads); + }); + + it('should only update model_memory_limit field of analytics job when model_memory_limit is sent in request', async () => { + const analyticsId = `${jobId}_3`; + + const requestBody = { + model_memory_limit: '61mb', + }; + + const { body } = await supertest + .post(`/api/ml/data_frame/analytics/${analyticsId}/_update`) + .auth(USER.ML_POWERUSER, ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER)) + .set(COMMON_REQUEST_HEADERS) + .send(requestBody) + .expect(200); + + expect(body).not.to.be(undefined); + + const fetchedJob = await getDFAJob(analyticsId); + + expect(fetchedJob.model_memory_limit).to.eql(requestBody.model_memory_limit); + expect(fetchedJob.allow_lazy_start).to.eql(commonJobConfig.allow_lazy_start); + expect(fetchedJob.description).to.eql(testJobConfigs[3].description); + expect(fetchedJob.max_num_threads).to.eql(commonJobConfig.max_num_threads); + }); + + it('should only update max_num_threads field of analytics job when max_num_threads is sent in request', async () => { + const analyticsId = `${jobId}_4`; + + const requestBody = { + max_num_threads: 2, + }; + + const { body } = await supertest + .post(`/api/ml/data_frame/analytics/${analyticsId}/_update`) + .auth(USER.ML_POWERUSER, ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER)) + .set(COMMON_REQUEST_HEADERS) + .send(requestBody) + .expect(200); + + expect(body).not.to.be(undefined); + + const fetchedJob = await getDFAJob(analyticsId); + + expect(fetchedJob.max_num_threads).to.eql(requestBody.max_num_threads); + expect(fetchedJob.model_memory_limit).to.eql(commonJobConfig.model_memory_limit); + expect(fetchedJob.allow_lazy_start).to.eql(commonJobConfig.allow_lazy_start); + expect(fetchedJob.description).to.eql(testJobConfigs[4].description); + }); + + it('should not allow to update analytics job for unauthorized user', async () => { + const analyticsId = `${jobId}_0`; + const requestBody = { + description: 'Unauthorized', + }; + + const { body } = await supertest + .post(`/api/ml/data_frame/analytics/${analyticsId}/_update`) + .auth(USER.ML_UNAUTHORIZED, ml.securityCommon.getPasswordForUser(USER.ML_UNAUTHORIZED)) + .set(COMMON_REQUEST_HEADERS) + .send(requestBody) + .expect(404); + + expect(body.error).to.eql('Not Found'); + expect(body.message).to.eql('Not Found'); + + const fetchedJob = await getDFAJob(analyticsId); + // Description should not have changed + expect(fetchedJob.description).to.eql(editedDescription); + }); + + it('should not allow to update analytics job for the user with only view permission', async () => { + const analyticsId = `${jobId}_0`; + const requestBody = { + description: 'View only', + }; + + const { body } = await supertest + .post(`/api/ml/data_frame/analytics/${analyticsId}/_update`) + .auth(USER.ML_VIEWER, ml.securityCommon.getPasswordForUser(USER.ML_VIEWER)) + .set(COMMON_REQUEST_HEADERS) + .send(requestBody) + .expect(404); + + expect(body.error).to.eql('Not Found'); + expect(body.message).to.eql('Not Found'); + + const fetchedJob = await getDFAJob(analyticsId); + // Description should not have changed + expect(fetchedJob.description).to.eql(editedDescription); + }); + + it('should show 404 error if job does not exist', async () => { + const requestBody = { + description: 'Not found', + }; + const id = `${jobId}_invalid`; + const message = `[resource_not_found_exception] No known data frame analytics with id [${id}]`; + + const { body } = await supertest + .post(`/api/ml/data_frame/analytics/${id}/_update`) + .auth(USER.ML_POWERUSER, ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER)) + .set(COMMON_REQUEST_HEADERS) + .send(requestBody) + .expect(404); + + expect(body.error).to.eql('Not Found'); + expect(body.message).to.eql(message); + }); + }); + }); +}; From 1f155dea995b65e3b77b8e4165e4964b340b2fa4 Mon Sep 17 00:00:00 2001 From: spalger Date: Wed, 22 Jul 2020 14:43:12 -0700 Subject: [PATCH 072/142] disable renovate masterIssue --- renovate.json5 | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/renovate.json5 b/renovate.json5 index 67454d266c19..57d0fcb9f8ce 100644 --- a/renovate.json5 +++ b/renovate.json5 @@ -27,8 +27,7 @@ ], }, separateMajorMinor: false, - masterIssue: true, - masterIssueApproval: false, + masterIssue: false, rangeStrategy: 'bump', npm: { lockFileMaintenance: { From f7a1679395396cc2811db0faeb5d8a3837303a99 Mon Sep 17 00:00:00 2001 From: Yara Tercero Date: Wed, 22 Jul 2020 18:21:40 -0400 Subject: [PATCH 073/142] [Security Solution][Exceptions] - Update UI exceptions builder nested logic (#72490) ## Summary This PR is meant to update the exception builder logic to handle nested fields. If you're unfamiliar with nested fields, you can read up more on it [here](https://www.elastic.co/guide/en/elasticsearch/reference/current/nested.html) and [here](https://github.com/elastic/kibana/issues/44554). It also does a bit of cleanup, so though it may look like a lot of changes, parts of it were just moving some things around. --- .../autocomplete/field_value_lists.test.tsx | 85 +- .../autocomplete/field_value_lists.tsx | 17 +- .../autocomplete/field_value_match.tsx | 28 +- .../autocomplete/field_value_match_any.tsx | 33 +- .../components/autocomplete/helpers.test.ts | 62 +- .../common/components/autocomplete/helpers.ts | 15 +- .../components/autocomplete/operator.test.tsx | 48 +- .../components/autocomplete/operator.tsx | 5 +- .../exceptions/add_exception_modal/index.tsx | 1 + .../builder_button_options.stories.tsx | 44 +- .../builder/builder_button_options.test.tsx | 88 +- .../builder/builder_button_options.tsx | 20 +- ...m.test.tsx => builder_entry_item.test.tsx} | 248 ++-- ...{entry_item.tsx => builder_entry_item.tsx} | 180 +-- .../builder/builder_exception_item.test.tsx | 53 +- .../builder/builder_exception_item.tsx | 134 ++- .../exceptions/builder/helpers.test.tsx | 1014 +++++++++++++++++ .../components/exceptions/builder/helpers.tsx | 549 +++++++++ .../components/exceptions/builder/index.tsx | 265 ++++- .../components/exceptions/builder/reducer.ts | 106 ++ .../exceptions/builder/translations.ts | 71 ++ .../exceptions/edit_exception_modal/index.tsx | 1 + .../components/exceptions/helpers.test.tsx | 206 +--- .../common/components/exceptions/helpers.tsx | 162 +-- .../components/exceptions/translations.ts | 39 +- .../common/components/exceptions/types.ts | 21 +- .../exception_item/exception_details.test.tsx | 2 +- .../exception_item/exception_details.tsx | 2 +- .../viewer/exception_item/index.tsx | 3 +- .../exceptions/viewer/helpers.test.tsx | 224 ++++ .../components/exceptions/viewer/helpers.tsx | 109 ++ 31 files changed, 3027 insertions(+), 808 deletions(-) rename x-pack/plugins/security_solution/public/common/components/exceptions/builder/{entry_item.test.tsx => builder_entry_item.test.tsx} (70%) rename x-pack/plugins/security_solution/public/common/components/exceptions/builder/{entry_item.tsx => builder_entry_item.tsx} (62%) create mode 100644 x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/exceptions/builder/reducer.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/exceptions/builder/translations.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/exceptions/viewer/helpers.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/exceptions/viewer/helpers.tsx diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_lists.test.tsx b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_lists.test.tsx index 90e195b6e95a..eca38b9effe1 100644 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_lists.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_lists.test.tsx @@ -52,20 +52,18 @@ describe('AutocompleteFieldListsComponent', () => { selectedField={getField('ip')} selectedValue="some-list-id" isLoading={false} - isClearable={false} - isDisabled={true} + isClearable={true} + isDisabled onChange={jest.fn()} /> ); - await waitFor(() => { - expect( - wrapper - .find(`[data-test-subj="valuesAutocompleteComboBox listsComboxBox"] input`) - .prop('disabled') - ).toBeTruthy(); - }); + expect( + wrapper + .find(`[data-test-subj="valuesAutocompleteComboBox listsComboxBox"] input`) + .prop('disabled') + ).toBeTruthy(); }); test('it renders loading if "isLoading" is true', async () => { @@ -73,9 +71,9 @@ describe('AutocompleteFieldListsComponent', () => { ({ eui: euiLightVars, darkMode: false })}> { ); - await waitFor(() => { + wrapper + .find(`[data-test-subj="valuesAutocompleteComboBox listsComboxBox"] button`) + .at(0) + .simulate('click'); + expect( wrapper - .find(`[data-test-subj="valuesAutocompleteComboBox listsComboxBox"] button`) - .at(0) - .simulate('click'); - expect( - wrapper - .find( - `EuiComboBoxOptionsList[data-test-subj="valuesAutocompleteComboBox listsComboxBox-optionsList"]` - ) - .prop('isLoading') - ).toBeTruthy(); - }); + .find( + `EuiComboBoxOptionsList[data-test-subj="valuesAutocompleteComboBox listsComboxBox-optionsList"]` + ) + .prop('isLoading') + ).toBeTruthy(); }); test('it allows user to clear values if "isClearable" is true', async () => { @@ -104,9 +100,9 @@ describe('AutocompleteFieldListsComponent', () => { @@ -114,9 +110,9 @@ describe('AutocompleteFieldListsComponent', () => { ); expect( wrapper - .find(`[data-test-subj="comboBoxInput"]`) - .hasClass('euiComboBox__inputWrap-isClearable') - ).toBeTruthy(); + .find('EuiComboBox[data-test-subj="valuesAutocompleteComboBox listsComboxBox"]') + .prop('options') + ).toEqual([{ label: 'some name' }]); }); test('it correctly displays lists that match the selected "keyword" field esType', () => { @@ -210,19 +206,24 @@ describe('AutocompleteFieldListsComponent', () => { onChange: (a: EuiComboBoxOptionOption[]) => void; }).onChange([{ label: 'some name' }]); - expect(mockOnChange).toHaveBeenCalledWith({ - created_at: DATE_NOW, - created_by: 'some user', - description: 'some description', - id: 'some-list-id', - meta: {}, - name: 'some name', - tie_breaker_id: '6a76b69d-80df-4ab2-8c3e-85f466b06a0e', - type: 'ip', - updated_at: DATE_NOW, - updated_by: 'some user', - version: VERSION, - immutable: IMMUTABLE, + await waitFor(() => { + expect(mockOnChange).toHaveBeenCalledWith({ + created_at: DATE_NOW, + created_by: 'some user', + description: 'some description', + id: 'some-list-id', + meta: {}, + name: 'some name', + tie_breaker_id: '6a76b69d-80df-4ab2-8c3e-85f466b06a0e', + type: 'ip', + updated_at: DATE_NOW, + updated_by: 'some user', + _version: undefined, + version: VERSION, + deserializer: undefined, + serializer: undefined, + immutable: IMMUTABLE, + }); }); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_lists.tsx b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_lists.tsx index cd90d6eb8562..4349e70594ec 100644 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_lists.tsx +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_lists.tsx @@ -9,7 +9,7 @@ import { EuiComboBoxOptionOption, EuiComboBox } from '@elastic/eui'; import { IFieldType } from '../../../../../../../src/plugins/data/common'; import { useFindLists, ListSchema } from '../../../lists_plugin_deps'; import { useKibana } from '../../../common/lib/kibana'; -import { getGenericComboBoxProps } from './helpers'; +import { getGenericComboBoxProps, paramIsValid } from './helpers'; interface AutocompleteFieldListsProps { placeholder: string; @@ -75,6 +75,8 @@ export const AutocompleteFieldListsComponent: React.FC setIsTouched(true), [setIsTouched]); + useEffect(() => { if (result != null) { setLists(result.data); @@ -91,17 +93,24 @@ export const AutocompleteFieldListsComponent: React.FC paramIsValid(selectedValue, selectedField, isRequired, touched), + [selectedField, selectedValue, isRequired, touched] + ); + + const isLoadingState = useMemo((): boolean => isLoading || loading, [isLoading, loading]); + return ( setIsTouched(true)} + isInvalid={!isValid} + onFocus={setIsTouchedValue} singleSelection={{ asPlainText: true }} sortMatchesBy="startsWith" data-test-subj="valuesAutocompleteComboBox listsComboxBox" diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match.tsx b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match.tsx index 992005b3be8b..137f6803dc54 100644 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match.tsx +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match.tsx @@ -9,7 +9,7 @@ import { uniq } from 'lodash'; import { IFieldType, IIndexPattern } from '../../../../../../../src/plugins/data/common'; import { useFieldValueAutocomplete } from './hooks/use_field_value_autocomplete'; -import { validateParams, getGenericComboBoxProps } from './helpers'; +import { paramIsValid, getGenericComboBoxProps } from './helpers'; import { OperatorTypeEnum } from '../../../lists_plugin_deps'; import { GetGenericComboBoxPropsReturn } from './types'; import * as i18n from './translations'; @@ -82,16 +82,28 @@ export const AutocompleteFieldMatchComponent: React.FC validateParams(selectedValue, selectedField), [ - selectedField, - selectedValue, + const isValid = useMemo( + (): boolean => paramIsValid(selectedValue, selectedField, isRequired, touched), + [selectedField, selectedValue, isRequired, touched] + ); + + const setIsTouchedValue = useCallback((): void => setIsTouched(true), [setIsTouched]); + + const inputPlaceholder = useMemo( + (): string => (isLoading || isLoadingSuggestions ? i18n.LOADING : placeholder), + [isLoading, isLoadingSuggestions, placeholder] + ); + + const isLoadingState = useMemo((): boolean => isLoading || isLoadingSuggestions, [ + isLoading, + isLoadingSuggestions, ]); return ( setIsTouched(true)} + isInvalid={!isValid} + onFocus={setIsTouchedValue} sortMatchesBy="startsWith" data-test-subj="valuesAutocompleteComboBox matchComboxBox" style={fieldInputWidth ? { width: `${fieldInputWidth}px` } : {}} diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match_any.tsx b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match_any.tsx index 27807a752c14..5a15c1f7238d 100644 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match_any.tsx +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/field_value_match_any.tsx @@ -9,7 +9,7 @@ import { uniq } from 'lodash'; import { IFieldType, IIndexPattern } from '../../../../../../../src/plugins/data/common'; import { useFieldValueAutocomplete } from './hooks/use_field_value_autocomplete'; -import { getGenericComboBoxProps, validateParams } from './helpers'; +import { getGenericComboBoxProps, paramIsValid } from './helpers'; import { OperatorTypeEnum } from '../../../lists_plugin_deps'; import { GetGenericComboBoxPropsReturn } from './types'; import * as i18n from './translations'; @@ -78,16 +78,29 @@ export const AutocompleteFieldMatchAnyComponent: React.FC onChange([...(selectedValue || []), option]); const isValid = useMemo((): boolean => { - const areAnyInvalid = selectedComboOptions.filter( - ({ label }) => !validateParams(label, selectedField) - ); - return areAnyInvalid.length === 0; - }, [selectedComboOptions, selectedField]); + const areAnyInvalid = + selectedComboOptions.filter( + ({ label }) => !paramIsValid(label, selectedField, isRequired, touched) + ).length > 0; + return !areAnyInvalid; + }, [selectedComboOptions, selectedField, isRequired, touched]); + + const setIsTouchedValue = useCallback((): void => setIsTouched(true), [setIsTouched]); + + const inputPlaceholder = useMemo( + (): string => (isLoading || isLoadingSuggestions ? i18n.LOADING : placeholder), + [isLoading, isLoadingSuggestions, placeholder] + ); + + const isLoadingState = useMemo((): boolean => isLoading || isLoadingSuggestions, [ + isLoading, + isLoadingSuggestions, + ]); return ( setIsTouched(true)} + isInvalid={!isValid} + onFocus={setIsTouchedValue} delimiter=", " data-test-subj="valuesAutocompleteComboBox matchAnyComboxBox" fullWidth diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.test.ts b/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.test.ts index b25bb245c679..289cdd5e87c0 100644 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.test.ts @@ -14,7 +14,7 @@ import { existsOperator, doesNotExistOperator, } from './operators'; -import { getOperators, validateParams, getGenericComboBoxProps } from './helpers'; +import { getOperators, paramIsValid, getGenericComboBoxProps } from './helpers'; describe('helpers', () => { describe('#getOperators', () => { @@ -53,27 +53,67 @@ describe('helpers', () => { }); }); - describe('#validateParams', () => { - test('returns false if value is undefined', () => { - const isValid = validateParams(undefined, getField('@timestamp')); + describe('#paramIsValid', () => { + test('returns false if value is undefined and "isRequired" nad "touched" are true', () => { + const isValid = paramIsValid(undefined, getField('@timestamp'), true, true); expect(isValid).toBeFalsy(); }); - test('returns false if value is empty string', () => { - const isValid = validateParams('', getField('@timestamp')); + test('returns true if value is undefined and "isRequired" is true but "touched" is false', () => { + const isValid = paramIsValid(undefined, getField('@timestamp'), true, false); - expect(isValid).toBeFalsy(); + expect(isValid).toBeTruthy(); + }); + + test('returns true if value is undefined and "isRequired" is false', () => { + const isValid = paramIsValid(undefined, getField('@timestamp'), false, false); + + expect(isValid).toBeTruthy(); + }); + + test('returns false if value is empty string when "isRequired" is true and "touched" is false', () => { + const isValid = paramIsValid('', getField('@timestamp'), true, false); + + expect(isValid).toBeTruthy(); + }); + + test('returns true if value is empty string and "isRequired" is false', () => { + const isValid = paramIsValid('', getField('@timestamp'), false, false); + + expect(isValid).toBeTruthy(); }); - test('returns true if type is "date" and value is valid', () => { - const isValid = validateParams('1994-11-05T08:15:30-05:00', getField('@timestamp')); + test('returns true if type is "date" and value is valid and "isRequired" is false', () => { + const isValid = paramIsValid( + '1994-11-05T08:15:30-05:00', + getField('@timestamp'), + false, + false + ); expect(isValid).toBeTruthy(); }); - test('returns false if type is "date" and value is not valid', () => { - const isValid = validateParams('1593478826', getField('@timestamp')); + test('returns true if type is "date" and value is valid and "isRequired" is true', () => { + const isValid = paramIsValid( + '1994-11-05T08:15:30-05:00', + getField('@timestamp'), + true, + false + ); + + expect(isValid).toBeTruthy(); + }); + + test('returns false if type is "date" and value is not valid and "isRequired" is false', () => { + const isValid = paramIsValid('1593478826', getField('@timestamp'), false, false); + + expect(isValid).toBeFalsy(); + }); + + test('returns false if type is "date" and value is not valid and "isRequired" is true', () => { + const isValid = paramIsValid('1593478826', getField('@timestamp'), true, true); expect(isValid).toBeFalsy(); }); diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.ts b/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.ts index a65f1fa35d3c..3dcaf612da64 100644 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.ts +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/helpers.ts @@ -30,21 +30,26 @@ export const getOperators = (field: IFieldType | undefined): OperatorOption[] => } }; -export const validateParams = ( +export const paramIsValid = ( params: string | undefined, - field: IFieldType | undefined + field: IFieldType | undefined, + isRequired: boolean, + touched: boolean ): boolean => { - // Box would show error state if empty otherwise - if (params == null || params === '') { + if (isRequired && touched && (params == null || params === '')) { return false; } + if ((isRequired && !touched) || (!isRequired && (params == null || params === ''))) { + return true; + } + const types = field != null && field.esTypes != null ? field.esTypes : []; return types.reduce((acc, type) => { switch (type) { case 'date': - const moment = dateMath.parse(params); + const moment = dateMath.parse(params ?? ''); return Boolean(moment && moment.isValid()); default: return acc; diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/operator.test.tsx b/x-pack/plugins/security_solution/public/common/components/autocomplete/operator.test.tsx index 45fe6be78ace..737be199e248 100644 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/operator.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/operator.test.tsx @@ -74,7 +74,7 @@ describe('OperatorComponent', () => { expect(wrapper.find(`button[data-test-subj="comboBoxClearButton"]`).exists()).toBeTruthy(); }); - test('it displays "operatorOptions" if param is passed in', () => { + test('it displays "operatorOptions" if param is passed in with items', () => { const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> { ).toEqual([{ label: 'is not' }]); }); + test('it does not display "operatorOptions" if param is passed in with no items', () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect( + wrapper.find(`[data-test-subj="operatorAutocompleteComboBox"]`).at(0).prop('options') + ).toEqual([ + { + label: 'is', + }, + { + label: 'is not', + }, + { + label: 'is one of', + }, + { + label: 'is not one of', + }, + { + label: 'exists', + }, + { + label: 'does not exist', + }, + { + label: 'is in list', + }, + { + label: 'is not in list', + }, + ]); + }); + test('it correctly displays selected operator', () => { const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/operator.tsx b/x-pack/plugins/security_solution/public/common/components/autocomplete/operator.tsx index 6d9a684aab2d..cec7d575fc78 100644 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/operator.tsx +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/operator.tsx @@ -35,7 +35,10 @@ export const OperatorComponent: React.FC = ({ }): JSX.Element => { const getLabel = useCallback(({ message }): string => message, []); const optionsMemo = useMemo( - (): OperatorOption[] => (operatorOptions ? operatorOptions : getOperators(selectedField)), + (): OperatorOption[] => + operatorOptions != null && operatorOptions.length > 0 + ? operatorOptions + : getOperators(selectedField), [operatorOptions, selectedField] ); const selectedOptionsMemo = useMemo((): OperatorOption[] => (operator ? [operator] : []), [ diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx index 6e77cd7082d5..2abbaee5187a 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx @@ -307,6 +307,7 @@ export const AddExceptionModal = memo(function AddExceptionModal({ indexPatterns={indexPatterns} isOrDisabled={false} isAndDisabled={false} + isNestedDisabled={false} data-test-subj="alert-exception-builder" id-aria="alert-exception-builder" onChange={handleBuilderOnChange} diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_button_options.stories.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_button_options.stories.tsx index 9486008e708e..5ca2d2b86a52 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_button_options.stories.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_button_options.stories.tsx @@ -21,22 +21,43 @@ storiesOf('Components|Exceptions|BuilderButtonOptions', module) ); }) - .add('nested button', () => { + .add('nested button - isNested false', () => { return ( + ); + }) + .add('nested button - isNested true', () => { + return ( + ); }) @@ -45,10 +66,13 @@ storiesOf('Components|Exceptions|BuilderButtonOptions', module) ); }) @@ -57,10 +81,28 @@ storiesOf('Components|Exceptions|BuilderButtonOptions', module) + ); + }) + .add('nested disabled', () => { + return ( + ); }); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_button_options.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_button_options.test.tsx index 66968ee95d3f..6564770196b8 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_button_options.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_button_options.test.tsx @@ -15,10 +15,13 @@ describe('BuilderButtonOptions', () => { ); @@ -37,10 +40,13 @@ describe('BuilderButtonOptions', () => { ); @@ -49,17 +55,20 @@ describe('BuilderButtonOptions', () => { expect(onOrClicked).toHaveBeenCalledTimes(1); }); - test('it invokes "onAndClicked" when "and" button is clicked', () => { + test('it invokes "onAndClicked" when "and" button is clicked and "isNested" is "false"', () => { const onAndClicked = jest.fn(); const wrapper = mount( ); @@ -68,15 +77,40 @@ describe('BuilderButtonOptions', () => { expect(onAndClicked).toHaveBeenCalledTimes(1); }); + test('it invokes "onAddClickWhenNested" when "and" button is clicked and "isNested" is "true"', () => { + const onAddClickWhenNested = jest.fn(); + + const wrapper = mount( + + ); + + wrapper.find('[data-test-subj="exceptionsAndButton"] button').simulate('click'); + + expect(onAddClickWhenNested).toHaveBeenCalledTimes(1); + }); + test('it disables "and" button if "isAndDisabled" is true', () => { const wrapper = mount( ); @@ -85,15 +119,18 @@ describe('BuilderButtonOptions', () => { expect(andButton.prop('disabled')).toBeTruthy(); }); - test('it disables "or" button if "isOrDisabled" is true', () => { + test('it disables "or" button if "isOrDisabled" is "true"', () => { const wrapper = mount( ); @@ -102,17 +139,40 @@ describe('BuilderButtonOptions', () => { expect(orButton.prop('disabled')).toBeTruthy(); }); - test('it invokes "onNestedClicked" when "and" button is clicked', () => { + test('it disables "add nested" button if "isNestedDisabled" is "true"', () => { + const wrapper = mount( + + ); + + const nestedButton = wrapper.find('[data-test-subj="exceptionsNestedButton"] button').at(0); + + expect(nestedButton.prop('disabled')).toBeTruthy(); + }); + + test('it invokes "onNestedClicked" when "isNested" is "false" and "nested" button is clicked', () => { const onNestedClicked = jest.fn(); const wrapper = mount( ); @@ -120,4 +180,26 @@ describe('BuilderButtonOptions', () => { expect(onNestedClicked).toHaveBeenCalledTimes(1); }); + + test('it invokes "onAndClicked" when "isNested" is "true" and "nested" button is clicked', () => { + const onAndClicked = jest.fn(); + + const wrapper = mount( + + ); + + wrapper.find('[data-test-subj="exceptionsNestedButton"] button').simulate('click'); + + expect(onAndClicked).toHaveBeenCalledTimes(1); + }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_button_options.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_button_options.tsx index eb224b82d756..bef47ce877b9 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_button_options.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_button_options.tsx @@ -7,7 +7,8 @@ import React from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiButton } from '@elastic/eui'; import styled from 'styled-components'; -import * as i18n from '../translations'; +import * as i18n from './translations'; +import * as i18nShared from '../translations'; const MyEuiButton = styled(EuiButton)` min-width: 95px; @@ -16,19 +17,25 @@ const MyEuiButton = styled(EuiButton)` interface BuilderButtonOptionsProps { isOrDisabled: boolean; isAndDisabled: boolean; + isNestedDisabled: boolean; + isNested: boolean; showNestedButton: boolean; onAndClicked: () => void; onOrClicked: () => void; onNestedClicked: () => void; + onAddClickWhenNested: () => void; } export const BuilderButtonOptions: React.FC = ({ isOrDisabled = false, isAndDisabled = false, showNestedButton = false, + isNestedDisabled = true, + isNested, onAndClicked, onOrClicked, onNestedClicked, + onAddClickWhenNested, }) => ( @@ -36,11 +43,11 @@ export const BuilderButtonOptions: React.FC = ({ fill size="s" iconType="plusInCircle" - onClick={onAndClicked} + onClick={isNested ? onAddClickWhenNested : onAndClicked} data-test-subj="exceptionsAndButton" isDisabled={isAndDisabled} > - {i18n.AND} + {i18nShared.AND} @@ -52,7 +59,7 @@ export const BuilderButtonOptions: React.FC = ({ isDisabled={isOrDisabled} data-test-subj="exceptionsOrButton" > - {i18n.OR} + {i18nShared.OR} {showNestedButton && ( @@ -60,10 +67,11 @@ export const BuilderButtonOptions: React.FC = ({ - {i18n.ADD_NESTED_DESCRIPTION} + {isNested ? i18n.ADD_NON_NESTED_DESCRIPTION : i18n.ADD_NESTED_DESCRIPTION} )} diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/entry_item.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_entry_item.test.tsx similarity index 70% rename from x-pack/plugins/security_solution/public/common/components/exceptions/builder/entry_item.test.tsx rename to x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_entry_item.test.tsx index 791782b0f015..b845848bd14d 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/entry_item.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_entry_item.test.tsx @@ -8,7 +8,7 @@ import { mount } from 'enzyme'; import React from 'react'; import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; -import { EntryItemComponent } from './entry_item'; +import { BuilderEntryItem } from './builder_entry_item'; import { isOperator, isNotOperator, @@ -44,47 +44,26 @@ jest.mock('../../../../lists_plugin_deps', () => { }; }); -describe('EntryItemComponent', () => { - test('it renders fields disabled if "isLoading" is "true"', () => { - const wrapper = mount( - - ); - - expect( - wrapper.find('[data-test-subj="exceptionBuilderEntryField"] input').props().disabled - ).toBeTruthy(); - expect( - wrapper.find('[data-test-subj="exceptionBuilderEntryOperator"] input').props().disabled - ).toBeTruthy(); - expect( - wrapper.find('[data-test-subj="exceptionBuilderEntryFieldMatch"] input').props().disabled - ).toBeTruthy(); - expect(wrapper.find('[data-test-subj="exceptionBuilderEntryFieldFormRow"]')).toHaveLength(0); - }); - +describe('BuilderEntryItem', () => { test('it renders field labels if "showLabel" is "true"', () => { const wrapper = mount( - ); @@ -94,16 +73,23 @@ describe('EntryItemComponent', () => { test('it renders field values correctly when operator is "isOperator"', () => { const wrapper = mount( - ); @@ -117,16 +103,23 @@ describe('EntryItemComponent', () => { test('it renders field values correctly when operator is "isNotOperator"', () => { const wrapper = mount( - ); @@ -142,16 +135,23 @@ describe('EntryItemComponent', () => { test('it renders field values correctly when operator is "isOneOfOperator"', () => { const wrapper = mount( - ); @@ -167,16 +167,23 @@ describe('EntryItemComponent', () => { test('it renders field values correctly when operator is "isNotOneOfOperator"', () => { const wrapper = mount( - ); @@ -192,16 +199,23 @@ describe('EntryItemComponent', () => { test('it renders field values correctly when operator is "isInListOperator"', () => { const wrapper = mount( - ); @@ -217,16 +231,23 @@ describe('EntryItemComponent', () => { test('it renders field values correctly when operator is "isNotInListOperator"', () => { const wrapper = mount( - ); @@ -242,16 +263,23 @@ describe('EntryItemComponent', () => { test('it renders field values correctly when operator is "existsOperator"', () => { const wrapper = mount( - ); @@ -270,16 +298,23 @@ describe('EntryItemComponent', () => { test('it renders field values correctly when operator is "doesNotExistOperator"', () => { const wrapper = mount( - ); @@ -299,16 +334,23 @@ describe('EntryItemComponent', () => { test('it invokes "onChange" when new field is selected and resets operator and value fields', () => { const mockOnChange = jest.fn(); const wrapper = mount( - ); @@ -318,24 +360,31 @@ describe('EntryItemComponent', () => { }).onChange([{ label: 'machine.os' }]); expect(mockOnChange).toHaveBeenCalledWith( - { field: 'machine.os', operator: 'included', type: 'match', value: undefined }, + { field: 'machine.os', operator: 'included', type: 'match', value: '' }, 0 ); }); - test('it invokes "onChange" when new operator is selected and resets value field', () => { + test('it invokes "onChange" when new operator is selected', () => { const mockOnChange = jest.fn(); const wrapper = mount( - ); @@ -345,7 +394,7 @@ describe('EntryItemComponent', () => { }).onChange([{ label: 'is not' }]); expect(mockOnChange).toHaveBeenCalledWith( - { field: 'ip', operator: 'excluded', type: 'match', value: '' }, + { field: 'ip', operator: 'excluded', type: 'match', value: '1234' }, 0 ); }); @@ -353,16 +402,23 @@ describe('EntryItemComponent', () => { test('it invokes "onChange" when new value field is entered for match operator', () => { const mockOnChange = jest.fn(); const wrapper = mount( - ); @@ -380,16 +436,23 @@ describe('EntryItemComponent', () => { test('it invokes "onChange" when new value field is entered for match_any operator', () => { const mockOnChange = jest.fn(); const wrapper = mount( - ); @@ -407,16 +470,23 @@ describe('EntryItemComponent', () => { test('it invokes "onChange" when new value field is entered for list operator', () => { const mockOnChange = jest.fn(); const wrapper = mount( - ); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/entry_item.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_entry_item.tsx similarity index 62% rename from x-pack/plugins/security_solution/public/common/components/exceptions/builder/entry_item.tsx rename to x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_entry_item.tsx index 7bf279168a9a..736e88ee9fe0 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/entry_item.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_entry_item.tsx @@ -9,136 +9,135 @@ import { EuiFormRow, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { IFieldType, IIndexPattern } from '../../../../../../../../src/plugins/data/common'; import { FieldComponent } from '../../autocomplete/field'; import { OperatorComponent } from '../../autocomplete/operator'; -import { isOperator } from '../../autocomplete/operators'; import { OperatorOption } from '../../autocomplete/types'; import { AutocompleteFieldMatchComponent } from '../../autocomplete/field_value_match'; import { AutocompleteFieldMatchAnyComponent } from '../../autocomplete/field_value_match_any'; import { AutocompleteFieldExistsComponent } from '../../autocomplete/field_value_exists'; import { FormattedBuilderEntry, BuilderEntry } from '../types'; import { AutocompleteFieldListsComponent } from '../../autocomplete/field_value_lists'; -import { ListSchema, OperatorTypeEnum } from '../../../../lists_plugin_deps'; -import { getValueFromOperator } from '../helpers'; +import { ListSchema, OperatorTypeEnum, ExceptionListType } from '../../../../lists_plugin_deps'; import { getEmptyValue } from '../../empty_value'; -import * as i18n from '../translations'; +import * as i18n from './translations'; +import { + getFilteredIndexPatterns, + getOperatorOptions, + getEntryOnFieldChange, + getEntryOnOperatorChange, + getEntryOnMatchChange, + getEntryOnMatchAnyChange, + getEntryOnListChange, +} from './helpers'; interface EntryItemProps { entry: FormattedBuilderEntry; - entryIndex: number; indexPattern: IIndexPattern; - isLoading: boolean; showLabel: boolean; + listType: ExceptionListType; + addNested: boolean; onChange: (arg: BuilderEntry, i: number) => void; } -export const EntryItemComponent: React.FC = ({ +export const BuilderEntryItem: React.FC = ({ entry, - entryIndex, indexPattern, - isLoading, + listType, + addNested, showLabel, onChange, }): JSX.Element => { const handleFieldChange = useCallback( ([newField]: IFieldType[]): void => { - onChange( - { - field: newField.name, - type: OperatorTypeEnum.MATCH, - operator: isOperator.operator, - value: undefined, - }, - entryIndex - ); + const { updatedEntry, index } = getEntryOnFieldChange(entry, newField); + + onChange(updatedEntry, index); }, - [onChange, entryIndex] + [onChange, entry] ); const handleOperatorChange = useCallback( ([newOperator]: OperatorOption[]): void => { - const newEntry = getValueFromOperator(entry.field, newOperator); - onChange(newEntry, entryIndex); + const { updatedEntry, index } = getEntryOnOperatorChange(entry, newOperator); + + onChange(updatedEntry, index); }, - [onChange, entryIndex, entry.field] + [onChange, entry] ); const handleFieldMatchValueChange = useCallback( (newField: string): void => { - onChange( - { - field: entry.field != null ? entry.field.name : undefined, - type: OperatorTypeEnum.MATCH, - operator: entry.operator.operator, - value: newField, - }, - entryIndex - ); + const { updatedEntry, index } = getEntryOnMatchChange(entry, newField); + + onChange(updatedEntry, index); }, - [onChange, entryIndex, entry.field, entry.operator.operator] + [onChange, entry] ); const handleFieldMatchAnyValueChange = useCallback( (newField: string[]): void => { - onChange( - { - field: entry.field != null ? entry.field.name : undefined, - type: OperatorTypeEnum.MATCH_ANY, - operator: entry.operator.operator, - value: newField, - }, - entryIndex - ); + const { updatedEntry, index } = getEntryOnMatchAnyChange(entry, newField); + + onChange(updatedEntry, index); }, - [onChange, entryIndex, entry.field, entry.operator.operator] + [onChange, entry] ); const handleFieldListValueChange = useCallback( (newField: ListSchema): void => { - onChange( - { - field: entry.field != null ? entry.field.name : undefined, - type: OperatorTypeEnum.LIST, - operator: entry.operator.operator, - list: { id: newField.id, type: newField.type }, - }, - entryIndex - ); + const { updatedEntry, index } = getEntryOnListChange(entry, newField); + + onChange(updatedEntry, index); }, - [onChange, entryIndex, entry.field, entry.operator.operator] + [onChange, entry] ); - const renderFieldInput = (isFirst: boolean): JSX.Element => { - const comboBox = ( - - ); - - if (isFirst) { - return ( - - {comboBox} - + const renderFieldInput = useCallback( + (isFirst: boolean): JSX.Element => { + const filteredIndexPatterns = getFilteredIndexPatterns(indexPattern, entry); + const comboBox = ( + ); - } else { - return comboBox; - } - }; + + if (isFirst) { + return ( + + {comboBox} + + ); + } else { + return comboBox; + } + }, + [handleFieldChange, indexPattern, entry] + ); const renderOperatorInput = (isFirst: boolean): JSX.Element => { + const operatorOptions = getOperatorOptions( + entry, + listType, + entry.field != null && entry.field.type === 'boolean' + ); const comboBox = ( = ({ placeholder={i18n.EXCEPTION_FIELD_VALUE_PLACEHOLDER} selectedField={entry.field} selectedValue={value} - isDisabled={isLoading} - isLoading={isLoading} + isDisabled={ + indexPattern == null || (indexPattern != null && indexPattern.fields.length === 0) + } + isLoading={false} isClearable={false} indexPattern={indexPattern} onChange={handleFieldMatchValueChange} @@ -182,8 +183,10 @@ export const EntryItemComponent: React.FC = ({ placeholder={i18n.EXCEPTION_FIELD_VALUE_PLACEHOLDER} selectedField={entry.field} selectedValue={values} - isDisabled={isLoading} - isLoading={isLoading} + isDisabled={ + indexPattern == null || (indexPattern != null && indexPattern.fields.length === 0) + } + isLoading={false} isClearable={false} indexPattern={indexPattern} onChange={handleFieldMatchAnyValueChange} @@ -198,8 +201,10 @@ export const EntryItemComponent: React.FC = ({ selectedField={entry.field} placeholder={i18n.EXCEPTION_FIELD_LISTS_PLACEHOLDER} selectedValue={id} - isLoading={isLoading} - isDisabled={isLoading} + isLoading={false} + isDisabled={ + indexPattern == null || (indexPattern != null && indexPattern.fields.length === 0) + } isClearable={false} onChange={handleFieldListValueChange} isRequired @@ -240,9 +245,14 @@ export const EntryItemComponent: React.FC = ({ > {renderFieldInput(showLabel)} {renderOperatorInput(showLabel)} - {renderFieldValueInput(showLabel, entry.operator.type)} + + {renderFieldValueInput( + showLabel, + entry.nested === 'parent' ? OperatorTypeEnum.EXISTS : entry.operator.type + )} +
); }; -EntryItemComponent.displayName = 'EntryItem'; +BuilderEntryItem.displayName = 'BuilderEntryItem'; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_exception_item.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_exception_item.test.tsx index 0f3b6ec2e94e..584f0971a419 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_exception_item.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_exception_item.test.tsx @@ -18,8 +18,10 @@ import { getEntryMatchAnyMock } from '../../../../../../lists/common/schemas/typ describe('ExceptionListItemComponent', () => { describe('and badge logic', () => { test('it renders "and" badge with extra top padding for the first exception item when "andLogicIncluded" is "true"', () => { - const exceptionItem = getExceptionListItemSchemaMock(); - exceptionItem.entries = [getEntryMatchMock(), getEntryMatchMock()]; + const exceptionItem = { + ...getExceptionListItemSchemaMock(), + entries: [getEntryMatchMock(), getEntryMatchMock()], + }; const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> { title: 'logstash-*', fields, }} - isLoading={false} andLogicIncluded={true} isOnlyItem={false} + listType="detection" + addNested={false} onDeleteExceptionItem={jest.fn()} onChangeExceptionItem={jest.fn()} /> @@ -46,7 +49,7 @@ describe('ExceptionListItemComponent', () => { }); test('it renders "and" badge when more than one exception item entry exists and it is not the first exception item', () => { - const exceptionItem = getExceptionListItemSchemaMock(); + const exceptionItem = { ...getExceptionListItemSchemaMock() }; exceptionItem.entries = [getEntryMatchMock(), getEntryMatchMock()]; const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> @@ -59,9 +62,10 @@ describe('ExceptionListItemComponent', () => { title: 'logstash-*', fields, }} - isLoading={false} andLogicIncluded={true} isOnlyItem={false} + listType="detection" + addNested={false} onDeleteExceptionItem={jest.fn()} onChangeExceptionItem={jest.fn()} /> @@ -72,7 +76,7 @@ describe('ExceptionListItemComponent', () => { }); test('it renders indented "and" badge when "andLogicIncluded" is "true" and only one entry exists', () => { - const exceptionItem = getExceptionListItemSchemaMock(); + const exceptionItem = { ...getExceptionListItemSchemaMock() }; exceptionItem.entries = [getEntryMatchMock()]; const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> @@ -85,9 +89,10 @@ describe('ExceptionListItemComponent', () => { title: 'logstash-*', fields, }} - isLoading={false} andLogicIncluded={true} isOnlyItem={false} + listType="detection" + addNested={false} onDeleteExceptionItem={jest.fn()} onChangeExceptionItem={jest.fn()} /> @@ -100,7 +105,7 @@ describe('ExceptionListItemComponent', () => { }); test('it renders no "and" badge when "andLogicIncluded" is "false"', () => { - const exceptionItem = getExceptionListItemSchemaMock(); + const exceptionItem = { ...getExceptionListItemSchemaMock() }; exceptionItem.entries = [getEntryMatchMock()]; const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> @@ -113,9 +118,10 @@ describe('ExceptionListItemComponent', () => { title: 'logstash-*', fields, }} - isLoading={false} andLogicIncluded={false} isOnlyItem={false} + listType="detection" + addNested={false} onDeleteExceptionItem={jest.fn()} onChangeExceptionItem={jest.fn()} /> @@ -134,8 +140,10 @@ describe('ExceptionListItemComponent', () => { describe('delete button logic', () => { test('it renders delete button disabled when it is only entry left in builder', () => { - const exceptionItem = getExceptionListItemSchemaMock(); - exceptionItem.entries = [getEntryMatchMock()]; + const exceptionItem = { + ...getExceptionListItemSchemaMock(), + entries: [{ ...getEntryMatchMock(), field: '' }], + }; const wrapper = mount( { title: 'logstash-*', fields, }} - isLoading={false} andLogicIncluded={false} isOnlyItem={true} + listType="detection" + addNested={false} onDeleteExceptionItem={jest.fn()} onChangeExceptionItem={jest.fn()} /> @@ -160,7 +169,7 @@ describe('ExceptionListItemComponent', () => { }); test('it does not render delete button disabled when it is not the only entry left in builder', () => { - const exceptionItem = getExceptionListItemSchemaMock(); + const exceptionItem = { ...getExceptionListItemSchemaMock() }; exceptionItem.entries = [getEntryMatchMock()]; const wrapper = mount( @@ -173,9 +182,10 @@ describe('ExceptionListItemComponent', () => { title: 'logstash-*', fields, }} - isLoading={false} andLogicIncluded={false} isOnlyItem={false} + listType="detection" + addNested={false} onDeleteExceptionItem={jest.fn()} onChangeExceptionItem={jest.fn()} /> @@ -187,7 +197,7 @@ describe('ExceptionListItemComponent', () => { }); test('it does not render delete button disabled when "exceptionItemIndex" is not "0"', () => { - const exceptionItem = getExceptionListItemSchemaMock(); + const exceptionItem = { ...getExceptionListItemSchemaMock() }; exceptionItem.entries = [getEntryMatchMock()]; const wrapper = mount( { title: 'logstash-*', fields, }} - isLoading={false} andLogicIncluded={false} // if exceptionItemIndex is not 0, wouldn't make sense for // this to be true, but done for testing purposes isOnlyItem={true} + listType="detection" + addNested={false} onDeleteExceptionItem={jest.fn()} onChangeExceptionItem={jest.fn()} /> @@ -215,7 +226,7 @@ describe('ExceptionListItemComponent', () => { }); test('it does not render delete button disabled when more than one entry exists', () => { - const exceptionItem = getExceptionListItemSchemaMock(); + const exceptionItem = { ...getExceptionListItemSchemaMock() }; exceptionItem.entries = [getEntryMatchMock(), getEntryMatchMock()]; const wrapper = mount( { title: 'logstash-*', fields, }} - isLoading={false} andLogicIncluded={false} isOnlyItem={true} + listType="detection" + addNested={false} onDeleteExceptionItem={jest.fn()} onChangeExceptionItem={jest.fn()} /> @@ -243,7 +255,7 @@ describe('ExceptionListItemComponent', () => { test('it invokes "onChangeExceptionItem" when delete button clicked', () => { const mockOnDeleteExceptionItem = jest.fn(); - const exceptionItem = getExceptionListItemSchemaMock(); + const exceptionItem = { ...getExceptionListItemSchemaMock() }; exceptionItem.entries = [getEntryMatchMock(), getEntryMatchAnyMock()]; const wrapper = mount( { title: 'logstash-*', fields, }} - isLoading={false} andLogicIncluded={false} isOnlyItem={true} + listType="detection" + addNested={false} onDeleteExceptionItem={mockOnDeleteExceptionItem} onChangeExceptionItem={jest.fn()} /> diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_exception_item.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_exception_item.tsx index 8e57e83d0e7e..dce78f3cb9ce 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_exception_item.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_exception_item.tsx @@ -10,9 +10,10 @@ import styled from 'styled-components'; import { IIndexPattern } from '../../../../../../../../src/plugins/data/common'; import { AndOrBadge } from '../../and_or_badge'; -import { EntryItemComponent } from './entry_item'; -import { getFormattedBuilderEntries } from '../helpers'; +import { BuilderEntryItem } from './builder_entry_item'; +import { getFormattedBuilderEntries, getUpdatedEntriesOnDelete } from './helpers'; import { FormattedBuilderEntry, ExceptionsBuilderExceptionItem, BuilderEntry } from '../types'; +import { ExceptionListType } from '../../../../../public/lists_plugin_deps'; const MyInvisibleAndBadge = styled(EuiFlexItem)` visibility: hidden; @@ -22,14 +23,25 @@ const MyFirstRowContainer = styled(EuiFlexItem)` padding-top: 20px; `; +const MyBeautifulLine = styled(EuiFlexItem)` + &:after { + background: ${({ theme }) => theme.eui.euiColorLightShade}; + content: ''; + width: 2px; + height: 40px; + margin: 0 15px; + } +`; + interface ExceptionListItemProps { exceptionItem: ExceptionsBuilderExceptionItem; exceptionId: string; exceptionItemIndex: number; - isLoading: boolean; indexPattern: IIndexPattern; andLogicIncluded: boolean; isOnlyItem: boolean; + listType: ExceptionListType; + addNested: boolean; onDeleteExceptionItem: (item: ExceptionsBuilderExceptionItem, index: number) => void; onChangeExceptionItem: (item: ExceptionsBuilderExceptionItem, index: number) => void; } @@ -40,8 +52,9 @@ export const ExceptionListItemComponent = React.memo( exceptionId, exceptionItemIndex, indexPattern, - isLoading, isOnlyItem, + listType, + addNested, andLogicIncluded, onDeleteExceptionItem, onChangeExceptionItem, @@ -63,15 +76,12 @@ export const ExceptionListItemComponent = React.memo( ); const handleDeleteEntry = useCallback( - (entryIndex: number): void => { - const updatedEntries: BuilderEntry[] = [ - ...exceptionItem.entries.slice(0, entryIndex), - ...exceptionItem.entries.slice(entryIndex + 1), - ]; - const updatedExceptionItem: ExceptionsBuilderExceptionItem = { - ...exceptionItem, - entries: updatedEntries, - }; + (entryIndex: number, parentIndex: number | null): void => { + const updatedExceptionItem = getUpdatedEntriesOnDelete( + exceptionItem, + parentIndex ? parentIndex : entryIndex, + parentIndex ? entryIndex : null + ); onDeleteExceptionItem(updatedExceptionItem, exceptionItemIndex); }, @@ -80,80 +90,98 @@ export const ExceptionListItemComponent = React.memo( const entries = useMemo( (): FormattedBuilderEntry[] => - indexPattern != null ? getFormattedBuilderEntries(indexPattern, exceptionItem.entries) : [], - [indexPattern, exceptionItem] + indexPattern != null && exceptionItem.entries.length > 0 + ? getFormattedBuilderEntries(indexPattern, exceptionItem.entries) + : [], + [exceptionItem.entries, indexPattern] ); - const andBadge = useMemo((): JSX.Element => { + const getAndBadge = useCallback((): JSX.Element => { const badge = ; - if (entries.length > 1 && exceptionItemIndex === 0) { + + if (andLogicIncluded && exceptionItem.entries.length > 1 && exceptionItemIndex === 0) { return ( {badge} ); - } else if (entries.length > 1) { + } else if (andLogicIncluded && exceptionItem.entries.length <= 1) { return ( - + {badge} - + ); - } else { + } else if (andLogicIncluded && exceptionItem.entries.length > 1) { return ( - + {badge} - + ); + } else { + return <>; } - }, [entries.length, exceptionItemIndex]); + }, [exceptionItem.entries.length, exceptionItemIndex, andLogicIncluded]); const getDeleteButton = useCallback( - (index: number): JSX.Element => { + (entryIndex: number, parentIndex: number | null): JSX.Element => { const button = ( handleDeleteEntry(index)} - isDisabled={isOnlyItem && entries.length === 1 && exceptionItemIndex === 0} + onClick={() => handleDeleteEntry(entryIndex, parentIndex)} + isDisabled={ + isOnlyItem && + exceptionItem.entries.length === 1 && + exceptionItemIndex === 0 && + (exceptionItem.entries[0].field == null || exceptionItem.entries[0].field === '') + } aria-label="entryDeleteButton" className="exceptionItemEntryDeleteButton" data-test-subj="exceptionItemEntryDeleteButton" /> ); - if (index === 0 && exceptionItemIndex === 0) { + if (entryIndex === 0 && exceptionItemIndex === 0 && parentIndex == null) { return {button}; } else { return {button}; } }, - [entries.length, exceptionItemIndex, handleDeleteEntry, isOnlyItem] + [exceptionItemIndex, exceptionItem.entries, handleDeleteEntry, isOnlyItem] ); return ( - - {andLogicIncluded && andBadge} - - - {entries.map((item, index) => ( - - - - - - {getDeleteButton(index)} - - - ))} - - - + + + {getAndBadge()} + + + {entries.map((item, index) => ( + + + {item.nested === 'child' && } + + + + {getDeleteButton( + item.entryIndex, + item.parent != null ? item.parent.parentIndex : null + )} + + + ))} + + + + ); } ); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.test.tsx new file mode 100644 index 000000000000..8b74d44f29a1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.test.tsx @@ -0,0 +1,1014 @@ +/* + * 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 { + fields, + getField, +} from '../../../../../../../../src/plugins/data/common/index_patterns/fields/fields.mocks.ts'; +import { getEntryNestedMock } from '../../../../../../lists/common/schemas/types/entry_nested.mock'; +import { getEntryMatchMock } from '../../../../../../lists/common/schemas/types/entry_match.mock'; +import { getEntryMatchAnyMock } from '../../../../../../lists/common/schemas/types/entry_match_any.mock'; +import { getEntryExistsMock } from '../../../../../../lists/common/schemas/types/entry_exists.mock'; +import { getExceptionListItemSchemaMock } from '../../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; +import { getListResponseMock } from '../../../../../../lists/common/schemas/response/list_schema.mock'; +import { + isOperator, + isOneOfOperator, + isNotOperator, + isNotOneOfOperator, + existsOperator, + doesNotExistOperator, + isInListOperator, + EXCEPTION_OPERATORS, +} from '../../autocomplete/operators'; +import { FormattedBuilderEntry, BuilderEntry, ExceptionsBuilderExceptionItem } from '../types'; +import { IIndexPattern, IFieldType } from '../../../../../../../../src/plugins/data/common'; +import { EntryNested, Entry } from '../../../../lists_plugin_deps'; + +import { + getFilteredIndexPatterns, + getFormattedBuilderEntry, + isEntryNested, + getFormattedBuilderEntries, + getUpdatedEntriesOnDelete, + getEntryFromOperator, + getOperatorOptions, + getEntryOnFieldChange, + getEntryOnOperatorChange, + getEntryOnMatchChange, + getEntryOnMatchAnyChange, + getEntryOnListChange, +} from './helpers'; +import { OperatorOption } from '../../autocomplete/types'; + +const getMockIndexPattern = (): IIndexPattern => ({ + id: '1234', + title: 'logstash-*', + fields, +}); + +const getMockBuilderEntry = (): FormattedBuilderEntry => ({ + field: getField('ip'), + operator: isOperator, + value: 'some value', + nested: undefined, + parent: undefined, + entryIndex: 0, +}); + +const getMockNestedBuilderEntry = (): FormattedBuilderEntry => ({ + field: getField('nestedField.child'), + operator: isOperator, + value: 'some value', + nested: 'child', + parent: { + parent: { + ...getEntryNestedMock(), + field: 'nestedField', + entries: [{ ...getEntryMatchMock(), field: 'child' }], + }, + parentIndex: 0, + }, + entryIndex: 0, +}); + +const getMockNestedParentBuilderEntry = (): FormattedBuilderEntry => ({ + field: { ...getField('nestedField.child'), name: 'nestedField', esTypes: ['nested'] }, + operator: isOperator, + value: undefined, + nested: 'parent', + parent: undefined, + entryIndex: 0, +}); + +describe('Exception builder helpers', () => { + describe('#getFilteredIndexPatterns', () => { + test('it returns nested fields that match parent value when "item.nested" is "child"', () => { + const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); + const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry(); + const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem); + const expected: IIndexPattern = { + fields: [ + { ...getField('nestedField.child') }, + { ...getField('nestedField.nestedChild.doublyNestedChild') }, + ], + id: '1234', + title: 'logstash-*', + }; + expect(output).toEqual(expected); + }); + + test('it returns only parent nested field when "item.nested" is "parent" and nested parent field is not undefined', () => { + const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); + const payloadItem: FormattedBuilderEntry = getMockNestedParentBuilderEntry(); + const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem); + const expected: IIndexPattern = { + fields: [{ ...getField('nestedField.child'), name: 'nestedField', esTypes: ['nested'] }], + id: '1234', + title: 'logstash-*', + }; + expect(output).toEqual(expected); + }); + + test('it returns only nested fields when "item.nested" is "parent" and nested parent field is undefined', () => { + const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); + const payloadItem: FormattedBuilderEntry = { + ...getMockNestedParentBuilderEntry(), + field: undefined, + }; + const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem); + const expected: IIndexPattern = { + fields: [ + { ...getField('nestedField.child') }, + { ...getField('nestedField.nestedChild.doublyNestedChild') }, + ], + id: '1234', + title: 'logstash-*', + }; + expect(output).toEqual(expected); + }); + + test('it returns all fields unfiletered if "item.nested" is not "child" or "parent"', () => { + const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); + const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); + const output = getFilteredIndexPatterns(payloadIndexPattern, payloadItem); + const expected: IIndexPattern = { + fields: [...fields], + id: '1234', + title: 'logstash-*', + }; + expect(output).toEqual(expected); + }); + }); + + describe('#getFormattedBuilderEntry', () => { + test('it returns "FormattedBuilderEntry" with value "nested" of "child" when "parent" and "parentIndex" are defined', () => { + const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); + const payloadItem: BuilderEntry = { ...getEntryMatchMock(), field: 'child' }; + const payloadParent: EntryNested = { + ...getEntryNestedMock(), + field: 'nestedField', + entries: [{ ...getEntryMatchMock(), field: 'child' }], + }; + const output = getFormattedBuilderEntry( + payloadIndexPattern, + payloadItem, + 0, + payloadParent, + 1 + ); + const expected: FormattedBuilderEntry = { + entryIndex: 0, + field: { + aggregatable: false, + count: 0, + esTypes: ['text'], + name: 'nestedField.child', + readFromDocValues: false, + scripted: false, + searchable: true, + subType: { + nested: { + path: 'nestedField', + }, + }, + type: 'string', + }, + nested: 'child', + operator: isOperator, + parent: { + parent: { + entries: [{ ...payloadItem }], + field: 'nestedField', + type: 'nested', + }, + parentIndex: 1, + }, + value: 'some host name', + }; + expect(output).toEqual(expected); + }); + + test('it returns non nested "FormattedBuilderEntry" when "parent" and "parentIndex" are not defined', () => { + const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); + const payloadItem: BuilderEntry = { ...getEntryMatchMock(), field: 'ip', value: 'some ip' }; + const output = getFormattedBuilderEntry( + payloadIndexPattern, + payloadItem, + 0, + undefined, + undefined + ); + const expected: FormattedBuilderEntry = { + entryIndex: 0, + field: { + aggregatable: true, + count: 0, + esTypes: ['ip'], + name: 'ip', + readFromDocValues: true, + scripted: false, + searchable: true, + type: 'ip', + }, + nested: undefined, + operator: isOperator, + parent: undefined, + value: 'some ip', + }; + expect(output).toEqual(expected); + }); + }); + + describe('#isEntryNested', () => { + test('it returns "false" if payload is not of type EntryNested', () => { + const payload: BuilderEntry = { ...getEntryMatchMock() }; + const output = isEntryNested(payload); + const expected = false; + expect(output).toEqual(expected); + }); + + test('it returns "true if payload is of type EntryNested', () => { + const payload: EntryNested = getEntryNestedMock(); + const output = isEntryNested(payload); + const expected = true; + expect(output).toEqual(expected); + }); + }); + + describe('#getFormattedBuilderEntries', () => { + test('it returns formatted entry with field undefined if it unable to find a matching index pattern field', () => { + const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); + const payloadItems: BuilderEntry[] = [{ ...getEntryMatchMock() }]; + const output = getFormattedBuilderEntries(payloadIndexPattern, payloadItems); + const expected: FormattedBuilderEntry[] = [ + { + entryIndex: 0, + field: undefined, + nested: undefined, + operator: isOperator, + parent: undefined, + value: 'some host name', + }, + ]; + expect(output).toEqual(expected); + }); + + test('it returns formatted entries when no nested entries exist', () => { + const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); + const payloadItems: BuilderEntry[] = [ + { ...getEntryMatchMock(), field: 'ip', value: 'some ip' }, + { ...getEntryMatchAnyMock(), field: 'extension', value: ['some extension'] }, + ]; + const output = getFormattedBuilderEntries(payloadIndexPattern, payloadItems); + const expected: FormattedBuilderEntry[] = [ + { + entryIndex: 0, + field: { + aggregatable: true, + count: 0, + esTypes: ['ip'], + name: 'ip', + readFromDocValues: true, + scripted: false, + searchable: true, + type: 'ip', + }, + nested: undefined, + operator: isOperator, + parent: undefined, + value: 'some ip', + }, + { + entryIndex: 1, + field: { + aggregatable: true, + count: 0, + esTypes: ['keyword'], + name: 'extension', + readFromDocValues: true, + scripted: false, + searchable: true, + type: 'string', + }, + nested: undefined, + operator: isOneOfOperator, + parent: undefined, + value: ['some extension'], + }, + ]; + expect(output).toEqual(expected); + }); + + test('it returns formatted entries when nested entries exist', () => { + const payloadIndexPattern: IIndexPattern = getMockIndexPattern(); + const payloadParent: EntryNested = { + ...getEntryNestedMock(), + field: 'nestedField', + entries: [{ ...getEntryMatchMock(), field: 'child' }], + }; + const payloadItems: BuilderEntry[] = [ + { ...getEntryMatchMock(), field: 'ip', value: 'some ip' }, + { ...payloadParent }, + ]; + + const output = getFormattedBuilderEntries(payloadIndexPattern, payloadItems); + const expected: FormattedBuilderEntry[] = [ + { + entryIndex: 0, + field: { + aggregatable: true, + count: 0, + esTypes: ['ip'], + name: 'ip', + readFromDocValues: true, + scripted: false, + searchable: true, + type: 'ip', + }, + nested: undefined, + operator: isOperator, + parent: undefined, + value: 'some ip', + }, + { + entryIndex: 1, + field: { + aggregatable: false, + esTypes: ['nested'], + name: 'nestedField', + searchable: false, + type: 'string', + }, + nested: 'parent', + operator: isOperator, + parent: undefined, + value: undefined, + }, + { + entryIndex: 0, + field: { + aggregatable: false, + count: 0, + esTypes: ['text'], + name: 'nestedField.child', + readFromDocValues: false, + scripted: false, + searchable: true, + subType: { + nested: { + path: 'nestedField', + }, + }, + type: 'string', + }, + nested: 'child', + operator: isOperator, + parent: { + parent: { + entries: [ + { + field: 'child', + operator: 'included', + type: 'match', + value: 'some host name', + }, + ], + field: 'nestedField', + type: 'nested', + }, + parentIndex: 1, + }, + value: 'some host name', + }, + ]; + expect(output).toEqual(expected); + }); + }); + + describe('#getUpdatedEntriesOnDelete', () => { + test('it removes entry corresponding to "entryIndex"', () => { + const payloadItem: ExceptionsBuilderExceptionItem = { ...getExceptionListItemSchemaMock() }; + const output = getUpdatedEntriesOnDelete(payloadItem, 0, null); + const expected: ExceptionsBuilderExceptionItem = { + ...getExceptionListItemSchemaMock(), + entries: [ + { + field: 'some.not.nested.field', + operator: 'included', + type: 'match', + value: 'some value', + }, + ], + }; + expect(output).toEqual(expected); + }); + + test('it removes entry corresponding to "nestedEntryIndex"', () => { + const payloadItem: ExceptionsBuilderExceptionItem = { + ...getExceptionListItemSchemaMock(), + entries: [ + { + ...getEntryNestedMock(), + entries: [{ ...getEntryExistsMock() }, { ...getEntryMatchAnyMock() }], + }, + ], + }; + const output = getUpdatedEntriesOnDelete(payloadItem, 0, 1); + const expected: ExceptionsBuilderExceptionItem = { + ...getExceptionListItemSchemaMock(), + entries: [{ ...getEntryNestedMock(), entries: [{ ...getEntryExistsMock() }] }], + }; + expect(output).toEqual(expected); + }); + + test('it removes entire nested entry if after deleting specified nested entry, there are no more nested entries left', () => { + const payloadItem: ExceptionsBuilderExceptionItem = { + ...getExceptionListItemSchemaMock(), + entries: [ + { + ...getEntryNestedMock(), + entries: [{ ...getEntryExistsMock() }], + }, + ], + }; + const output = getUpdatedEntriesOnDelete(payloadItem, 0, 0); + const expected: ExceptionsBuilderExceptionItem = { + ...getExceptionListItemSchemaMock(), + entries: [], + }; + expect(output).toEqual(expected); + }); + }); + + describe('#getEntryFromOperator', () => { + test('it returns current value when switching from "is" to "is not"', () => { + const payloadOperator: OperatorOption = isNotOperator; + const payloadEntry: FormattedBuilderEntry = { + ...getMockBuilderEntry(), + value: 'I should stay the same', + }; + const output = getEntryFromOperator(payloadOperator, payloadEntry); + const expected: Entry = { + field: 'ip', + operator: 'excluded', + type: 'match', + value: 'I should stay the same', + }; + expect(output).toEqual(expected); + }); + + test('it returns current value when switching from "is not" to "is"', () => { + const payloadOperator: OperatorOption = isOperator; + const payloadEntry: FormattedBuilderEntry = { + ...getMockBuilderEntry(), + operator: isNotOperator, + value: 'I should stay the same', + }; + const output = getEntryFromOperator(payloadOperator, payloadEntry); + const expected: Entry = { + field: 'ip', + operator: 'included', + type: 'match', + value: 'I should stay the same', + }; + expect(output).toEqual(expected); + }); + + test('it returns empty value when switching operator types to "match"', () => { + const payloadOperator: OperatorOption = isOperator; + const payloadEntry: FormattedBuilderEntry = { + ...getMockBuilderEntry(), + operator: isNotOneOfOperator, + value: ['I should stay the same'], + }; + const output = getEntryFromOperator(payloadOperator, payloadEntry); + const expected: Entry = { + field: 'ip', + operator: 'included', + type: 'match', + value: '', + }; + expect(output).toEqual(expected); + }); + + test('it returns current value when switching from "is one of" to "is not one of"', () => { + const payloadOperator: OperatorOption = isNotOneOfOperator; + const payloadEntry: FormattedBuilderEntry = { + ...getMockBuilderEntry(), + operator: isOneOfOperator, + value: ['I should stay the same'], + }; + const output = getEntryFromOperator(payloadOperator, payloadEntry); + const expected: Entry = { + field: 'ip', + operator: 'excluded', + type: 'match_any', + value: ['I should stay the same'], + }; + expect(output).toEqual(expected); + }); + + test('it returns current value when switching from "is not one of" to "is one of"', () => { + const payloadOperator: OperatorOption = isOneOfOperator; + const payloadEntry: FormattedBuilderEntry = { + ...getMockBuilderEntry(), + operator: isNotOneOfOperator, + value: ['I should stay the same'], + }; + const output = getEntryFromOperator(payloadOperator, payloadEntry); + const expected: Entry = { + field: 'ip', + operator: 'included', + type: 'match_any', + value: ['I should stay the same'], + }; + expect(output).toEqual(expected); + }); + + test('it returns empty value when switching operator types to "match_any"', () => { + const payloadOperator: OperatorOption = isOneOfOperator; + const payloadEntry: FormattedBuilderEntry = { + ...getMockBuilderEntry(), + operator: isOperator, + value: 'I should stay the same', + }; + const output = getEntryFromOperator(payloadOperator, payloadEntry); + const expected: Entry = { + field: 'ip', + operator: 'included', + type: 'match_any', + value: [], + }; + expect(output).toEqual(expected); + }); + + test('it returns current value when switching from "exists" to "does not exist"', () => { + const payloadOperator: OperatorOption = doesNotExistOperator; + const payloadEntry: FormattedBuilderEntry = { + ...getMockBuilderEntry(), + operator: existsOperator, + }; + const output = getEntryFromOperator(payloadOperator, payloadEntry); + const expected: Entry = { + field: 'ip', + operator: 'excluded', + type: 'exists', + }; + expect(output).toEqual(expected); + }); + + test('it returns current value when switching from "does not exist" to "exists"', () => { + const payloadOperator: OperatorOption = existsOperator; + const payloadEntry: FormattedBuilderEntry = { + ...getMockBuilderEntry(), + operator: doesNotExistOperator, + }; + const output = getEntryFromOperator(payloadOperator, payloadEntry); + const expected: Entry = { + field: 'ip', + operator: 'included', + type: 'exists', + }; + expect(output).toEqual(expected); + }); + + test('it returns empty value when switching operator types to "exists"', () => { + const payloadOperator: OperatorOption = existsOperator; + const payloadEntry: FormattedBuilderEntry = { + ...getMockBuilderEntry(), + operator: isOperator, + value: 'I should stay the same', + }; + const output = getEntryFromOperator(payloadOperator, payloadEntry); + const expected: Entry = { + field: 'ip', + operator: 'included', + type: 'exists', + }; + expect(output).toEqual(expected); + }); + + test('it returns empty value when switching operator types to "list"', () => { + const payloadOperator: OperatorOption = isInListOperator; + const payloadEntry: FormattedBuilderEntry = { + ...getMockBuilderEntry(), + operator: isOperator, + value: 'I should stay the same', + }; + const output = getEntryFromOperator(payloadOperator, payloadEntry); + const expected: Entry = { + field: 'ip', + operator: 'included', + type: 'list', + list: { id: '', type: 'ip' }, + }; + expect(output).toEqual(expected); + }); + }); + + describe('#getOperatorOptions', () => { + test('it returns "isOperator" if "item.nested" is "parent"', () => { + const payloadItem: FormattedBuilderEntry = getMockNestedParentBuilderEntry(); + const output = getOperatorOptions(payloadItem, 'endpoint', false); + const expected: OperatorOption[] = [isOperator]; + expect(output).toEqual(expected); + }); + + test('it returns "isOperator" if no field selected', () => { + const payloadItem: FormattedBuilderEntry = { ...getMockBuilderEntry(), field: undefined }; + const output = getOperatorOptions(payloadItem, 'endpoint', false); + const expected: OperatorOption[] = [isOperator]; + expect(output).toEqual(expected); + }); + + test('it returns "isOperator" and "isOneOfOperator" if item is nested and "listType" is "endpoint"', () => { + const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry(); + const output = getOperatorOptions(payloadItem, 'endpoint', false); + const expected: OperatorOption[] = [isOperator, isOneOfOperator]; + expect(output).toEqual(expected); + }); + + test('it returns "isOperator" and "isOneOfOperator" if "listType" is "endpoint"', () => { + const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); + const output = getOperatorOptions(payloadItem, 'endpoint', false); + const expected: OperatorOption[] = [isOperator, isOneOfOperator]; + expect(output).toEqual(expected); + }); + + test('it returns "isOperator" if "listType" is "endpoint" and field type is boolean', () => { + const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); + const output = getOperatorOptions(payloadItem, 'endpoint', true); + const expected: OperatorOption[] = [isOperator]; + expect(output).toEqual(expected); + }); + + test('it returns "isOperator", "isOneOfOperator", and "existsOperator" if item is nested and "listType" is "detection"', () => { + const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry(); + const output = getOperatorOptions(payloadItem, 'detection', false); + const expected: OperatorOption[] = [isOperator, isOneOfOperator, existsOperator]; + expect(output).toEqual(expected); + }); + + test('it returns "isOperator" and "existsOperator" if item is nested, "listType" is "detection", and field type is boolean', () => { + const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry(); + const output = getOperatorOptions(payloadItem, 'detection', true); + const expected: OperatorOption[] = [isOperator, existsOperator]; + expect(output).toEqual(expected); + }); + + test('it returns all operator options if "listType" is "detection"', () => { + const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); + const output = getOperatorOptions(payloadItem, 'detection', false); + const expected: OperatorOption[] = EXCEPTION_OPERATORS; + expect(output).toEqual(expected); + }); + + test('it returns "isOperator" and "existsOperator" if field type is boolean', () => { + const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); + const output = getOperatorOptions(payloadItem, 'detection', true); + const expected: OperatorOption[] = [isOperator, existsOperator]; + expect(output).toEqual(expected); + }); + }); + + describe('#getEntryOnFieldChange', () => { + test('it returns nested entry with single new subentry when "item.nested" is "parent"', () => { + const payloadItem: FormattedBuilderEntry = getMockNestedParentBuilderEntry(); + const payloadIFieldType: IFieldType = getField('nestedField.child'); + const output = getEntryOnFieldChange(payloadItem, payloadIFieldType); + const expected: { updatedEntry: BuilderEntry; index: number } = { + index: 0, + updatedEntry: { + entries: [{ field: 'child', operator: 'included', type: 'match', value: '' }], + field: 'nestedField', + type: 'nested', + }, + }; + expect(output).toEqual(expected); + }); + + test('it returns nested entry with newly selected field value when "item.nested" is "child"', () => { + const payloadItem: FormattedBuilderEntry = { + ...getMockNestedBuilderEntry(), + parent: { + parent: { + ...getEntryNestedMock(), + field: 'nestedField', + entries: [{ ...getEntryMatchMock(), field: 'child' }, getEntryMatchAnyMock()], + }, + parentIndex: 0, + }, + }; + const payloadIFieldType: IFieldType = getField('nestedField.child'); + const output = getEntryOnFieldChange(payloadItem, payloadIFieldType); + const expected: { updatedEntry: BuilderEntry; index: number } = { + index: 0, + updatedEntry: { + entries: [ + { field: 'child', operator: 'included', type: 'match', value: '' }, + getEntryMatchAnyMock(), + ], + field: 'nestedField', + type: 'nested', + }, + }; + expect(output).toEqual(expected); + }); + + test('it returns field of type "match" with updated field if not a nested entry', () => { + const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); + const payloadIFieldType: IFieldType = getField('ip'); + const output = getEntryOnFieldChange(payloadItem, payloadIFieldType); + const expected: { updatedEntry: BuilderEntry; index: number } = { + index: 0, + updatedEntry: { + field: 'ip', + operator: 'included', + type: 'match', + value: '', + }, + }; + expect(output).toEqual(expected); + }); + }); + + describe('#getEntryOnOperatorChange', () => { + test('it returns updated subentry preserving its value when entry is not switching operator types', () => { + const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); + const payloadOperator: OperatorOption = isNotOperator; + const output = getEntryOnOperatorChange(payloadItem, payloadOperator); + const expected: { updatedEntry: BuilderEntry; index: number } = { + updatedEntry: { field: 'ip', type: 'match', value: 'some value', operator: 'excluded' }, + index: 0, + }; + expect(output).toEqual(expected); + }); + + test('it returns updated subentry resetting its value when entry is switching operator types', () => { + const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); + const payloadOperator: OperatorOption = isOneOfOperator; + const output = getEntryOnOperatorChange(payloadItem, payloadOperator); + const expected: { updatedEntry: BuilderEntry; index: number } = { + updatedEntry: { field: 'ip', type: 'match_any', value: [], operator: 'included' }, + index: 0, + }; + expect(output).toEqual(expected); + }); + + test('it returns updated subentry preserving its value when entry is nested and not switching operator types', () => { + const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry(); + const payloadOperator: OperatorOption = isNotOperator; + const output = getEntryOnOperatorChange(payloadItem, payloadOperator); + const expected: { updatedEntry: BuilderEntry; index: number } = { + index: 0, + updatedEntry: { + entries: [ + { + field: 'child', + operator: 'excluded', + type: 'match', + value: 'some value', + }, + ], + field: 'nestedField', + type: 'nested', + }, + }; + expect(output).toEqual(expected); + }); + + test('it returns updated subentry resetting its value when entry is nested and switching operator types', () => { + const payloadItem: FormattedBuilderEntry = getMockNestedBuilderEntry(); + const payloadOperator: OperatorOption = isOneOfOperator; + const output = getEntryOnOperatorChange(payloadItem, payloadOperator); + const expected: { updatedEntry: BuilderEntry; index: number } = { + index: 0, + updatedEntry: { + entries: [ + { + field: 'child', + operator: 'included', + type: 'match_any', + value: [], + }, + ], + field: 'nestedField', + type: 'nested', + }, + }; + expect(output).toEqual(expected); + }); + }); + + describe('#getEntryOnMatchChange', () => { + test('it returns entry with updated value', () => { + const payload: FormattedBuilderEntry = getMockBuilderEntry(); + const output = getEntryOnMatchChange(payload, 'jibber jabber'); + const expected: { updatedEntry: BuilderEntry; index: number } = { + updatedEntry: { field: 'ip', type: 'match', value: 'jibber jabber', operator: 'included' }, + index: 0, + }; + expect(output).toEqual(expected); + }); + + test('it returns entry with updated value and "field" of empty string if entry does not have a "field" defined', () => { + const payload: FormattedBuilderEntry = { ...getMockBuilderEntry(), field: undefined }; + const output = getEntryOnMatchChange(payload, 'jibber jabber'); + const expected: { updatedEntry: BuilderEntry; index: number } = { + updatedEntry: { field: '', type: 'match', value: 'jibber jabber', operator: 'included' }, + index: 0, + }; + expect(output).toEqual(expected); + }); + + test('it returns nested entry with updated value', () => { + const payload: FormattedBuilderEntry = getMockNestedBuilderEntry(); + const output = getEntryOnMatchChange(payload, 'jibber jabber'); + const expected: { updatedEntry: BuilderEntry; index: number } = { + index: 0, + updatedEntry: { + entries: [ + { + field: 'child', + operator: 'included', + type: 'match', + value: 'jibber jabber', + }, + ], + field: 'nestedField', + type: 'nested', + }, + }; + expect(output).toEqual(expected); + }); + + test('it returns nested entry with updated value and "field" of empty string if entry does not have a "field" defined', () => { + const payload: FormattedBuilderEntry = { ...getMockNestedBuilderEntry(), field: undefined }; + const output = getEntryOnMatchChange(payload, 'jibber jabber'); + const expected: { updatedEntry: BuilderEntry; index: number } = { + index: 0, + updatedEntry: { + entries: [ + { + field: '', + operator: 'included', + type: 'match', + value: 'jibber jabber', + }, + ], + field: 'nestedField', + type: 'nested', + }, + }; + expect(output).toEqual(expected); + }); + }); + + describe('#getEntryOnMatchAnyChange', () => { + test('it returns entry with updated value', () => { + const payload: FormattedBuilderEntry = { + ...getMockBuilderEntry(), + operator: isOneOfOperator, + value: ['some value'], + }; + const output = getEntryOnMatchAnyChange(payload, ['jibber jabber']); + const expected: { updatedEntry: BuilderEntry; index: number } = { + updatedEntry: { + field: 'ip', + type: 'match_any', + value: ['jibber jabber'], + operator: 'included', + }, + index: 0, + }; + expect(output).toEqual(expected); + }); + + test('it returns entry with updated value and "field" of empty string if entry does not have a "field" defined', () => { + const payload: FormattedBuilderEntry = { + ...getMockBuilderEntry(), + operator: isOneOfOperator, + value: ['some value'], + field: undefined, + }; + const output = getEntryOnMatchAnyChange(payload, ['jibber jabber']); + const expected: { updatedEntry: BuilderEntry; index: number } = { + updatedEntry: { + field: '', + type: 'match_any', + value: ['jibber jabber'], + operator: 'included', + }, + index: 0, + }; + expect(output).toEqual(expected); + }); + + test('it returns nested entry with updated value', () => { + const payload: FormattedBuilderEntry = { + ...getMockNestedBuilderEntry(), + parent: { + parent: { + ...getEntryNestedMock(), + field: 'nestedField', + entries: [{ ...getEntryMatchAnyMock(), field: 'child' }], + }, + parentIndex: 0, + }, + }; + const output = getEntryOnMatchAnyChange(payload, ['jibber jabber']); + const expected: { updatedEntry: BuilderEntry; index: number } = { + index: 0, + updatedEntry: { + entries: [ + { + field: 'child', + operator: 'included', + type: 'match_any', + value: ['jibber jabber'], + }, + ], + field: 'nestedField', + type: 'nested', + }, + }; + expect(output).toEqual(expected); + }); + + test('it returns nested entry with updated value and "field" of empty string if entry does not have a "field" defined', () => { + const payload: FormattedBuilderEntry = { + ...getMockNestedBuilderEntry(), + field: undefined, + parent: { + parent: { + ...getEntryNestedMock(), + field: 'nestedField', + entries: [{ ...getEntryMatchAnyMock(), field: 'child' }], + }, + parentIndex: 0, + }, + }; + const output = getEntryOnMatchAnyChange(payload, ['jibber jabber']); + const expected: { updatedEntry: BuilderEntry; index: number } = { + index: 0, + updatedEntry: { + entries: [ + { + field: '', + operator: 'included', + type: 'match_any', + value: ['jibber jabber'], + }, + ], + field: 'nestedField', + type: 'nested', + }, + }; + expect(output).toEqual(expected); + }); + }); + + describe('#getEntryOnListChange', () => { + test('it returns entry with updated value', () => { + const payload: FormattedBuilderEntry = { + ...getMockBuilderEntry(), + operator: isOneOfOperator, + value: '1234', + }; + const output = getEntryOnListChange(payload, getListResponseMock()); + const expected: { updatedEntry: BuilderEntry; index: number } = { + updatedEntry: { + field: 'ip', + type: 'list', + list: { id: 'some-list-id', type: 'ip' }, + operator: 'included', + }, + index: 0, + }; + expect(output).toEqual(expected); + }); + + test('it returns entry with updated value and "field" of empty string if entry does not have a "field" defined', () => { + const payload: FormattedBuilderEntry = { + ...getMockBuilderEntry(), + operator: isOneOfOperator, + value: '1234', + field: undefined, + }; + const output = getEntryOnListChange(payload, getListResponseMock()); + const expected: { updatedEntry: BuilderEntry; index: number } = { + updatedEntry: { + field: '', + type: 'list', + list: { id: 'some-list-id', type: 'ip' }, + operator: 'included', + }, + index: 0, + }; + expect(output).toEqual(expected); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.tsx new file mode 100644 index 000000000000..2fe2c68941ae --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.tsx @@ -0,0 +1,549 @@ +/* + * 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 { IIndexPattern, IFieldType } from '../../../../../../../../src/plugins/data/common'; +import { + Entry, + OperatorTypeEnum, + EntryNested, + ExceptionListType, + EntryMatch, + EntryMatchAny, + EntryExists, + entriesList, + ListSchema, + OperatorEnum, +} from '../../../../lists_plugin_deps'; +import { + isOperator, + existsOperator, + isOneOfOperator, + EXCEPTION_OPERATORS, +} from '../../autocomplete/operators'; +import { OperatorOption } from '../../autocomplete/types'; +import { + BuilderEntry, + FormattedBuilderEntry, + ExceptionsBuilderExceptionItem, + EmptyEntry, + EmptyNestedEntry, +} from '../types'; +import { getEntryValue, getExceptionOperatorSelect } from '../helpers'; + +/** + * Returns filtered index patterns based on the field - if a user selects to + * add nested entry, should only show nested fields, if item is the parent + * field of a nested entry, we only display the parent field + * + * @param patterns IIndexPattern containing available fields on rule index + * @param item exception item entry + * @param addNested boolean noting whether or not UI is currently + * set to add a nested field + */ +export const getFilteredIndexPatterns = ( + patterns: IIndexPattern, + item: FormattedBuilderEntry +): IIndexPattern => { + if (item.nested === 'child' && item.parent != null) { + // when user has selected a nested entry, only fields with the common parent are shown + return { + ...patterns, + fields: patterns.fields.filter( + (field) => + field.subType != null && + field.subType.nested != null && + item.parent != null && + field.subType.nested.path.startsWith(item.parent.parent.field) + ), + }; + } else if (item.nested === 'parent' && item.field != null) { + // when user has selected a nested entry, right above it we show the common parent + return { ...patterns, fields: [item.field] }; + } else if (item.nested === 'parent' && item.field == null) { + // when user selects to add a nested entry, only nested fields are shown as options + return { + ...patterns, + fields: patterns.fields.filter( + (field) => field.subType != null && field.subType.nested != null + ), + }; + } else { + return patterns; + } +}; + +/** + * Formats the entry into one that is easily usable for the UI, most of the + * complexity was introduced with nested fields + * + * @param patterns IIndexPattern containing available fields on rule index + * @param item exception item entry + * @param itemIndex entry index + * @param parent nested entries hold copy of their parent for use in various logic + * @param parentIndex corresponds to the entry index, this might seem obvious, but + * was added to ensure that nested items could be identified with their parent entry + */ +export const getFormattedBuilderEntry = ( + indexPattern: IIndexPattern, + item: BuilderEntry, + itemIndex: number, + parent: EntryNested | undefined, + parentIndex: number | undefined +): FormattedBuilderEntry => { + const { fields } = indexPattern; + const field = parent != null ? `${parent.field}.${item.field}` : item.field; + const [selectedField] = fields.filter(({ name }) => field != null && field === name); + + if (parent != null && parentIndex != null) { + return { + field: selectedField, + operator: getExceptionOperatorSelect(item), + value: getEntryValue(item), + nested: 'child', + parent: { parent, parentIndex }, + entryIndex: itemIndex, + }; + } else { + return { + field: selectedField, + operator: getExceptionOperatorSelect(item), + value: getEntryValue(item), + nested: undefined, + parent: undefined, + entryIndex: itemIndex, + }; + } +}; + +export const isEntryNested = (item: BuilderEntry): item is EntryNested => { + return (item as EntryNested).entries != null; +}; + +/** + * Formats the entries to be easily usable for the UI, most of the + * complexity was introduced with nested fields + * + * @param patterns IIndexPattern containing available fields on rule index + * @param entries exception item entries + * @param addNested boolean noting whether or not UI is currently + * set to add a nested field + * @param parent nested entries hold copy of their parent for use in various logic + * @param parentIndex corresponds to the entry index, this might seem obvious, but + * was added to ensure that nested items could be identified with their parent entry + */ +export const getFormattedBuilderEntries = ( + indexPattern: IIndexPattern, + entries: BuilderEntry[], + parent?: EntryNested, + parentIndex?: number +): FormattedBuilderEntry[] => { + return entries.reduce((acc, item, index) => { + const isNewNestedEntry = item.type === 'nested' && item.entries.length === 0; + if (item.type !== 'nested' && !isNewNestedEntry) { + const newItemEntry: FormattedBuilderEntry = getFormattedBuilderEntry( + indexPattern, + item, + index, + parent, + parentIndex + ); + return [...acc, newItemEntry]; + } else { + const parentEntry: FormattedBuilderEntry = { + operator: isOperator, + nested: 'parent', + field: isNewNestedEntry + ? undefined + : { + name: item.field ?? '', + aggregatable: false, + searchable: false, + type: 'string', + esTypes: ['nested'], + }, + value: undefined, + entryIndex: index, + parent: undefined, + }; + + // User has selected to add a nested field, but not yet selected the field + if (isNewNestedEntry) { + return [...acc, parentEntry]; + } + + if (isEntryNested(item)) { + const nestedItems = getFormattedBuilderEntries(indexPattern, item.entries, item, index); + + return [...acc, parentEntry, ...nestedItems]; + } + + return [...acc]; + } + }, []); +}; + +/** + * Determines whether an entire entry, exception item, or entry within a nested + * entry needs to be removed + * + * @param exceptionItem + * @param entryIndex index of given entry, for nested entries, this will correspond + * to their parent index + * @param nestedEntryIndex index of nested entry + * + */ +export const getUpdatedEntriesOnDelete = ( + exceptionItem: ExceptionsBuilderExceptionItem, + entryIndex: number, + nestedEntryIndex: number | null +): ExceptionsBuilderExceptionItem => { + const itemOfInterest: BuilderEntry = exceptionItem.entries[entryIndex]; + + if (nestedEntryIndex != null && itemOfInterest.type === OperatorTypeEnum.NESTED) { + const updatedEntryEntries: Array = [ + ...itemOfInterest.entries.slice(0, nestedEntryIndex), + ...itemOfInterest.entries.slice(nestedEntryIndex + 1), + ]; + + if (updatedEntryEntries.length === 0) { + return { + ...exceptionItem, + entries: [ + ...exceptionItem.entries.slice(0, entryIndex), + ...exceptionItem.entries.slice(entryIndex + 1), + ], + }; + } else { + const { field } = itemOfInterest; + const updatedItemOfInterest: EntryNested | EmptyNestedEntry = { + field, + type: OperatorTypeEnum.NESTED, + entries: updatedEntryEntries, + }; + + return { + ...exceptionItem, + entries: [ + ...exceptionItem.entries.slice(0, entryIndex), + updatedItemOfInterest, + ...exceptionItem.entries.slice(entryIndex + 1), + ], + }; + } + } else { + return { + ...exceptionItem, + entries: [ + ...exceptionItem.entries.slice(0, entryIndex), + ...exceptionItem.entries.slice(entryIndex + 1), + ], + }; + } +}; + +/** + * On operator change, determines whether value needs to be cleared or not + * + * @param field + * @param selectedOperator + * @param currentEntry + * + */ +export const getEntryFromOperator = ( + selectedOperator: OperatorOption, + currentEntry: FormattedBuilderEntry +): Entry => { + const isSameOperatorType = currentEntry.operator.type === selectedOperator.type; + const fieldValue = currentEntry.field != null ? currentEntry.field.name : ''; + switch (selectedOperator.type) { + case 'match': + return { + field: fieldValue, + type: OperatorTypeEnum.MATCH, + operator: selectedOperator.operator, + value: + isSameOperatorType && typeof currentEntry.value === 'string' ? currentEntry.value : '', + }; + case 'match_any': + return { + field: fieldValue, + type: OperatorTypeEnum.MATCH_ANY, + operator: selectedOperator.operator, + value: isSameOperatorType && Array.isArray(currentEntry.value) ? currentEntry.value : [], + }; + case 'list': + return { + field: fieldValue, + type: OperatorTypeEnum.LIST, + operator: selectedOperator.operator, + list: { id: '', type: 'ip' }, + }; + default: + return { + field: fieldValue, + type: OperatorTypeEnum.EXISTS, + operator: selectedOperator.operator, + }; + } +}; + +/** + * Determines which operators to make available + * + * @param item + * @param listType + * + */ +export const getOperatorOptions = ( + item: FormattedBuilderEntry, + listType: ExceptionListType, + isBoolean: boolean +): OperatorOption[] => { + if (item.nested === 'parent' || item.field == null) { + return [isOperator]; + } else if ((item.nested != null && listType === 'endpoint') || listType === 'endpoint') { + return isBoolean ? [isOperator] : [isOperator, isOneOfOperator]; + } else if (item.nested != null && listType === 'detection') { + return isBoolean ? [isOperator, existsOperator] : [isOperator, isOneOfOperator, existsOperator]; + } else { + return isBoolean ? [isOperator, existsOperator] : EXCEPTION_OPERATORS; + } +}; + +/** + * Determines proper entry update when user selects new field + * + * @param item - current exception item entry values + * @param newField - newly selected field + * + */ +export const getEntryOnFieldChange = ( + item: FormattedBuilderEntry, + newField: IFieldType +): { updatedEntry: BuilderEntry; index: number } => { + const { parent, entryIndex, nested } = item; + const newChildFieldValue = newField != null ? newField.name.split('.').slice(-1)[0] : ''; + + if (nested === 'parent') { + // For nested entries, when user first selects to add a nested + // entry, they first see a row similiar to what is shown for when + // a user selects "exists", as soon as they make a selection + // we can now identify the 'parent' and 'child' this is where + // we first convert the entry into type "nested" + const newParentFieldValue = + newField.subType != null && newField.subType.nested != null + ? newField.subType.nested.path + : ''; + + return { + updatedEntry: { + field: newParentFieldValue, + type: OperatorTypeEnum.NESTED, + entries: [ + { + field: newChildFieldValue ?? '', + type: OperatorTypeEnum.MATCH, + operator: isOperator.operator, + value: '', + }, + ], + }, + index: entryIndex, + }; + } else if (nested === 'child' && parent != null) { + return { + updatedEntry: { + ...parent.parent, + entries: [ + ...parent.parent.entries.slice(0, entryIndex), + { + field: newChildFieldValue ?? '', + type: OperatorTypeEnum.MATCH, + operator: isOperator.operator, + value: '', + }, + ...parent.parent.entries.slice(entryIndex + 1), + ], + }, + index: parent.parentIndex, + }; + } else { + return { + updatedEntry: { + field: newField != null ? newField.name : '', + type: OperatorTypeEnum.MATCH, + operator: isOperator.operator, + value: '', + }, + index: entryIndex, + }; + } +}; + +/** + * Determines proper entry update when user selects new operator + * + * @param item - current exception item entry values + * @param newOperator - newly selected operator + * + */ +export const getEntryOnOperatorChange = ( + item: FormattedBuilderEntry, + newOperator: OperatorOption +): { updatedEntry: BuilderEntry; index: number } => { + const { parent, entryIndex, field, nested } = item; + const newEntry = getEntryFromOperator(newOperator, item); + + if (!entriesList.is(newEntry) && nested != null && parent != null) { + return { + updatedEntry: { + ...parent.parent, + entries: [ + ...parent.parent.entries.slice(0, entryIndex), + { + ...newEntry, + field: field != null ? field.name.split('.').slice(-1)[0] : '', + }, + ...parent.parent.entries.slice(entryIndex + 1), + ], + }, + index: parent.parentIndex, + }; + } else { + return { updatedEntry: newEntry, index: entryIndex }; + } +}; + +/** + * Determines proper entry update when user updates value + * when operator is of type "match" + * + * @param item - current exception item entry values + * @param newField - newly entered value + * + */ +export const getEntryOnMatchChange = ( + item: FormattedBuilderEntry, + newField: string +): { updatedEntry: BuilderEntry; index: number } => { + const { nested, parent, entryIndex, field, operator } = item; + + if (nested != null && parent != null) { + const fieldName = field != null ? field.name.split('.').slice(-1)[0] : ''; + + return { + updatedEntry: { + ...parent.parent, + entries: [ + ...parent.parent.entries.slice(0, entryIndex), + { + field: fieldName, + type: OperatorTypeEnum.MATCH, + operator: operator.operator, + value: newField, + }, + ...parent.parent.entries.slice(entryIndex + 1), + ], + }, + index: parent.parentIndex, + }; + } else { + return { + updatedEntry: { + field: field != null ? field.name : '', + type: OperatorTypeEnum.MATCH, + operator: operator.operator, + value: newField, + }, + index: entryIndex, + }; + } +}; + +/** + * Determines proper entry update when user updates value + * when operator is of type "match_any" + * + * @param item - current exception item entry values + * @param newField - newly entered value + * + */ +export const getEntryOnMatchAnyChange = ( + item: FormattedBuilderEntry, + newField: string[] +): { updatedEntry: BuilderEntry; index: number } => { + const { nested, parent, entryIndex, field, operator } = item; + + if (nested != null && parent != null) { + const fieldName = field != null ? field.name.split('.').slice(-1)[0] : ''; + + return { + updatedEntry: { + ...parent.parent, + entries: [ + ...parent.parent.entries.slice(0, entryIndex), + { + field: fieldName, + type: OperatorTypeEnum.MATCH_ANY, + operator: operator.operator, + value: newField, + }, + ...parent.parent.entries.slice(entryIndex + 1), + ], + }, + index: parent.parentIndex, + }; + } else { + return { + updatedEntry: { + field: field != null ? field.name : '', + type: OperatorTypeEnum.MATCH_ANY, + operator: operator.operator, + value: newField, + }, + index: entryIndex, + }; + } +}; + +/** + * Determines proper entry update when user updates value + * when operator is of type "list" + * + * @param item - current exception item entry values + * @param newField - newly selected list + * + */ +export const getEntryOnListChange = ( + item: FormattedBuilderEntry, + newField: ListSchema +): { updatedEntry: BuilderEntry; index: number } => { + const { entryIndex, field, operator } = item; + const { id, type } = newField; + + return { + updatedEntry: { + field: field != null ? field.name : '', + type: OperatorTypeEnum.LIST, + operator: operator.operator, + list: { id, type }, + }, + index: entryIndex, + }; +}; + +export const getDefaultEmptyEntry = (): EmptyEntry => ({ + field: '', + type: OperatorTypeEnum.MATCH, + operator: OperatorEnum.INCLUDED, + value: '', +}); + +export const getDefaultNestedEmptyEntry = (): EmptyNestedEntry => ({ + field: '', + type: OperatorTypeEnum.NESTED, + entries: [], +}); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.tsx index f6feca591dc6..141429f15279 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.tsx @@ -3,7 +3,7 @@ * 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, { useCallback, useEffect, useState, useMemo } from 'react'; +import React, { useCallback, useEffect, useMemo, useReducer } from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import styled from 'styled-components'; @@ -17,11 +17,14 @@ import { OperatorEnum, CreateExceptionListItemSchema, ExceptionListType, + entriesNested, } from '../../../../../public/lists_plugin_deps'; import { AndOrBadge } from '../../and_or_badge'; import { BuilderButtonOptions } from './builder_button_options'; import { getNewExceptionItem, filterExceptionItems } from '../helpers'; import { ExceptionsBuilderExceptionItem, CreateExceptionListItemBuilderSchema } from '../types'; +import { State, exceptionsBuilderReducer } from './reducer'; +import { getDefaultEmptyEntry, getDefaultNestedEmptyEntry } from './helpers'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import exceptionableFields from '../exceptionable_fields.json'; @@ -39,6 +42,15 @@ const MyButtonsContainer = styled(EuiFlexItem)` margin: 16px 0; `; +const initialState: State = { + disableAnd: false, + disableOr: false, + andLogicIncluded: false, + addNested: false, + exceptions: [], + exceptionsToDelete: [], +}; + interface OnChangeProps { exceptionItems: Array; exceptionsToDelete: ExceptionListItemSchema[]; @@ -53,6 +65,7 @@ interface ExceptionBuilderProps { indexPatterns: IIndexPattern; isOrDisabled: boolean; isAndDisabled: boolean; + isNestedDisabled: boolean; onChange: (arg: OnChangeProps) => void; } @@ -65,74 +78,144 @@ export const ExceptionBuilder = ({ indexPatterns, isOrDisabled, isAndDisabled, + isNestedDisabled, onChange, }: ExceptionBuilderProps) => { - const [andLogicIncluded, setAndLogicIncluded] = useState(false); - const [exceptions, setExceptions] = useState( - exceptionListItems + const [ + { exceptions, exceptionsToDelete, andLogicIncluded, disableAnd, disableOr, addNested }, + dispatch, + ] = useReducer(exceptionsBuilderReducer(), { + ...initialState, + disableAnd: isAndDisabled, + disableOr: isOrDisabled, + }); + + const setUpdateExceptions = useCallback( + (items: ExceptionsBuilderExceptionItem[]): void => { + dispatch({ + type: 'setExceptions', + exceptions: items, + }); + }, + [dispatch] ); - const [exceptionsToDelete, setExceptionsToDelete] = useState([]); - const handleCheckAndLogic = (items: ExceptionsBuilderExceptionItem[]): void => { - setAndLogicIncluded(items.filter(({ entries }) => entries.length > 1).length > 0); - }; + const setDefaultExceptions = useCallback( + (item: ExceptionsBuilderExceptionItem): void => { + dispatch({ + type: 'setDefault', + initialState, + lastException: item, + }); + }, + [dispatch] + ); - const handleDeleteExceptionItem = ( - item: ExceptionsBuilderExceptionItem, - itemIndex: number - ): void => { - if (item.entries.length === 0) { - if (exceptionListItemSchema.is(item)) { - setExceptionsToDelete((items) => [...items, item]); - } + const setUpdateExceptionsToDelete = useCallback( + (items: ExceptionListItemSchema[]): void => { + dispatch({ + type: 'setExceptionsToDelete', + exceptions: items, + }); + }, + [dispatch] + ); - setExceptions((existingExceptions) => { - const updatedExceptions = [ - ...existingExceptions.slice(0, itemIndex), - ...existingExceptions.slice(itemIndex + 1), - ]; - handleCheckAndLogic(updatedExceptions); + const setUpdateAndDisabled = useCallback( + (shouldDisable: boolean): void => { + dispatch({ + type: 'setDisableAnd', + shouldDisable, + }); + }, + [dispatch] + ); - return updatedExceptions; + const setUpdateOrDisabled = useCallback( + (shouldDisable: boolean): void => { + dispatch({ + type: 'setDisableOr', + shouldDisable, }); - } else { - handleExceptionItemChange(item, itemIndex); - } - }; + }, + [dispatch] + ); - const handleExceptionItemChange = (item: ExceptionsBuilderExceptionItem, index: number): void => { - const updatedExceptions = [ - ...exceptions.slice(0, index), - { - ...item, - }, - ...exceptions.slice(index + 1), - ]; - - handleCheckAndLogic(updatedExceptions); - setExceptions(updatedExceptions); - }; + const setUpdateAddNested = useCallback( + (shouldAddNested: boolean): void => { + dispatch({ + type: 'setAddNested', + addNested: shouldAddNested, + }); + }, + [dispatch] + ); + + const handleExceptionItemChange = useCallback( + (item: ExceptionsBuilderExceptionItem, index: number): void => { + const updatedExceptions = [ + ...exceptions.slice(0, index), + { + ...item, + }, + ...exceptions.slice(index + 1), + ]; + + setUpdateExceptions(updatedExceptions); + }, + [setUpdateExceptions, exceptions] + ); + + const handleDeleteExceptionItem = useCallback( + (item: ExceptionsBuilderExceptionItem, itemIndex: number): void => { + if (item.entries.length === 0) { + const updatedExceptions = [ + ...exceptions.slice(0, itemIndex), + ...exceptions.slice(itemIndex + 1), + ]; - const handleAddNewExceptionItemEntry = useCallback((): void => { - setExceptions((existingExceptions): ExceptionsBuilderExceptionItem[] => { - const lastException = existingExceptions[existingExceptions.length - 1]; + // if it's the only exception item left, don't delete it + // just add a default entry to it + if (updatedExceptions.length === 0) { + setDefaultExceptions(item); + } else if (updatedExceptions.length > 0 && exceptionListItemSchema.is(item)) { + setUpdateExceptionsToDelete([...exceptionsToDelete, item]); + } else { + setUpdateExceptions([ + ...exceptions.slice(0, itemIndex), + ...exceptions.slice(itemIndex + 1), + ]); + } + } else { + handleExceptionItemChange(item, itemIndex); + } + }, + [ + handleExceptionItemChange, + setUpdateExceptions, + setUpdateExceptionsToDelete, + exceptions, + exceptionsToDelete, + setDefaultExceptions, + ] + ); + + const handleAddNewExceptionItemEntry = useCallback( + (isNested = false): void => { + const lastException = exceptions[exceptions.length - 1]; const { entries } = lastException; + const updatedException: ExceptionsBuilderExceptionItem = { ...lastException, - entries: [ - ...entries, - { field: '', type: OperatorTypeEnum.MATCH, operator: OperatorEnum.INCLUDED, value: '' }, - ], + entries: [...entries, isNested ? getDefaultNestedEmptyEntry() : getDefaultEmptyEntry()], }; - setAndLogicIncluded(updatedException.entries.length > 1); + // setAndLogicIncluded(updatedException.entries.length > 1); - return [ - ...existingExceptions.slice(0, existingExceptions.length - 1), - { ...updatedException }, - ]; - }); - }, [setExceptions, setAndLogicIncluded]); + setUpdateExceptions([...exceptions.slice(0, exceptions.length - 1), { ...updatedException }]); + }, + [setUpdateExceptions, exceptions] + ); const handleAddNewExceptionItem = useCallback((): void => { // There is a case where there are numerous exception list items, all with @@ -144,8 +227,8 @@ export const ExceptionBuilder = ({ namespaceType: listNamespaceType, ruleName, }); - setExceptions((existingExceptions) => [...existingExceptions, { ...newException }]); - }, [setExceptions, listType, listId, listNamespaceType, ruleName]); + setUpdateExceptions([...exceptions, { ...newException }]); + }, [setUpdateExceptions, exceptions, listType, listId, listNamespaceType, ruleName]); // Filters index pattern fields by exceptionable fields if list type is endpoint const filterIndexPatterns = useMemo((): IIndexPattern => { @@ -172,6 +255,55 @@ export const ExceptionBuilder = ({ } }; + const handleAddNestedExceptionItemEntry = useCallback((): void => { + const lastException = exceptions[exceptions.length - 1]; + const { entries } = lastException; + const lastEntry = entries[entries.length - 1]; + + if (entriesNested.is(lastEntry)) { + const updatedException: ExceptionsBuilderExceptionItem = { + ...lastException, + entries: [ + ...entries.slice(0, entries.length - 1), + { + ...lastEntry, + entries: [ + ...lastEntry.entries, + { + field: '', + type: OperatorTypeEnum.MATCH, + operator: OperatorEnum.INCLUDED, + value: '', + }, + ], + }, + ], + }; + + setUpdateExceptions([...exceptions.slice(0, exceptions.length - 1), { ...updatedException }]); + } else { + setUpdateExceptions(exceptions); + } + }, [setUpdateExceptions, exceptions]); + + const handleAddNestedClick = useCallback((): void => { + setUpdateAddNested(true); + setUpdateOrDisabled(true); + setUpdateAndDisabled(true); + handleAddNewExceptionItemEntry(true); + }, [ + handleAddNewExceptionItemEntry, + setUpdateAndDisabled, + setUpdateOrDisabled, + setUpdateAddNested, + ]); + + const handleAddClick = useCallback((): void => { + setUpdateAddNested(false); + setUpdateOrDisabled(false); + handleAddNewExceptionItemEntry(); + }, [handleAddNewExceptionItemEntry, setUpdateOrDisabled, setUpdateAddNested]); + // Bubble up changes to parent useEffect(() => { onChange({ exceptionItems: filterExceptionItems(exceptions), exceptionsToDelete }); @@ -188,6 +320,13 @@ export const ExceptionBuilder = ({ } }, [exceptions, handleAddNewExceptionItem]); + useEffect(() => { + if (exceptionListItems.length > 0) { + setUpdateExceptions(exceptionListItems); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return ( {exceptions.map((exceptionListItem, index) => ( @@ -216,7 +355,8 @@ export const ExceptionBuilder = ({ exceptionItem={exceptionListItem} exceptionId={getExceptionListItemId(exceptionListItem, index)} indexPattern={filterIndexPatterns} - isLoading={indexPatterns.fields.length === 0} + listType={listType} + addNested={addNested} exceptionItemIndex={index} andLogicIncluded={andLogicIncluded} isOnlyItem={exceptions.length === 1} @@ -237,12 +377,15 @@ export const ExceptionBuilder = ({ )} {}} + onAndClicked={handleAddClick} + onNestedClicked={handleAddNestedClick} + onAddClickWhenNested={handleAddNestedExceptionItemEntry} /> diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/reducer.ts b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/reducer.ts new file mode 100644 index 000000000000..045ff458755b --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/reducer.ts @@ -0,0 +1,106 @@ +/* + * 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 { ExceptionsBuilderExceptionItem } from '../types'; +import { ExceptionListItemSchema } from '../../../../../public/lists_plugin_deps'; +import { getDefaultEmptyEntry } from './helpers'; + +export type ViewerModalName = 'addModal' | 'editModal' | null; + +export interface State { + disableAnd: boolean; + disableOr: boolean; + andLogicIncluded: boolean; + addNested: boolean; + exceptions: ExceptionsBuilderExceptionItem[]; + exceptionsToDelete: ExceptionListItemSchema[]; +} + +export type Action = + | { + type: 'setExceptions'; + exceptions: ExceptionsBuilderExceptionItem[]; + } + | { + type: 'setExceptionsToDelete'; + exceptions: ExceptionListItemSchema[]; + } + | { + type: 'setDefault'; + initialState: State; + lastException: ExceptionsBuilderExceptionItem; + } + | { + type: 'setDisableAnd'; + shouldDisable: boolean; + } + | { + type: 'setDisableOr'; + shouldDisable: boolean; + } + | { + type: 'setAddNested'; + addNested: boolean; + }; + +export const exceptionsBuilderReducer = () => (state: State, action: Action): State => { + switch (action.type) { + case 'setExceptions': { + const isAndLogicIncluded = + action.exceptions.filter(({ entries }) => entries.length > 1).length > 0; + const lastExceptionItem = action.exceptions.slice(-1)[0]; + const isAddNested = + lastExceptionItem != null + ? lastExceptionItem.entries.slice(-1).filter(({ type }) => type === 'nested').length > 0 + : false; + const lastEntry = lastExceptionItem != null ? lastExceptionItem.entries.slice(-1)[0] : null; + const isAndDisabled = + lastEntry != null && lastEntry.type === 'nested' && lastEntry.entries.length === 0; + const isOrDisabled = lastEntry != null && lastEntry.type === 'nested'; + + return { + ...state, + andLogicIncluded: isAndLogicIncluded, + exceptions: action.exceptions, + addNested: isAddNested, + disableAnd: isAndDisabled, + disableOr: isOrDisabled, + }; + } + case 'setDefault': { + return { + ...state, + ...action.initialState, + exceptions: [{ ...action.lastException, entries: [getDefaultEmptyEntry()] }], + }; + } + case 'setExceptionsToDelete': { + return { + ...state, + exceptionsToDelete: action.exceptions, + }; + } + case 'setDisableAnd': { + return { + ...state, + disableAnd: action.shouldDisable, + }; + } + case 'setDisableOr': { + return { + ...state, + disableOr: action.shouldDisable, + }; + } + case 'setAddNested': { + return { + ...state, + addNested: action.addNested, + }; + } + default: + return state; + } +}; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/translations.ts b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/translations.ts new file mode 100644 index 000000000000..82cca2596da6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/translations.ts @@ -0,0 +1,71 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +export const FIELD = i18n.translate('xpack.securitySolution.exceptions.builder.fieldDescription', { + defaultMessage: 'Field', +}); + +export const OPERATOR = i18n.translate( + 'xpack.securitySolution.exceptions.builder.operatorDescription', + { + defaultMessage: 'Operator', + } +); + +export const VALUE = i18n.translate('xpack.securitySolution.exceptions.builder.valueDescription', { + defaultMessage: 'Value', +}); + +export const EXCEPTION_FIELD_PLACEHOLDER = i18n.translate( + 'xpack.securitySolution.exceptions.builder.exceptionFieldPlaceholderDescription', + { + defaultMessage: 'Search', + } +); + +export const EXCEPTION_FIELD_NESTED_PLACEHOLDER = i18n.translate( + 'xpack.securitySolution.exceptions.builder.exceptionFieldNestedPlaceholderDescription', + { + defaultMessage: 'Search nested field', + } +); + +export const EXCEPTION_OPERATOR_PLACEHOLDER = i18n.translate( + 'xpack.securitySolution.exceptions.builder.exceptionOperatorPlaceholderDescription', + { + defaultMessage: 'Operator', + } +); + +export const EXCEPTION_FIELD_VALUE_PLACEHOLDER = i18n.translate( + 'xpack.securitySolution.exceptions.builder.exceptionFieldValuePlaceholderDescription', + { + defaultMessage: 'Search field value...', + } +); + +export const EXCEPTION_FIELD_LISTS_PLACEHOLDER = i18n.translate( + 'xpack.securitySolution.exceptions.builder.exceptionListsPlaceholderDescription', + { + defaultMessage: 'Search for list...', + } +); + +export const ADD_NESTED_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.exceptions.builder.addNestedDescription', + { + defaultMessage: 'Add nested condition', + } +); + +export const ADD_NON_NESTED_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.exceptions.builder.addNonNestedDescription', + { + defaultMessage: 'Add non-nested condition', + } +); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx index 2d12cfbec160..4ad077edf66f 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx @@ -219,6 +219,7 @@ export const EditExceptionModal = memo(function EditExceptionModal({ ruleName={ruleName} isOrDisabled={false} isAndDisabled={false} + isNestedDisabled={false} data-test-subj="edit-exception-modal-builder" id-aria="edit-exception-modal-builder" onChange={handleBuilderOnChange} diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx index dace2eb5f067..78936d5d0da6 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx @@ -10,11 +10,8 @@ import moment from 'moment-timezone'; import { getOperatorType, getExceptionOperatorSelect, - getFormattedEntries, - formatEntry, getOperatingSystems, getTagsInclude, - getDescriptionListContent, getFormattedComments, filterExceptionItems, getNewExceptionItem, @@ -27,7 +24,7 @@ import { entryHasNonEcsType, prepareExceptionItemsForBulkClose, } from './helpers'; -import { FormattedEntry, DescriptionListItem, EmptyEntry } from './types'; +import { EmptyEntry } from './types'; import { isOperator, isNotOperator, @@ -45,7 +42,6 @@ import { getEntryMatchAnyMock } from '../../../../../lists/common/schemas/types/ import { getEntryExistsMock } from '../../../../../lists/common/schemas/types/entry_exists.mock'; import { getEntryListMock } from '../../../../../lists/common/schemas/types/entry_list.mock'; import { getCommentsArrayMock } from '../../../../../lists/common/schemas/types/comments.mock'; -import { getEntriesArrayMock } from '../../../../../lists/common/schemas/types/entries.mock'; import { ENTRIES } from '../../../../../lists/common/constants.mock'; import { CreateExceptionListItemSchema, @@ -155,112 +151,6 @@ describe('Exception helpers', () => { }); }); - describe('#getFormattedEntries', () => { - test('it returns empty array if no entries passed', () => { - const result = getFormattedEntries([]); - - expect(result).toEqual([]); - }); - - test('it formats nested entries as expected', () => { - const payload = [getEntryMatchMock()]; - const result = getFormattedEntries(payload); - const expected: FormattedEntry[] = [ - { - fieldName: 'host.name', - isNested: false, - operator: 'is', - value: 'some host name', - }, - ]; - expect(result).toEqual(expected); - }); - - test('it formats "exists" entries as expected', () => { - const payload = [getEntryExistsMock()]; - const result = getFormattedEntries(payload); - const expected: FormattedEntry[] = [ - { - fieldName: 'host.name', - isNested: false, - operator: 'exists', - value: undefined, - }, - ]; - expect(result).toEqual(expected); - }); - - test('it formats non-nested entries as expected', () => { - const payload = [getEntryMatchAnyMock(), getEntryMatchMock()]; - const result = getFormattedEntries(payload); - const expected: FormattedEntry[] = [ - { - fieldName: 'host.name', - isNested: false, - operator: 'is one of', - value: ['some host name'], - }, - { - fieldName: 'host.name', - isNested: false, - operator: 'is', - value: 'some host name', - }, - ]; - expect(result).toEqual(expected); - }); - - test('it formats a mix of nested and non-nested entries as expected', () => { - const payload = getEntriesArrayMock(); - const result = getFormattedEntries(payload); - const expected: FormattedEntry[] = [ - { - fieldName: 'host.name', - isNested: false, - operator: 'is', - value: 'some host name', - }, - { - fieldName: 'host.name', - isNested: false, - operator: 'is one of', - value: ['some host name'], - }, - { - fieldName: 'host.name', - isNested: false, - operator: 'is in list', - value: 'some-list-id', - }, - { - fieldName: 'host.name', - isNested: false, - operator: 'exists', - value: undefined, - }, - { - fieldName: 'host.name', - isNested: false, - operator: undefined, - value: undefined, - }, - { - fieldName: 'host.name.host.name', - isNested: true, - operator: 'is', - value: 'some host name', - }, - { - fieldName: 'host.name.host.name', - isNested: true, - operator: 'is one of', - value: ['some host name'], - }, - ]; - expect(result).toEqual(expected); - }); - }); - describe('#getEntryValue', () => { it('returns "match" entry value', () => { const payload = getEntryMatchMock(); @@ -291,34 +181,6 @@ describe('Exception helpers', () => { }); }); - describe('#formatEntry', () => { - test('it formats an entry', () => { - const payload = getEntryMatchMock(); - const formattedEntry = formatEntry({ isNested: false, item: payload }); - const expected: FormattedEntry = { - fieldName: 'host.name', - isNested: false, - operator: 'is', - value: 'some host name', - }; - - expect(formattedEntry).toEqual(expected); - }); - - test('it formats as expected when "isNested" is "true"', () => { - const payload = getEntryMatchMock(); - const formattedEntry = formatEntry({ isNested: true, parent: 'parent', item: payload }); - const expected: FormattedEntry = { - fieldName: 'parent.host.name', - isNested: true, - operator: 'is', - value: 'some host name', - }; - - expect(formattedEntry).toEqual(expected); - }); - }); - describe('#getOperatingSystems', () => { test('it returns null if no operating system tag specified', () => { const result = getOperatingSystems(['some tag', 'some other tag']); @@ -389,72 +251,6 @@ describe('Exception helpers', () => { }); }); - describe('#getDescriptionListContent', () => { - test('it returns formatted description list with os if one is specified', () => { - const payload = getExceptionListItemSchemaMock(); - payload.description = ''; - const result = getDescriptionListContent(payload); - const expected: DescriptionListItem[] = [ - { - description: 'Linux', - title: 'OS', - }, - { - description: 'April 20th 2020 @ 15:25:31', - title: 'Date created', - }, - { - description: 'some user', - title: 'Created by', - }, - ]; - - expect(result).toEqual(expected); - }); - - test('it returns formatted description list with a description if one specified', () => { - const payload = getExceptionListItemSchemaMock(); - payload._tags = []; - payload.description = 'Im a description'; - const result = getDescriptionListContent(payload); - const expected: DescriptionListItem[] = [ - { - description: 'April 20th 2020 @ 15:25:31', - title: 'Date created', - }, - { - description: 'some user', - title: 'Created by', - }, - { - description: 'Im a description', - title: 'Comment', - }, - ]; - - expect(result).toEqual(expected); - }); - - test('it returns just user and date created if no other fields specified', () => { - const payload = getExceptionListItemSchemaMock(); - payload._tags = []; - payload.description = ''; - const result = getDescriptionListContent(payload); - const expected: DescriptionListItem[] = [ - { - description: 'April 20th 2020 @ 15:25:31', - title: 'Date created', - }, - { - description: 'some user', - title: 'Created by', - }, - ]; - - expect(result).toEqual(expected); - }); - }); - describe('#getFormattedComments', () => { test('it returns formatted comment object with username and timestamp', () => { const payload = getCommentsArrayMock(); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx index 4d8fc5f68870..384badefc34a 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx @@ -12,10 +12,7 @@ import uuid from 'uuid'; import * as i18n from './translations'; import { - FormattedEntry, BuilderEntry, - DescriptionListItem, - FormattedBuilderEntry, CreateExceptionListItemBuilderSchema, ExceptionsBuilderExceptionItem, } from './types'; @@ -38,7 +35,7 @@ import { ExceptionListType, EntryNested, } from '../../../lists_plugin_deps'; -import { IFieldType, IIndexPattern } from '../../../../../../../src/plugins/data/common'; +import { IIndexPattern } from '../../../../../../../src/plugins/data/common'; import { validate } from '../../../../common/validate'; import { TimelineNonEcsData } from '../../../graphql/types'; import { WithCopyToClipboard } from '../../lib/clipboard/with_copy_to_clipboard'; @@ -68,7 +65,7 @@ export const getOperatorType = (item: BuilderEntry): OperatorTypeEnum => { * @param item a single ExceptionItem entry */ export const getExceptionOperatorSelect = (item: BuilderEntry): OperatorOption => { - if (entriesNested.is(item)) { + if (item.type === 'nested') { return isOperator; } else { const operatorType = getOperatorType(item); @@ -81,39 +78,10 @@ export const getExceptionOperatorSelect = (item: BuilderEntry): OperatorOption = }; /** - * Formats ExceptionItem entries into simple field, operator, value - * for use in rendering items in table + * Returns the fields corresponding value for an entry * - * @param entries an ExceptionItem's entries + * @param item a single ExceptionItem entry */ -export const getFormattedEntries = (entries: BuilderEntry[]): FormattedEntry[] => { - const formattedEntries = entries.map((item) => { - if (entriesNested.is(item)) { - const parent = { - fieldName: item.field, - operator: undefined, - value: undefined, - isNested: false, - }; - return item.entries.reduce( - (acc, nestedEntry) => { - const formattedEntry = formatEntry({ - isNested: true, - parent: item.field, - item: nestedEntry, - }); - return [...acc, { ...formattedEntry }]; - }, - [parent] - ); - } else { - return formatEntry({ isNested: false, item }); - } - }); - - return formattedEntries.flat(); -}; - export const getEntryValue = (item: BuilderEntry): string | string[] | undefined => { switch (item.type) { case OperatorTypeEnum.MATCH: @@ -128,29 +96,6 @@ export const getEntryValue = (item: BuilderEntry): string | string[] | undefined } }; -/** - * Helper method for `getFormattedEntries` - */ -export const formatEntry = ({ - isNested, - parent, - item, -}: { - isNested: boolean; - parent?: string; - item: BuilderEntry; -}): FormattedEntry => { - const operator = getExceptionOperatorSelect(item); - const value = getEntryValue(item); - - return { - fieldName: isNested ? `${parent}.${item.field}` : item.field ?? '', - operator: operator.message, - value, - isNested, - }; -}; - /** * Retrieves the values of tags marked as os * @@ -189,42 +134,6 @@ export const getTagsInclude = ({ return [matches != null, match]; }; -/** - * Formats ExceptionItem information for description list component - * - * @param exceptionItem an ExceptionItem - */ -export const getDescriptionListContent = ( - exceptionItem: ExceptionListItemSchema -): DescriptionListItem[] => { - const details = [ - { - title: i18n.OPERATING_SYSTEM, - value: formatOperatingSystems(getOperatingSystems(exceptionItem._tags ?? [])), - }, - { - title: i18n.DATE_CREATED, - value: moment(exceptionItem.created_at).format('MMMM Do YYYY @ HH:mm:ss'), - }, - { - title: i18n.CREATED_BY, - value: exceptionItem.created_by, - }, - { - title: i18n.COMMENT, - value: exceptionItem.description, - }, - ]; - - return details.reduce((acc, { value, title }) => { - if (value != null && value.trim() !== '') { - return [...acc, { title, description: value }]; - } else { - return acc; - } - }, []); -}; - /** * Formats ExceptionItem.comments into EuiCommentList format * @@ -246,69 +155,6 @@ export const getFormattedComments = (comments: CommentsArray): EuiCommentProps[] ), })); -export const getFormattedBuilderEntries = ( - indexPattern: IIndexPattern, - entries: BuilderEntry[] -): FormattedBuilderEntry[] => { - const { fields } = indexPattern; - return entries.map((item) => { - if (entriesNested.is(item)) { - return { - parent: item.field, - operator: isOperator, - nested: getFormattedBuilderEntries(indexPattern, item.entries), - field: undefined, - value: undefined, - }; - } else { - const [selectedField] = fields.filter( - ({ name }) => item.field != null && item.field === name - ); - return { - field: selectedField, - operator: getExceptionOperatorSelect(item), - value: getEntryValue(item), - }; - } - }); -}; - -export const getValueFromOperator = ( - field: IFieldType | undefined, - selectedOperator: OperatorOption -): Entry => { - const fieldValue = field != null ? field.name : ''; - switch (selectedOperator.type) { - case 'match': - return { - field: fieldValue, - type: OperatorTypeEnum.MATCH, - operator: selectedOperator.operator, - value: '', - }; - case 'match_any': - return { - field: fieldValue, - type: OperatorTypeEnum.MATCH_ANY, - operator: selectedOperator.operator, - value: [], - }; - case 'list': - return { - field: fieldValue, - type: OperatorTypeEnum.LIST, - operator: selectedOperator.operator, - list: { id: '', type: 'ip' }, - }; - default: - return { - field: fieldValue, - type: OperatorTypeEnum.EXISTS, - operator: selectedOperator.operator, - }; - } -}; - export const getNewExceptionItem = ({ listType, listId, diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/translations.ts b/x-pack/plugins/security_solution/public/common/components/exceptions/translations.ts index 870f98f63ee2..87d2f9dcda93 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/translations.ts +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/translations.ts @@ -151,34 +151,6 @@ export const VALUE = i18n.translate('xpack.securitySolution.exceptions.valueDesc defaultMessage: 'Value', }); -export const EXCEPTION_FIELD_PLACEHOLDER = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionFieldPlaceholderDescription', - { - defaultMessage: 'Search', - } -); - -export const EXCEPTION_OPERATOR_PLACEHOLDER = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionOperatorPlaceholderDescription', - { - defaultMessage: 'Operator', - } -); - -export const EXCEPTION_FIELD_VALUE_PLACEHOLDER = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionFieldValuePlaceholderDescription', - { - defaultMessage: 'Search field value...', - } -); - -export const EXCEPTION_FIELD_LISTS_PLACEHOLDER = i18n.translate( - 'xpack.securitySolution.exceptions.exceptionListsPlaceholderDescription', - { - defaultMessage: 'Search for list...', - } -); - export const AND = i18n.translate('xpack.securitySolution.exceptions.andDescription', { defaultMessage: 'AND', }); @@ -187,13 +159,6 @@ export const OR = i18n.translate('xpack.securitySolution.exceptions.orDescriptio defaultMessage: 'OR', }); -export const ADD_NESTED_DESCRIPTION = i18n.translate( - 'xpack.securitySolution.exceptions.addNestedDescription', - { - defaultMessage: 'Add nested condition', - } -); - export const ADD_COMMENT_PLACEHOLDER = i18n.translate( 'xpack.securitySolution.exceptions.viewer.addCommentPlaceholder', { @@ -207,3 +172,7 @@ export const ADD_TO_CLIPBOARD = i18n.translate( defaultMessage: 'Add to clipboard', } ); + +export const DESCRIPTION = i18n.translate('xpack.securitySolution.exceptions.descriptionLabel', { + defaultMessage: 'Description', +}); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/types.ts b/x-pack/plugins/security_solution/public/common/components/exceptions/types.ts index 994aed3952cf..54caab03e615 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/types.ts +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/types.ts @@ -9,6 +9,9 @@ import { OperatorOption } from '../autocomplete/types'; import { EntryNested, Entry, + EntryMatch, + EntryMatchAny, + EntryExists, ExceptionListItemSchema, CreateExceptionListItemSchema, NamespaceType, @@ -52,15 +55,13 @@ export interface ExceptionsPagination { pageSizeOptions: number[]; } -export interface FormattedBuilderEntryBase { +export interface FormattedBuilderEntry { field: IFieldType | undefined; operator: OperatorOption; value: string | string[] | undefined; -} - -export interface FormattedBuilderEntry extends FormattedBuilderEntryBase { - parent?: string; - nested?: FormattedBuilderEntryBase[]; + nested: 'parent' | 'child' | undefined; + entryIndex: number; + parent: { parent: EntryNested; parentIndex: number } | undefined; } export interface EmptyEntry { @@ -77,7 +78,13 @@ export interface EmptyListEntry { list: { id: string | undefined; type: string | undefined }; } -export type BuilderEntry = Entry | EmptyListEntry | EmptyEntry | EntryNested; +export interface EmptyNestedEntry { + field: string | undefined; + type: OperatorTypeEnum.NESTED; + entries: Array; +} + +export type BuilderEntry = Entry | EmptyListEntry | EmptyEntry | EntryNested | EmptyNestedEntry; export type ExceptionListItemBuilderSchema = Omit & { entries: BuilderEntry[]; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.test.tsx index 4fc744c2c9d0..8df7b51bb9d3 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.test.tsx @@ -211,7 +211,7 @@ describe('ExceptionDetails', () => { ); - expect(wrapper.find('EuiDescriptionListTitle').at(3).text()).toEqual('Comment'); + expect(wrapper.find('EuiDescriptionListTitle').at(3).text()).toEqual('Description'); expect(wrapper.find('EuiDescriptionListDescription').at(3).text()).toEqual('some description'); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.tsx index 44632236ea7a..cca7d76899a1 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.tsx @@ -16,7 +16,7 @@ import React, { useMemo, Fragment } from 'react'; import styled, { css } from 'styled-components'; import { DescriptionListItem } from '../../types'; -import { getDescriptionListContent } from '../../helpers'; +import { getDescriptionListContent } from '../helpers'; import * as i18n from '../../translations'; import { ExceptionListItemSchema } from '../../../../../../public/lists_plugin_deps'; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/index.tsx index 3b85c6741a48..13a90091ba4c 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/index.tsx @@ -17,7 +17,8 @@ import styled from 'styled-components'; import { ExceptionDetails } from './exception_details'; import { ExceptionEntries } from './exception_entries'; -import { getFormattedEntries, getFormattedComments } from '../../helpers'; +import { getFormattedComments } from '../../helpers'; +import { getFormattedEntries } from '../helpers'; import { FormattedEntry, ExceptionListItemIdentifiers } from '../../types'; import { ExceptionListItemSchema } from '../../../../../../public/lists_plugin_deps'; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/helpers.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/helpers.test.tsx new file mode 100644 index 000000000000..fe00e3530fa8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/helpers.test.tsx @@ -0,0 +1,224 @@ +/* + * 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 moment from 'moment-timezone'; + +import { getFormattedEntries, formatEntry, getDescriptionListContent } from './helpers'; +import { FormattedEntry, DescriptionListItem } from '../types'; +import { getExceptionListItemSchemaMock } from '../../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; +import { getEntriesArrayMock } from '../../../../../../lists/common/schemas/types/entries.mock'; +import { getEntryMatchMock } from '../../../../../../lists/common/schemas/types/entry_match.mock'; +import { getEntryMatchAnyMock } from '../../../../../../lists/common/schemas/types/entry_match_any.mock'; +import { getEntryExistsMock } from '../../../../../../lists/common/schemas/types/entry_exists.mock'; + +describe('Exception viewer helpers', () => { + beforeEach(() => { + moment.tz.setDefault('UTC'); + }); + + afterEach(() => { + moment.tz.setDefault('Browser'); + }); + + describe('#getFormattedEntries', () => { + test('it returns empty array if no entries passed', () => { + const result = getFormattedEntries([]); + + expect(result).toEqual([]); + }); + + test('it formats nested entries as expected', () => { + const payload = [getEntryMatchMock()]; + const result = getFormattedEntries(payload); + const expected: FormattedEntry[] = [ + { + fieldName: 'host.name', + isNested: false, + operator: 'is', + value: 'some host name', + }, + ]; + expect(result).toEqual(expected); + }); + + test('it formats "exists" entries as expected', () => { + const payload = [getEntryExistsMock()]; + const result = getFormattedEntries(payload); + const expected: FormattedEntry[] = [ + { + fieldName: 'host.name', + isNested: false, + operator: 'exists', + value: undefined, + }, + ]; + expect(result).toEqual(expected); + }); + + test('it formats non-nested entries as expected', () => { + const payload = [getEntryMatchAnyMock(), getEntryMatchMock()]; + const result = getFormattedEntries(payload); + const expected: FormattedEntry[] = [ + { + fieldName: 'host.name', + isNested: false, + operator: 'is one of', + value: ['some host name'], + }, + { + fieldName: 'host.name', + isNested: false, + operator: 'is', + value: 'some host name', + }, + ]; + expect(result).toEqual(expected); + }); + + test('it formats a mix of nested and non-nested entries as expected', () => { + const payload = getEntriesArrayMock(); + const result = getFormattedEntries(payload); + const expected: FormattedEntry[] = [ + { + fieldName: 'host.name', + isNested: false, + operator: 'is', + value: 'some host name', + }, + { + fieldName: 'host.name', + isNested: false, + operator: 'is one of', + value: ['some host name'], + }, + { + fieldName: 'host.name', + isNested: false, + operator: 'is in list', + value: 'some-list-id', + }, + { + fieldName: 'host.name', + isNested: false, + operator: 'exists', + value: undefined, + }, + { + fieldName: 'host.name', + isNested: false, + operator: undefined, + value: undefined, + }, + { + fieldName: 'host.name.host.name', + isNested: true, + operator: 'is', + value: 'some host name', + }, + { + fieldName: 'host.name.host.name', + isNested: true, + operator: 'is one of', + value: ['some host name'], + }, + ]; + expect(result).toEqual(expected); + }); + }); + + describe('#formatEntry', () => { + test('it formats an entry', () => { + const payload = getEntryMatchMock(); + const formattedEntry = formatEntry({ isNested: false, item: payload }); + const expected: FormattedEntry = { + fieldName: 'host.name', + isNested: false, + operator: 'is', + value: 'some host name', + }; + + expect(formattedEntry).toEqual(expected); + }); + + test('it formats as expected when "isNested" is "true"', () => { + const payload = getEntryMatchMock(); + const formattedEntry = formatEntry({ isNested: true, parent: 'parent', item: payload }); + const expected: FormattedEntry = { + fieldName: 'parent.host.name', + isNested: true, + operator: 'is', + value: 'some host name', + }; + + expect(formattedEntry).toEqual(expected); + }); + }); + + describe('#getDescriptionListContent', () => { + test('it returns formatted description list with os if one is specified', () => { + const payload = getExceptionListItemSchemaMock(); + payload.description = ''; + const result = getDescriptionListContent(payload); + const expected: DescriptionListItem[] = [ + { + description: 'Linux', + title: 'OS', + }, + { + description: 'April 20th 2020 @ 15:25:31', + title: 'Date created', + }, + { + description: 'some user', + title: 'Created by', + }, + ]; + + expect(result).toEqual(expected); + }); + + test('it returns formatted description list with a description if one specified', () => { + const payload = getExceptionListItemSchemaMock(); + payload._tags = []; + payload.description = 'Im a description'; + const result = getDescriptionListContent(payload); + const expected: DescriptionListItem[] = [ + { + description: 'April 20th 2020 @ 15:25:31', + title: 'Date created', + }, + { + description: 'some user', + title: 'Created by', + }, + { + description: 'Im a description', + title: 'Description', + }, + ]; + + expect(result).toEqual(expected); + }); + + test('it returns just user and date created if no other fields specified', () => { + const payload = getExceptionListItemSchemaMock(); + payload._tags = []; + payload.description = ''; + const result = getDescriptionListContent(payload); + const expected: DescriptionListItem[] = [ + { + description: 'April 20th 2020 @ 15:25:31', + title: 'Date created', + }, + { + description: 'some user', + title: 'Created by', + }, + ]; + + expect(result).toEqual(expected); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/helpers.tsx new file mode 100644 index 000000000000..345db5bf1e75 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/helpers.tsx @@ -0,0 +1,109 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import moment from 'moment'; + +import { entriesNested, ExceptionListItemSchema } from '../../../../lists_plugin_deps'; +import { + getEntryValue, + getExceptionOperatorSelect, + formatOperatingSystems, + getOperatingSystems, +} from '../helpers'; +import { FormattedEntry, BuilderEntry, DescriptionListItem } from '../types'; +import * as i18n from '../translations'; + +/** + * Helper method for `getFormattedEntries` + */ +export const formatEntry = ({ + isNested, + parent, + item, +}: { + isNested: boolean; + parent?: string; + item: BuilderEntry; +}): FormattedEntry => { + const operator = getExceptionOperatorSelect(item); + const value = getEntryValue(item); + + return { + fieldName: isNested ? `${parent}.${item.field}` : item.field ?? '', + operator: operator.message, + value, + isNested, + }; +}; + +/** + * Formats ExceptionItem entries into simple field, operator, value + * for use in rendering items in table + * + * @param entries an ExceptionItem's entries + */ +export const getFormattedEntries = (entries: BuilderEntry[]): FormattedEntry[] => { + const formattedEntries = entries.map((item) => { + if (entriesNested.is(item)) { + const parent = { + fieldName: item.field, + operator: undefined, + value: undefined, + isNested: false, + }; + return item.entries.reduce( + (acc, nestedEntry) => { + const formattedEntry = formatEntry({ + isNested: true, + parent: item.field, + item: nestedEntry, + }); + return [...acc, { ...formattedEntry }]; + }, + [parent] + ); + } else { + return formatEntry({ isNested: false, item }); + } + }); + + return formattedEntries.flat(); +}; + +/** + * Formats ExceptionItem details for description list component + * + * @param exceptionItem an ExceptionItem + */ +export const getDescriptionListContent = ( + exceptionItem: ExceptionListItemSchema +): DescriptionListItem[] => { + const details = [ + { + title: i18n.OPERATING_SYSTEM, + value: formatOperatingSystems(getOperatingSystems(exceptionItem._tags ?? [])), + }, + { + title: i18n.DATE_CREATED, + value: moment(exceptionItem.created_at).format('MMMM Do YYYY @ HH:mm:ss'), + }, + { + title: i18n.CREATED_BY, + value: exceptionItem.created_by, + }, + { + title: i18n.DESCRIPTION, + value: exceptionItem.description, + }, + ]; + + return details.reduce((acc, { value, title }) => { + if (value != null && value.trim() !== '') { + return [...acc, { title, description: value }]; + } else { + return acc; + } + }, []); +}; From 1343643696d5af16cf084338c673a7aa5102f5a7 Mon Sep 17 00:00:00 2001 From: John Schulz Date: Wed, 22 Jul 2020 18:24:04 -0400 Subject: [PATCH 074/142] [Ingest Manager] Add more Fleet concurrency tests #71744 (#72338) * Refactor to make more testable. Add more tests. * Remove ts-ignores. Add comment re: testing limitation Co-authored-by: Elastic Machine --- .../server/routes/limited_concurrency.test.ts | 188 +++++++++++++++++- .../server/routes/limited_concurrency.ts | 45 +++-- 2 files changed, 217 insertions(+), 16 deletions(-) diff --git a/x-pack/plugins/ingest_manager/server/routes/limited_concurrency.test.ts b/x-pack/plugins/ingest_manager/server/routes/limited_concurrency.test.ts index a0bb8e9b86fb..f84f417ce402 100644 --- a/x-pack/plugins/ingest_manager/server/routes/limited_concurrency.test.ts +++ b/x-pack/plugins/ingest_manager/server/routes/limited_concurrency.test.ts @@ -4,8 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { coreMock } from 'src/core/server/mocks'; -import { registerLimitedConcurrencyRoutes } from './limited_concurrency'; +import { coreMock, httpServerMock, httpServiceMock } from 'src/core/server/mocks'; +import { + createLimitedPreAuthHandler, + isLimitedRoute, + registerLimitedConcurrencyRoutes, +} from './limited_concurrency'; import { IngestManagerConfigType } from '../index'; describe('registerLimitedConcurrencyRoutes', () => { @@ -33,3 +37,183 @@ describe('registerLimitedConcurrencyRoutes', () => { expect(mockSetup.http.registerOnPreAuth).toHaveBeenCalledTimes(1); }); }); + +// assertions for calls to .decrease are commented out because it's called on the +// "req.events.aborted$ observable (which) will never emit from a mocked request in a jest unit test environment" +// https://github.com/elastic/kibana/pull/72338#issuecomment-661908791 +describe('preAuthHandler', () => { + test(`ignores routes when !isMatch`, async () => { + const mockMaxCounter = { + increase: jest.fn(), + decrease: jest.fn(), + lessThanMax: jest.fn(), + }; + const preAuthHandler = createLimitedPreAuthHandler({ + isMatch: jest.fn().mockImplementation(() => false), + maxCounter: mockMaxCounter, + }); + + const mockRequest = httpServerMock.createKibanaRequest({ + path: '/no/match', + }); + const mockResponse = httpServerMock.createResponseFactory(); + const mockPreAuthToolkit = httpServiceMock.createOnPreAuthToolkit(); + + await preAuthHandler(mockRequest, mockResponse, mockPreAuthToolkit); + + expect(mockMaxCounter.increase).not.toHaveBeenCalled(); + expect(mockMaxCounter.decrease).not.toHaveBeenCalled(); + expect(mockMaxCounter.lessThanMax).not.toHaveBeenCalled(); + expect(mockPreAuthToolkit.next).toHaveBeenCalledTimes(1); + }); + + test(`ignores routes which don't have the correct tag`, async () => { + const mockMaxCounter = { + increase: jest.fn(), + decrease: jest.fn(), + lessThanMax: jest.fn(), + }; + const preAuthHandler = createLimitedPreAuthHandler({ + isMatch: isLimitedRoute, + maxCounter: mockMaxCounter, + }); + + const mockRequest = httpServerMock.createKibanaRequest({ + path: '/no/match', + }); + const mockResponse = httpServerMock.createResponseFactory(); + const mockPreAuthToolkit = httpServiceMock.createOnPreAuthToolkit(); + + await preAuthHandler(mockRequest, mockResponse, mockPreAuthToolkit); + + expect(mockMaxCounter.increase).not.toHaveBeenCalled(); + expect(mockMaxCounter.decrease).not.toHaveBeenCalled(); + expect(mockMaxCounter.lessThanMax).not.toHaveBeenCalled(); + expect(mockPreAuthToolkit.next).toHaveBeenCalledTimes(1); + }); + + test(`processes routes which have the correct tag`, async () => { + const mockMaxCounter = { + increase: jest.fn(), + decrease: jest.fn(), + lessThanMax: jest.fn().mockImplementation(() => true), + }; + const preAuthHandler = createLimitedPreAuthHandler({ + isMatch: isLimitedRoute, + maxCounter: mockMaxCounter, + }); + + const mockRequest = httpServerMock.createKibanaRequest({ + path: '/should/match', + routeTags: ['ingest:limited-concurrency'], + }); + const mockResponse = httpServerMock.createResponseFactory(); + const mockPreAuthToolkit = httpServiceMock.createOnPreAuthToolkit(); + + await preAuthHandler(mockRequest, mockResponse, mockPreAuthToolkit); + + // will call lessThanMax because isMatch succeeds + expect(mockMaxCounter.lessThanMax).toHaveBeenCalledTimes(1); + // will not error because lessThanMax is true + expect(mockResponse.customError).not.toHaveBeenCalled(); + expect(mockPreAuthToolkit.next).toHaveBeenCalledTimes(1); + }); + + test(`updates the counter when isMatch & lessThanMax`, async () => { + const mockMaxCounter = { + increase: jest.fn(), + decrease: jest.fn(), + lessThanMax: jest.fn().mockImplementation(() => true), + }; + const preAuthHandler = createLimitedPreAuthHandler({ + isMatch: jest.fn().mockImplementation(() => true), + maxCounter: mockMaxCounter, + }); + + const mockRequest = httpServerMock.createKibanaRequest(); + const mockResponse = httpServerMock.createResponseFactory(); + const mockPreAuthToolkit = httpServiceMock.createOnPreAuthToolkit(); + + await preAuthHandler(mockRequest, mockResponse, mockPreAuthToolkit); + + expect(mockMaxCounter.increase).toHaveBeenCalled(); + // expect(mockMaxCounter.decrease).toHaveBeenCalled(); + expect(mockPreAuthToolkit.next).toHaveBeenCalledTimes(1); + }); + + test(`lessThanMax ? next : error`, async () => { + const mockMaxCounter = { + increase: jest.fn(), + decrease: jest.fn(), + lessThanMax: jest + .fn() + // call 1 + .mockImplementationOnce(() => true) + // calls 2, 3, 4 + .mockImplementationOnce(() => false) + .mockImplementationOnce(() => false) + .mockImplementationOnce(() => false) + // calls 5+ + .mockImplementationOnce(() => true) + .mockImplementation(() => true), + }; + + const preAuthHandler = createLimitedPreAuthHandler({ + isMatch: isLimitedRoute, + maxCounter: mockMaxCounter, + }); + + function makeRequestExpectNext() { + const request = httpServerMock.createKibanaRequest({ + path: '/should/match/', + routeTags: ['ingest:limited-concurrency'], + }); + const response = httpServerMock.createResponseFactory(); + const toolkit = httpServiceMock.createOnPreAuthToolkit(); + + preAuthHandler(request, response, toolkit); + expect(toolkit.next).toHaveBeenCalledTimes(1); + expect(response.customError).not.toHaveBeenCalled(); + } + + function makeRequestExpectError() { + const request = httpServerMock.createKibanaRequest({ + path: '/should/match/', + routeTags: ['ingest:limited-concurrency'], + }); + const response = httpServerMock.createResponseFactory(); + const toolkit = httpServiceMock.createOnPreAuthToolkit(); + + preAuthHandler(request, response, toolkit); + expect(toolkit.next).not.toHaveBeenCalled(); + expect(response.customError).toHaveBeenCalledTimes(1); + expect(response.customError).toHaveBeenCalledWith({ + statusCode: 429, + body: 'Too Many Requests', + }); + } + + // request 1 succeeds + makeRequestExpectNext(); + expect(mockMaxCounter.increase).toHaveBeenCalledTimes(1); + // expect(mockMaxCounter.decrease).toHaveBeenCalledTimes(1); + + // requests 2, 3, 4 fail + makeRequestExpectError(); + makeRequestExpectError(); + makeRequestExpectError(); + + // requests 5+ succeed + makeRequestExpectNext(); + expect(mockMaxCounter.increase).toHaveBeenCalledTimes(2); + // expect(mockMaxCounter.decrease).toHaveBeenCalledTimes(2); + + makeRequestExpectNext(); + expect(mockMaxCounter.increase).toHaveBeenCalledTimes(3); + // expect(mockMaxCounter.decrease).toHaveBeenCalledTimes(3); + + makeRequestExpectNext(); + expect(mockMaxCounter.increase).toHaveBeenCalledTimes(4); + // expect(mockMaxCounter.decrease).toHaveBeenCalledTimes(4); + }); +}); diff --git a/x-pack/plugins/ingest_manager/server/routes/limited_concurrency.ts b/x-pack/plugins/ingest_manager/server/routes/limited_concurrency.ts index ec8e2f6c8d43..11fdc944e031 100644 --- a/x-pack/plugins/ingest_manager/server/routes/limited_concurrency.ts +++ b/x-pack/plugins/ingest_manager/server/routes/limited_concurrency.ts @@ -12,7 +12,8 @@ import { } from 'kibana/server'; import { LIMITED_CONCURRENCY_ROUTE_TAG } from '../../common'; import { IngestManagerConfigType } from '../index'; -class MaxCounter { + +export class MaxCounter { constructor(private readonly max: number = 1) {} private counter = 0; valueOf() { @@ -33,40 +34,56 @@ class MaxCounter { } } -function shouldHandleRequest(request: KibanaRequest) { +export type IMaxCounter = Pick; + +export function isLimitedRoute(request: KibanaRequest) { const tags = request.route.options.tags; - return tags.includes(LIMITED_CONCURRENCY_ROUTE_TAG); + return !!tags.includes(LIMITED_CONCURRENCY_ROUTE_TAG); } -export function registerLimitedConcurrencyRoutes(core: CoreSetup, config: IngestManagerConfigType) { - const max = config.fleet.maxConcurrentConnections; - if (!max) return; - - const counter = new MaxCounter(max); - core.http.registerOnPreAuth(function preAuthHandler( +export function createLimitedPreAuthHandler({ + isMatch, + maxCounter, +}: { + isMatch: (request: KibanaRequest) => boolean; + maxCounter: IMaxCounter; +}) { + return function preAuthHandler( request: KibanaRequest, response: LifecycleResponseFactory, toolkit: OnPreAuthToolkit ) { - if (!shouldHandleRequest(request)) { + if (!isMatch(request)) { return toolkit.next(); } - if (!counter.lessThanMax()) { + if (!maxCounter.lessThanMax()) { return response.customError({ body: 'Too Many Requests', statusCode: 429, }); } - counter.increase(); + maxCounter.increase(); // requests.events.aborted$ has a bug (but has test which explicitly verifies) where it's fired even when the request completes // https://github.com/elastic/kibana/pull/70495#issuecomment-656288766 request.events.aborted$.toPromise().then(() => { - counter.decrease(); + maxCounter.decrease(); }); return toolkit.next(); - }); + }; +} + +export function registerLimitedConcurrencyRoutes(core: CoreSetup, config: IngestManagerConfigType) { + const max = config.fleet.maxConcurrentConnections; + if (!max) return; + + core.http.registerOnPreAuth( + createLimitedPreAuthHandler({ + isMatch: isLimitedRoute, + maxCounter: new MaxCounter(max), + }) + ); } From bacf9f2aba9e37fee91839b56a498e02d4bab164 Mon Sep 17 00:00:00 2001 From: Chris Roberson Date: Wed, 22 Jul 2020 19:28:00 -0400 Subject: [PATCH 075/142] [Monitoring] Fix issues displaying alerts (#72891) * Fix issues displaying alerts * Fix type issues * More support for multiple alerts Co-authored-by: Elastic Machine --- .../monitoring/public/alerts/badge.tsx | 36 ++-- .../monitoring/public/alerts/callout.tsx | 9 +- .../monitoring/public/alerts/panel.tsx | 20 +- .../monitoring/public/alerts/status.tsx | 22 +- .../components/elasticsearch/node/node.js | 8 +- .../elasticsearch/node_detail_status/index.js | 4 +- .../components/elasticsearch/nodes/nodes.js | 10 +- .../server/alerts/cpu_usage_alert.test.ts | 192 ++++++++++++++++++ .../server/alerts/cpu_usage_alert.ts | 93 +++++---- 9 files changed, 313 insertions(+), 81 deletions(-) diff --git a/x-pack/plugins/monitoring/public/alerts/badge.tsx b/x-pack/plugins/monitoring/public/alerts/badge.tsx index 4518d2c56cab..02963e9457ab 100644 --- a/x-pack/plugins/monitoring/public/alerts/badge.tsx +++ b/x-pack/plugins/monitoring/public/alerts/badge.tsx @@ -23,18 +23,25 @@ import { AlertPanel } from './panel'; import { Legacy } from '../legacy_shims'; import { isInSetupMode } from '../lib/setup_mode'; -function getDateFromState(states: CommonAlertState[]) { - const timestamp = states[0].state.ui.triggeredMS; +function getDateFromState(state: CommonAlertState) { + const timestamp = state.state.ui.triggeredMS; const tz = Legacy.shims.uiSettings.get('dateFormat:tz'); return formatDateTimeLocal(timestamp, false, tz === 'Browser' ? null : tz); } export const numberOfAlertsLabel = (count: number) => `${count} alert${count > 1 ? 's' : ''}`; +interface AlertInPanel { + alert: CommonAlertStatus; + alertState: CommonAlertState; +} + interface Props { alerts: { [alertTypeId: string]: CommonAlertStatus }; + stateFilter: (state: AlertState) => boolean; } export const AlertsBadge: React.FC = (props: Props) => { + const { stateFilter = () => true } = props; const [showPopover, setShowPopover] = React.useState(null); const inSetupMode = isInSetupMode(); const alerts = Object.values(props.alerts).filter(Boolean); @@ -93,15 +100,20 @@ export const AlertsBadge: React.FC = (props: Props) => { ); } else { const byType = { - [AlertSeverity.Danger]: [] as CommonAlertStatus[], - [AlertSeverity.Warning]: [] as CommonAlertStatus[], - [AlertSeverity.Success]: [] as CommonAlertStatus[], + [AlertSeverity.Danger]: [] as AlertInPanel[], + [AlertSeverity.Warning]: [] as AlertInPanel[], + [AlertSeverity.Success]: [] as AlertInPanel[], }; for (const alert of alerts) { for (const alertState of alert.states) { - const state = alertState.state as AlertState; - byType[state.ui.severity].push(alert); + if (alertState.firing && stateFilter(alertState.state)) { + const state = alertState.state as AlertState; + byType[state.ui.severity].push({ + alertState, + alert, + }); + } } } @@ -127,14 +139,14 @@ export const AlertsBadge: React.FC = (props: Props) => { { id: 0, title: `Alerts`, - items: list.map(({ alert, states }, index) => { + items: list.map(({ alert, alertState }, index) => { return { name: ( -

{getDateFromState(states)}

+

{getDateFromState(alertState)}

- {alert.label} + {alert.alert.label}
), panel: index + 1, @@ -144,9 +156,9 @@ export const AlertsBadge: React.FC = (props: Props) => { ...list.map((alertStatus, index) => { return { id: index + 1, - title: getDateFromState(alertStatus.states), + title: getDateFromState(alertStatus.alertState), width: 400, - content: , + content: , }; }), ]; diff --git a/x-pack/plugins/monitoring/public/alerts/callout.tsx b/x-pack/plugins/monitoring/public/alerts/callout.tsx index d000f470da33..cad98dd1e6ae 100644 --- a/x-pack/plugins/monitoring/public/alerts/callout.tsx +++ b/x-pack/plugins/monitoring/public/alerts/callout.tsx @@ -10,7 +10,7 @@ import { EuiCallOut, EuiSpacer } from '@elastic/eui'; import { CommonAlertStatus } from '../../common/types'; import { AlertSeverity } from '../../common/enums'; import { replaceTokens } from './lib/replace_tokens'; -import { AlertMessage } from '../../server/alerts/types'; +import { AlertMessage, AlertState } from '../../server/alerts/types'; const TYPES = [ { @@ -31,16 +31,17 @@ const TYPES = [ interface Props { alerts: { [alertTypeId: string]: CommonAlertStatus }; + stateFilter: (state: AlertState) => boolean; } export const AlertsCallout: React.FC = (props: Props) => { - const { alerts } = props; + const { alerts, stateFilter = () => true } = props; const callouts = TYPES.map((type) => { const list = []; for (const alertTypeId of Object.keys(alerts)) { const alertInstance = alerts[alertTypeId]; - for (const { state } of alertInstance.states) { - if (state.ui.severity === type.severity) { + for (const { firing, state } of alertInstance.states) { + if (firing && stateFilter(state) && state.ui.severity === type.severity) { list.push(state); } } diff --git a/x-pack/plugins/monitoring/public/alerts/panel.tsx b/x-pack/plugins/monitoring/public/alerts/panel.tsx index 3c5a4ef55a96..91a426cc8798 100644 --- a/x-pack/plugins/monitoring/public/alerts/panel.tsx +++ b/x-pack/plugins/monitoring/public/alerts/panel.tsx @@ -18,7 +18,7 @@ import { EuiListGroupItem, } from '@elastic/eui'; -import { CommonAlertStatus } from '../../common/types'; +import { CommonAlertStatus, CommonAlertState } from '../../common/types'; import { AlertMessage } from '../../server/alerts/types'; import { Legacy } from '../legacy_shims'; import { replaceTokens } from './lib/replace_tokens'; @@ -30,10 +30,12 @@ import { BASE_ALERT_API_PATH } from '../../../alerts/common'; interface Props { alert: CommonAlertStatus; + alertState?: CommonAlertState; } export const AlertPanel: React.FC = (props: Props) => { const { - alert: { states, alert }, + alert: { alert }, + alertState, } = props; const [showFlyout, setShowFlyout] = React.useState(false); const [isEnabled, setIsEnabled] = React.useState(alert.rawAlert.enabled); @@ -190,20 +192,14 @@ export const AlertPanel: React.FC = (props: Props) => { ); - if (inSetupMode) { + if (inSetupMode || !alertState) { return
{configurationUi}
; } - const firingStates = states.filter((state) => state.firing); - if (!firingStates.length) { - return
{configurationUi}
; - } - - const firingState = firingStates[0]; const nextStepsUi = - firingState.state.ui.message.nextSteps && firingState.state.ui.message.nextSteps.length ? ( + alertState.state.ui.message.nextSteps && alertState.state.ui.message.nextSteps.length ? ( - {firingState.state.ui.message.nextSteps.map((step: AlertMessage, index: number) => ( + {alertState.state.ui.message.nextSteps.map((step: AlertMessage, index: number) => ( ))} @@ -213,7 +209,7 @@ export const AlertPanel: React.FC = (props: Props) => {
-
{replaceTokens(firingState.state.ui.message)}
+
{replaceTokens(alertState.state.ui.message)}
{nextStepsUi ? : null} {nextStepsUi} diff --git a/x-pack/plugins/monitoring/public/alerts/status.tsx b/x-pack/plugins/monitoring/public/alerts/status.tsx index 9c262884d725..0407ddfecf5e 100644 --- a/x-pack/plugins/monitoring/public/alerts/status.tsx +++ b/x-pack/plugins/monitoring/public/alerts/status.tsx @@ -11,14 +11,17 @@ import { CommonAlertStatus } from '../../common/types'; import { AlertSeverity } from '../../common/enums'; import { AlertState } from '../../server/alerts/types'; import { AlertsBadge } from './badge'; +import { isInSetupMode } from '../lib/setup_mode'; interface Props { alerts: { [alertTypeId: string]: CommonAlertStatus }; showBadge: boolean; showOnlyCount: boolean; + stateFilter: (state: AlertState) => boolean; } export const AlertsStatus: React.FC = (props: Props) => { - const { alerts, showBadge = false, showOnlyCount = false } = props; + const { alerts, showBadge = false, showOnlyCount = false, stateFilter = () => true } = props; + const inSetupMode = isInSetupMode(); if (!alerts) { return null; @@ -26,21 +29,26 @@ export const AlertsStatus: React.FC = (props: Props) => { let atLeastOneDanger = false; const count = Object.values(alerts).reduce((cnt, alertStatus) => { - if (alertStatus.states.length) { + const firingStates = alertStatus.states.filter((state) => state.firing); + const firingAndFilterStates = firingStates.filter((state) => stateFilter(state.state)); + cnt += firingAndFilterStates.length; + if (firingStates.length) { if (!atLeastOneDanger) { for (const state of alertStatus.states) { - if ((state.state as AlertState).ui.severity === AlertSeverity.Danger) { + if ( + stateFilter(state.state) && + (state.state as AlertState).ui.severity === AlertSeverity.Danger + ) { atLeastOneDanger = true; break; } } } - cnt++; } return cnt; }, 0); - if (count === 0) { + if (count === 0 && (!inSetupMode || showOnlyCount)) { return ( = (props: Props) => { ); } - if (showBadge) { - return ; + if (showBadge || inSetupMode) { + return ; } const severity = atLeastOneDanger ? AlertSeverity.Danger : AlertSeverity.Warning; diff --git a/x-pack/plugins/monitoring/public/components/elasticsearch/node/node.js b/x-pack/plugins/monitoring/public/components/elasticsearch/node/node.js index f91e251030d7..ac1a5212a8d2 100644 --- a/x-pack/plugins/monitoring/public/components/elasticsearch/node/node.js +++ b/x-pack/plugins/monitoring/public/components/elasticsearch/node/node.js @@ -70,10 +70,14 @@ export const Node = ({ - + state.nodeId === nodeId} + /> - + state.nodeId === nodeId} /> {metricsToShow.map((metric, index) => ( diff --git a/x-pack/plugins/monitoring/public/components/elasticsearch/node_detail_status/index.js b/x-pack/plugins/monitoring/public/components/elasticsearch/node_detail_status/index.js index 85b4d0daddad..77d0b294f66d 100644 --- a/x-pack/plugins/monitoring/public/components/elasticsearch/node_detail_status/index.js +++ b/x-pack/plugins/monitoring/public/components/elasticsearch/node_detail_status/index.js @@ -11,7 +11,7 @@ import { formatMetric } from '../../../lib/format_number'; import { i18n } from '@kbn/i18n'; import { AlertsStatus } from '../../../alerts/status'; -export function NodeDetailStatus({ stats, alerts = {} }) { +export function NodeDetailStatus({ stats, alerts = {}, alertsStateFilter = () => true }) { const { transport_address: transportAddress, usedHeap, @@ -33,7 +33,7 @@ export function NodeDetailStatus({ stats, alerts = {} }) { label: i18n.translate('xpack.monitoring.elasticsearch.nodeDetailStatus.alerts', { defaultMessage: 'Alerts', }), - value: , + value: , }, { label: i18n.translate('xpack.monitoring.elasticsearch.nodeDetailStatus.transportAddress', { diff --git a/x-pack/plugins/monitoring/public/components/elasticsearch/nodes/nodes.js b/x-pack/plugins/monitoring/public/components/elasticsearch/nodes/nodes.js index c2e5c8e22a1c..b7463fe6532b 100644 --- a/x-pack/plugins/monitoring/public/components/elasticsearch/nodes/nodes.js +++ b/x-pack/plugins/monitoring/public/components/elasticsearch/nodes/nodes.js @@ -131,8 +131,14 @@ const getColumns = (showCgroupMetricsElasticsearch, setupMode, clusterUuid, aler field: 'alerts', width: '175px', sortable: true, - render: () => { - return ; + render: (_field, node) => { + return ( + state.nodeId === node.resolver} + /> + ); }, }); diff --git a/x-pack/plugins/monitoring/server/alerts/cpu_usage_alert.test.ts b/x-pack/plugins/monitoring/server/alerts/cpu_usage_alert.test.ts index 1a66560ae124..2596252c92d1 100644 --- a/x-pack/plugins/monitoring/server/alerts/cpu_usage_alert.test.ts +++ b/x-pack/plugins/monitoring/server/alerts/cpu_usage_alert.test.ts @@ -372,5 +372,197 @@ describe('CpuUsageAlert', () => { state: 'firing', }); }); + + it('should show proper counts for resolved and firing nodes', async () => { + (fetchCpuUsageNodeStats as jest.Mock).mockImplementation(() => { + return [ + { + ...stat, + cpuUsage: 1, + }, + { + ...stat, + nodeId: 'anotherNode', + nodeName: 'anotherNode', + cpuUsage: 99, + }, + ]; + }); + (getState as jest.Mock).mockImplementation(() => { + return { + alertStates: [ + { + cluster: { + clusterUuid, + clusterName, + }, + ccs: null, + cpuUsage: 91, + nodeId, + nodeName, + ui: { + isFiring: true, + message: null, + severity: 'danger', + resolvedMS: 0, + triggeredMS: 1, + lastCheckedMS: 0, + }, + }, + { + cluster: { + clusterUuid, + clusterName, + }, + ccs: null, + cpuUsage: 100, + nodeId: 'anotherNode', + nodeName: 'anotherNode', + ui: { + isFiring: true, + message: null, + severity: 'danger', + resolvedMS: 0, + triggeredMS: 1, + lastCheckedMS: 0, + }, + }, + ], + }; + }); + const alert = new CpuUsageAlert(); + alert.initializeAlertType( + getUiSettingsService as any, + monitoringCluster as any, + getLogger as any, + config as any, + kibanaUrl + ); + const type = alert.getAlertType(); + await type.executor({ + ...executorOptions, + // @ts-ignore + params: alert.defaultParams, + } as any); + const count = 1; + expect(replaceState).toHaveBeenCalledWith({ + alertStates: [ + { + cluster: { clusterUuid, clusterName }, + ccs: null, + cpuUsage: 1, + nodeId, + nodeName, + ui: { + isFiring: false, + message: { + text: + 'The cpu usage on node myNodeName is now under the threshold, currently reporting at 1.00% as of #resolved', + tokens: [ + { + startToken: '#resolved', + type: 'time', + isAbsolute: true, + isRelative: false, + timestamp: 1, + }, + ], + }, + severity: 'danger', + resolvedMS: 1, + triggeredMS: 1, + lastCheckedMS: 0, + }, + }, + { + ccs: null, + cluster: { clusterUuid, clusterName }, + cpuUsage: 99, + nodeId: 'anotherNode', + nodeName: 'anotherNode', + ui: { + isFiring: true, + message: { + text: + 'Node #start_linkanotherNode#end_link is reporting cpu usage of 99.00% at #absolute', + nextSteps: [ + { + text: '#start_linkCheck hot threads#end_link', + tokens: [ + { + startToken: '#start_link', + endToken: '#end_link', + type: 'docLink', + partialUrl: + '{elasticWebsiteUrl}/guide/en/elasticsearch/reference/{docLinkVersion}/cluster-nodes-hot-threads.html', + }, + ], + }, + { + text: '#start_linkCheck long running tasks#end_link', + tokens: [ + { + startToken: '#start_link', + endToken: '#end_link', + type: 'docLink', + partialUrl: + '{elasticWebsiteUrl}/guide/en/elasticsearch/reference/{docLinkVersion}/tasks.html', + }, + ], + }, + ], + tokens: [ + { + startToken: '#absolute', + type: 'time', + isAbsolute: true, + isRelative: false, + timestamp: 1, + }, + { + startToken: '#start_link', + endToken: '#end_link', + type: 'link', + url: 'elasticsearch/nodes/anotherNode', + }, + ], + }, + severity: 'danger', + resolvedMS: 0, + triggeredMS: 1, + lastCheckedMS: 0, + }, + }, + ], + }); + expect(scheduleActions).toHaveBeenCalledTimes(1); + // expect(scheduleActions.mock.calls[0]).toEqual([ + // 'default', + // { + // internalFullMessage: `CPU usage alert is resolved for ${count} node(s) in cluster: ${clusterName}.`, + // internalShortMessage: `CPU usage alert is resolved for ${count} node(s) in cluster: ${clusterName}.`, + // clusterName, + // count, + // nodes: `${nodeName}:1.00`, + // state: 'resolved', + // }, + // ]); + expect(scheduleActions.mock.calls[0]).toEqual([ + 'default', + { + action: + '[View nodes](http://localhost:5601/app/monitoring#elasticsearch/nodes?_g=(cluster_uuid:abc123))', + actionPlain: 'Verify CPU levels across affected nodes.', + internalFullMessage: + 'CPU usage alert is firing for 1 node(s) in cluster: testCluster. [View nodes](http://localhost:5601/app/monitoring#elasticsearch/nodes?_g=(cluster_uuid:abc123))', + internalShortMessage: + 'CPU usage alert is firing for 1 node(s) in cluster: testCluster. Verify CPU levels across affected nodes.', + nodes: 'anotherNode:99.00', + clusterName, + count, + state: 'firing', + }, + ]); + }); }); }); diff --git a/x-pack/plugins/monitoring/server/alerts/cpu_usage_alert.ts b/x-pack/plugins/monitoring/server/alerts/cpu_usage_alert.ts index b543a4c97637..4742f5548704 100644 --- a/x-pack/plugins/monitoring/server/alerts/cpu_usage_alert.ts +++ b/x-pack/plugins/monitoring/server/alerts/cpu_usage_alert.ts @@ -291,13 +291,6 @@ export class CpuUsageAlert extends BaseAlert { return; } - const nodes = instanceState.alertStates - .map((_state) => { - const state = _state as AlertCpuUsageState; - return `${state.nodeName}:${state.cpuUsage.toFixed(2)}`; - }) - .join(','); - const ccs = instanceState.alertStates.reduce((accum: string, state): string => { if (state.ccs) { return state.ccs; @@ -305,35 +298,16 @@ export class CpuUsageAlert extends BaseAlert { return accum; }, ''); - const count = instanceState.alertStates.length; - if (!instanceState.alertStates[0].ui.isFiring) { - instance.scheduleActions('default', { - internalShortMessage: i18n.translate( - 'xpack.monitoring.alerts.cpuUsage.resolved.internalShortMessage', - { - defaultMessage: `CPU usage alert is resolved for {count} node(s) in cluster: {clusterName}.`, - values: { - count, - clusterName: cluster.clusterName, - }, - } - ), - internalFullMessage: i18n.translate( - 'xpack.monitoring.alerts.cpuUsage.resolved.internalFullMessage', - { - defaultMessage: `CPU usage alert is resolved for {count} node(s) in cluster: {clusterName}.`, - values: { - count, - clusterName: cluster.clusterName, - }, - } - ), - state: RESOLVED, - nodes, - count, - clusterName: cluster.clusterName, - }); - } else { + const firingCount = instanceState.alertStates.filter((alertState) => alertState.ui.isFiring) + .length; + const firingNodes = instanceState.alertStates + .filter((_state) => (_state as AlertCpuUsageState).ui.isFiring) + .map((_state) => { + const state = _state as AlertCpuUsageState; + return `${state.nodeName}:${state.cpuUsage.toFixed(2)}`; + }) + .join(','); + if (firingCount > 0) { const shortActionText = i18n.translate('xpack.monitoring.alerts.cpuUsage.shortAction', { defaultMessage: 'Verify CPU levels across affected nodes.', }); @@ -354,7 +328,7 @@ export class CpuUsageAlert extends BaseAlert { { defaultMessage: `CPU usage alert is firing for {count} node(s) in cluster: {clusterName}. {shortActionText}`, values: { - count, + count: firingCount, clusterName: cluster.clusterName, shortActionText, }, @@ -365,19 +339,58 @@ export class CpuUsageAlert extends BaseAlert { { defaultMessage: `CPU usage alert is firing for {count} node(s) in cluster: {clusterName}. {action}`, values: { - count, + count: firingCount, clusterName: cluster.clusterName, action, }, } ), state: FIRING, - nodes, - count, + nodes: firingNodes, + count: firingCount, clusterName: cluster.clusterName, action, actionPlain: shortActionText, }); + } else { + const resolvedCount = instanceState.alertStates.filter( + (alertState) => !alertState.ui.isFiring + ).length; + const resolvedNodes = instanceState.alertStates + .filter((_state) => !(_state as AlertCpuUsageState).ui.isFiring) + .map((_state) => { + const state = _state as AlertCpuUsageState; + return `${state.nodeName}:${state.cpuUsage.toFixed(2)}`; + }) + .join(','); + if (resolvedCount > 0) { + instance.scheduleActions('default', { + internalShortMessage: i18n.translate( + 'xpack.monitoring.alerts.cpuUsage.resolved.internalShortMessage', + { + defaultMessage: `CPU usage alert is resolved for {count} node(s) in cluster: {clusterName}.`, + values: { + count: resolvedCount, + clusterName: cluster.clusterName, + }, + } + ), + internalFullMessage: i18n.translate( + 'xpack.monitoring.alerts.cpuUsage.resolved.internalFullMessage', + { + defaultMessage: `CPU usage alert is resolved for {count} node(s) in cluster: {clusterName}.`, + values: { + count: resolvedCount, + clusterName: cluster.clusterName, + }, + } + ), + state: RESOLVED, + nodes: resolvedNodes, + count: resolvedCount, + clusterName: cluster.clusterName, + }); + } } } From ac8cdf34bae570f47c780969c0835fd9427a9038 Mon Sep 17 00:00:00 2001 From: Pedro Jaramillo Date: Thu, 23 Jul 2020 02:22:51 +0200 Subject: [PATCH 076/142] Fix bug where user can't add an exception when "close alert" is checked (#72919) Co-authored-by: Elastic Machine --- .../exceptions/use_add_exception.test.tsx | 2 +- .../exceptions/use_add_exception.tsx | 22 +++++++++++++++---- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.test.tsx index bf07ff21823e..cb1a80abedb2 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.test.tsx @@ -144,7 +144,7 @@ describe('useAddOrUpdateException', () => { await act(async () => { const { result, waitForNextUpdate } = render(); await waitForNextUpdate(); - expect(result.current).toEqual([{ isLoading: false }, null]); + expect(result.current).toEqual([{ isLoading: false }, result.current[1]]); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.tsx index 55c3ea35716d..9d45a411b513 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState, useCallback } from 'react'; import { HttpStart } from '../../../../../../../src/core/public'; import { @@ -60,7 +60,19 @@ export const useAddOrUpdateException = ({ onSuccess, }: UseAddOrUpdateExceptionProps): ReturnUseAddOrUpdateException => { const [isLoading, setIsLoading] = useState(false); - const addOrUpdateException = useRef(null); + const addOrUpdateExceptionRef = useRef(null); + const addOrUpdateException = useCallback( + async (exceptionItemsToAddOrUpdate, alertIdToClose, bulkCloseIndex) => { + if (addOrUpdateExceptionRef.current !== null) { + addOrUpdateExceptionRef.current( + exceptionItemsToAddOrUpdate, + alertIdToClose, + bulkCloseIndex + ); + } + }, + [] + ); useEffect(() => { let isSubscribed = true; @@ -114,6 +126,7 @@ export const useAddOrUpdateException = ({ await updateAlertStatus({ query: getUpdateAlertsQuery([alertIdToClose]), status: 'closed', + signal: abortCtrl.signal, }); } @@ -131,6 +144,7 @@ export const useAddOrUpdateException = ({ query: filter, }, status: 'closed', + signal: abortCtrl.signal, }); } @@ -148,12 +162,12 @@ export const useAddOrUpdateException = ({ } }; - addOrUpdateException.current = addOrUpdateExceptionItems; + addOrUpdateExceptionRef.current = addOrUpdateExceptionItems; return (): void => { isSubscribed = false; abortCtrl.abort(); }; }, [http, onSuccess, onError]); - return [{ isLoading }, addOrUpdateException.current]; + return [{ isLoading }, addOrUpdateException]; }; From 8b4c4c0abc9392f0137716102a23bba9322b8351 Mon Sep 17 00:00:00 2001 From: Sonja Krause-Harder Date: Thu, 23 Jul 2020 08:45:01 +0200 Subject: [PATCH 077/142] Show step number instead of incomplete step. (#72866) --- .../agent_enrollment_flyout/standalone_instructions.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/standalone_instructions.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/standalone_instructions.tsx index d5f79563f33c..bb3b2d1797ca 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/standalone_instructions.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_enrollment_flyout/standalone_instructions.tsx @@ -152,7 +152,6 @@ export const StandaloneInstructions: React.FunctionComponent = ({ agentCo title: i18n.translate('xpack.ingestManager.agentEnrollment.stepCheckForDataTitle', { defaultMessage: 'Check for data', }), - status: 'incomplete', children: ( <> From 119cb10993a9d02d033bec8d8f1e996f56cbba56 Mon Sep 17 00:00:00 2001 From: Jonathan Budzenski Date: Thu, 23 Jul 2020 02:24:22 -0500 Subject: [PATCH 078/142] [keystore] use get_keystore in server cli (#72954) * [keystore] use get_keystore in server cli * temporily add docker build so this can be retested in original env * Revert "temporily add docker build so this can be retested in original env" This reverts commit 25f401aa2e1e669b8098ac67105682ad3f8e1095. --- src/cli/serve/read_keystore.js | 7 +++---- src/cli/serve/read_keystore.test.js | 16 +++++++++++----- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/cli/serve/read_keystore.js b/src/cli/serve/read_keystore.js index 962c708c0d8d..38d0e68bd5c4 100644 --- a/src/cli/serve/read_keystore.js +++ b/src/cli/serve/read_keystore.js @@ -17,14 +17,13 @@ * under the License. */ -import path from 'path'; import { set } from '@elastic/safer-lodash-set'; import { Keystore } from '../../legacy/server/keystore'; -import { getDataPath } from '../../core/server/path'; +import { getKeystore } from '../../cli_keystore/get_keystore'; -export function readKeystore(dataPath = getDataPath()) { - const keystore = new Keystore(path.join(dataPath, 'kibana.keystore')); +export function readKeystore(keystorePath = getKeystore()) { + const keystore = new Keystore(keystorePath); keystore.load(); const keys = Object.keys(keystore.data); diff --git a/src/cli/serve/read_keystore.test.js b/src/cli/serve/read_keystore.test.js index b77e51fc3033..e5407b257a90 100644 --- a/src/cli/serve/read_keystore.test.js +++ b/src/cli/serve/read_keystore.test.js @@ -40,11 +40,17 @@ describe('cli/serve/read_keystore', () => { }); }); - it('uses data path provided', () => { - const keystoreDir = '/foo/'; - const keystorePath = path.join(keystoreDir, 'kibana.keystore'); + it('uses data path if provided', () => { + const keystorePath = path.join('/foo/', 'kibana.keystore'); - readKeystore(keystoreDir); - expect(Keystore.mock.calls[0][0]).toEqual(keystorePath); + readKeystore(keystorePath); + expect(Keystore.mock.calls[0][0]).toContain(keystorePath); + }); + + it('uses the getKeystore path if not', () => { + readKeystore(); + // we test exact path scenarios in get_keystore.test.js - we use both + // deprecated and new to cover any older local environments + expect(Keystore.mock.calls[0][0]).toMatch(/data|config/); }); }); From 5cdd0801b241020869db61659078ffb56437ff2a Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Thu, 23 Jul 2020 09:35:36 +0200 Subject: [PATCH 079/142] fix bug (#72809) --- .../editor_frame/editor_frame.test.tsx | 3 +- .../workspace_panel/chart_switch.test.tsx | 28 ++++++++++++++++ .../workspace_panel/chart_switch.tsx | 6 +++- .../xy_visualization/xy_suggestions.test.ts | 33 +++++++++++++++++++ .../public/xy_visualization/xy_suggestions.ts | 6 +--- 5 files changed, 69 insertions(+), 7 deletions(-) diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx index ad4f6e74c9e9..2f7a78197b2b 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx @@ -1007,7 +1007,8 @@ describe('editor_frame', () => { expect(mockVisualization2.initialize).toHaveBeenCalledWith( expect.objectContaining({ datasourceLayers: expect.objectContaining({ first: mockDatasource.publicAPIMock }), - }) + }), + undefined ); expect(mockVisualization2.getConfiguration).toHaveBeenCalledWith( expect.objectContaining({ state: { initial: true } }) diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.test.tsx index ceced2a7a353..c78de9d140f7 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.test.tsx @@ -480,6 +480,34 @@ describe('chart_switch', () => { expect(frame.removeLayers).not.toHaveBeenCalled(); }); + it('should not remove layers and initialize with existing state when switching between subtypes without data', () => { + const dispatch = jest.fn(); + const frame = mockFrame(['a']); + frame.datasourceLayers.a.getTableSpec = jest.fn().mockReturnValue([]); + const visualizations = mockVisualizations(); + visualizations.visC.getSuggestions = jest.fn().mockReturnValue([]); + visualizations.visC.switchVisualizationType = jest.fn(() => 'switched'); + + const component = mount( + + ); + + switchTo('subvisC3', component); + + expect(visualizations.visC.switchVisualizationType).toHaveBeenCalledWith('subvisC3', { + type: 'subvisC1', + }); + expect(frame.removeLayers).not.toHaveBeenCalled(); + }); + it('should switch to the updated datasource state', () => { const dispatch = jest.fn(); const visualizations = mockVisualizations(); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx index 51b4a347af6f..a0d803d05d98 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/chart_switch.tsx @@ -160,12 +160,16 @@ export function ChartSwitch(props: Props) { : () => { return switchVisType( subVisualizationId, - newVisualization.initialize(props.framePublicAPI) + newVisualization.initialize( + props.framePublicAPI, + props.visualizationId === newVisualization.id ? props.visualizationState : undefined + ) ); }, keptLayerIds: topSuggestion ? topSuggestion.keptLayerIds : [], datasourceState: topSuggestion ? topSuggestion.datasourceState : undefined, datasourceId: topSuggestion ? topSuggestion.datasourceId : undefined, + sameDatasources: dataLoss === 'nothing' && props.visualizationId === newVisualization.id, }; } diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.test.ts b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.test.ts index f5828dbaeccc..7b3398658a50 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.test.ts +++ b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.test.ts @@ -408,6 +408,39 @@ describe('xy_suggestions', () => { expect(suggestion.hide).toBeTruthy(); }); + test('keeps existing seriesType for initial tables', () => { + const currentState: XYState = { + legend: { isVisible: true, position: 'bottom' }, + fittingFunction: 'None', + preferredSeriesType: 'line', + layers: [ + { + accessors: [], + layerId: 'first', + seriesType: 'line', + splitAccessor: undefined, + xAccessor: '', + }, + ], + }; + const suggestions = getSuggestions({ + table: { + isMultiRow: true, + columns: [numCol('price'), dateCol('date')], + layerId: 'first', + changeType: 'initial', + }, + state: currentState, + keptLayerIds: ['first'], + }); + + expect(suggestions).toHaveLength(1); + + expect(suggestions[0].hide).toEqual(false); + expect(suggestions[0].state.preferredSeriesType).toEqual('line'); + expect(suggestions[0].state.layers[0].seriesType).toEqual('line'); + }); + test('makes a visible seriesType suggestion for unchanged table without split', () => { const currentState: XYState = { legend: { isVisible: true, position: 'bottom' }, diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts index d7348f00bf8b..1be8d566a8b6 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts +++ b/x-pack/plugins/lens/public/xy_visualization/xy_suggestions.ts @@ -318,11 +318,7 @@ function getSeriesType( return closestSeriesType.startsWith('bar') ? closestSeriesType : defaultType; } - if (changeType === 'initial') { - return defaultType; - } - - return closestSeriesType !== defaultType ? closestSeriesType : defaultType; + return closestSeriesType; } function getSuggestionTitle( From 4b359f4420d7d96cacc23d92cc86b6d97d77816e Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Thu, 23 Jul 2020 00:56:45 -0700 Subject: [PATCH 080/142] =?UTF-8?q?test:=20=F0=9F=92=8D=20add=20test=20for?= =?UTF-8?q?=20sub-expression=20variables=20(#71644)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Elastic Machine --- .../common/execution/execution.test.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/plugins/expressions/common/execution/execution.test.ts b/src/plugins/expressions/common/execution/execution.test.ts index 4e73d27c1c4a..2b8aa4b5e68f 100644 --- a/src/plugins/expressions/common/execution/execution.test.ts +++ b/src/plugins/expressions/common/execution/execution.test.ts @@ -376,6 +376,38 @@ describe('Execution', () => { value: 5, }); }); + + test('can use global variables', async () => { + const result = await run( + 'add val={var foo}', + { + variables: { + foo: 3, + }, + }, + null + ); + + expect(result).toMatchObject({ + type: 'num', + value: 3, + }); + }); + + test('can modify global variables', async () => { + const result = await run( + 'add val={var_set name=foo value=66 | var bar} | var foo', + { + variables: { + foo: 3, + bar: 25, + }, + }, + null + ); + + expect(result).toBe(66); + }); }); describe('when arguments are missing', () => { From d4a362018aa3593d5b24256d6fdae96becd9acec Mon Sep 17 00:00:00 2001 From: James Gowdy Date: Thu, 23 Jul 2020 09:23:19 +0100 Subject: [PATCH 081/142] [ML] Fixing link to index management from file data visualizer (#72863) --- .../file_based/components/results_links/results_links.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/results_links/results_links.tsx b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/results_links/results_links.tsx index 088c2a0cad7e..efade08720cc 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/results_links/results_links.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/results_links/results_links.tsx @@ -139,7 +139,7 @@ export const ResultsLinks: FC = ({ /> } description="" - href={`${basePath.get()}/app/management/data/index_management/indices/filter/${index}`} + href={`${basePath.get()}/app/management/data/index_management/indices`} /> From 6befacfc19a03f1bcb802e14253ddaee2b8821f6 Mon Sep 17 00:00:00 2001 From: Marco Liberati Date: Thu, 23 Jul 2020 10:40:21 +0200 Subject: [PATCH 082/142] Fix Firefox TSVB flaky test with switch index patterns (#72882) --- test/functional/apps/visualize/_tsvb_chart.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/test/functional/apps/visualize/_tsvb_chart.ts b/test/functional/apps/visualize/_tsvb_chart.ts index 5e8d2ef5653f..7e1f88650cbb 100644 --- a/test/functional/apps/visualize/_tsvb_chart.ts +++ b/test/functional/apps/visualize/_tsvb_chart.ts @@ -25,11 +25,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const log = getService('log'); const inspector = getService('inspector'); + const retry = getService('retry'); const security = getService('security'); const PageObjects = getPageObjects(['visualize', 'visualBuilder', 'timePicker', 'visChart']); - // FLAKY: https://github.com/elastic/kibana/issues/71979 - describe.skip('visual builder', function describeIndexTests() { + describe('visual builder', function describeIndexTests() { this.tags('includeFirefox'); beforeEach(async () => { await security.testUser.setRoles([ @@ -129,9 +129,12 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.visualBuilder.clickPanelOptions('metric'); const fromTime = 'Oct 22, 2018 @ 00:00:00.000'; const toTime = 'Oct 28, 2018 @ 23:59:59.999'; - await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); - await PageObjects.visualBuilder.setIndexPatternValue('kibana_sample_data_flights'); - await PageObjects.visualBuilder.selectIndexPatternTimeField('timestamp'); + // Sometimes popovers take some time to appear in Firefox (#71979) + await retry.try(async () => { + await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); + await PageObjects.visualBuilder.setIndexPatternValue('kibana_sample_data_flights'); + await PageObjects.visualBuilder.selectIndexPatternTimeField('timestamp'); + }); const newValue = await PageObjects.visualBuilder.getMetricValue(); expect(newValue).to.eql('10'); }); From b2a473d6fe3bc703709d2752941f79249be94546 Mon Sep 17 00:00:00 2001 From: Alexey Antonov Date: Thu, 23 Jul 2020 12:38:21 +0300 Subject: [PATCH 083/142] Failing test: Jest Tests.src/plugins/vis_type_vega/public (#71834) * Failing test: Jest Tests.src/plugins/vis_type_vega/public * remove workaround for vega height bug Related to #31461 Co-authored-by: Elastic Machine --- .../vega_visualization.test.js.snap | 4 +- .../public/vega_view/vega_base_view.js | 10 +-- .../public/vega_visualization.test.js | 66 ++++--------------- 3 files changed, 16 insertions(+), 64 deletions(-) diff --git a/src/plugins/vis_type_vega/public/__snapshots__/vega_visualization.test.js.snap b/src/plugins/vis_type_vega/public/__snapshots__/vega_visualization.test.js.snap index 650d9c1b430f..001382d946df 100644 --- a/src/plugins/vis_type_vega/public/__snapshots__/vega_visualization.test.js.snap +++ b/src/plugins/vis_type_vega/public/__snapshots__/vega_visualization.test.js.snap @@ -4,6 +4,6 @@ exports[`VegaVisualizations VegaVisualization - basics should show vega blank re exports[`VegaVisualizations VegaVisualization - basics should show vega graph (may fail in dev env) 1`] = `"
"`; -exports[`VegaVisualizations VegaVisualization - basics should show vegalite graph and update on resize (may fail in dev env) 1`] = `"
"`; +exports[`VegaVisualizations VegaVisualization - basics should show vegalite graph and update on resize (may fail in dev env) 1`] = `"
"`; -exports[`VegaVisualizations VegaVisualization - basics should show vegalite graph and update on resize (may fail in dev env) 2`] = `"
"`; +exports[`VegaVisualizations VegaVisualization - basics should show vegalite graph and update on resize (may fail in dev env) 2`] = `"
"`; diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_base_view.js b/src/plugins/vis_type_vega/public/vega_view/vega_base_view.js index 8f88d5c5b205..4596b4736449 100644 --- a/src/plugins/vis_type_vega/public/vega_view/vega_base_view.js +++ b/src/plugins/vis_type_vega/public/vega_view/vega_base_view.js @@ -195,13 +195,9 @@ export class VegaBaseView { const width = Math.max(0, this._$container.width() - this._parser.paddingWidth); const height = Math.max(0, this._$container.height() - this._parser.paddingHeight) - heightExtraPadding; - // Somehow the `height` signal in vega becomes zero if the height is set exactly to - // an even number. This is a dirty workaround for this. - // when vega itself is updated again, it should be checked whether this is still - // necessary. - const adjustedHeight = height + 0.00000001; - if (view.width() !== width || view.height() !== adjustedHeight) { - view.width(width).height(adjustedHeight); + + if (view.width() !== width || view.height() !== height) { + view.width(width).height(height); return true; } return false; diff --git a/src/plugins/vis_type_vega/public/vega_visualization.test.js b/src/plugins/vis_type_vega/public/vega_visualization.test.js index 108b34b36c66..3e318fa22c19 100644 --- a/src/plugins/vis_type_vega/public/vega_visualization.test.js +++ b/src/plugins/vis_type_vega/public/vega_visualization.test.js @@ -53,7 +53,7 @@ jest.mock('./lib/vega', () => ({ })); // FLAKY: https://github.com/elastic/kibana/issues/71713 -describe.skip('VegaVisualizations', () => { +describe('VegaVisualizations', () => { let domNode; let VegaVisualization; let vis; @@ -77,17 +77,17 @@ describe.skip('VegaVisualizations', () => { mockHeight = jest.spyOn($.prototype, 'height').mockImplementation(() => mockedHeightValue); }; - setKibanaMapFactory((...args) => new KibanaMap(...args)); - setInjectedVars({ - emsTileLayerId: {}, - enableExternalUrls: true, - esShardTimeout: 10000, - }); - setData(dataPluginStart); - setSavedObjects(coreStart.savedObjects); - setNotifications(coreStart.notifications); - beforeEach(() => { + setKibanaMapFactory((...args) => new KibanaMap(...args)); + setInjectedVars({ + emsTileLayerId: {}, + enableExternalUrls: true, + esShardTimeout: 10000, + }); + setData(dataPluginStart); + setSavedObjects(coreStart.savedObjects); + setNotifications(coreStart.notifications); + vegaVisualizationDependencies = { core: coreMock.createSetup(), plugins: { @@ -185,49 +185,5 @@ describe.skip('VegaVisualizations', () => { vegaVis.destroy(); } }); - - test('should add a small subpixel value to the height of the canvas to avoid getting it set to 0', async () => { - let vegaVis; - try { - vegaVis = new VegaVisualization(domNode, vis); - const vegaParser = new VegaParser( - `{ - "$schema": "https://vega.github.io/schema/vega/v5.json", - "marks": [ - { - "type": "text", - "encode": { - "update": { - "text": { - "value": "Test" - }, - "align": {"value": "center"}, - "baseline": {"value": "middle"}, - "xc": {"signal": "width/2"}, - "yc": {"signal": "height/2"} - fontSize: {value: "14"} - } - } - } - ] - }`, - new SearchAPI({ - search: dataPluginStart.search, - uiSettings: coreStart.uiSettings, - injectedMetadata: coreStart.injectedMetadata, - }) - ); - await vegaParser.parseAsync(); - - mockedWidthValue = 256; - mockedHeightValue = 256; - - await vegaVis.render(vegaParser); - const vegaView = vegaVis._vegaView._view; - expect(vegaView.height()).toBe(250.00000001); - } finally { - vegaVis.destroy(); - } - }); }); }); From 2178a14519796f2a6f9925a85422480c9ea65473 Mon Sep 17 00:00:00 2001 From: Pierre Gayvallet Date: Thu, 23 Jul 2020 12:15:03 +0200 Subject: [PATCH 084/142] Migrate status page app to core (#72017) * move http.anonymousPaths.register('/status'); logic into core, remove status_page plugin * move status_page to core & migrate lib * migrate the status_app components to TS/KP APIs * update rendering snapshots * use import type syntax * moves `/status` server-side route to core * fix route registration * update generated file * change statusPage i18n prefix * (temporary) try to restore legacy plugin to check behavior * add some FTR tests * do not import whole lodash * update snapshots * review comments * improve / clean component unit tests * change url for legacy status app * set status app as chromeless * fix FTR test due to chromeless app * fix typings * add unit test for CoreApp /status registration --- .../architecture/code-exploration.asciidoc | 5 - src/core/public/core_app/core_app.ts | 32 +++- .../__snapshots__/metric_tiles.test.tsx.snap | 37 ++++ .../__snapshots__/server_status.test.tsx.snap | 67 +++++++ .../__snapshots__/status_table.test.tsx.snap} | 3 +- .../core_app/status/components}/index.ts | 8 +- .../status/components/metric_tiles.test.tsx} | 43 ++--- .../status/components/metric_tiles.tsx} | 60 +++---- .../status/components/server_status.test.tsx | 58 +++++++ .../status/components/server_status.tsx} | 33 ++-- .../status/components/status_table.test.tsx} | 21 ++- .../status/components/status_table.tsx | 67 +++++++ .../public/core_app/status/index.ts} | 15 +- .../status/lib/format_number.test.ts} | 38 +++- .../core_app/status/lib/format_number.ts} | 16 +- src/core/public/core_app/status/lib/index.ts | 21 +++ .../core_app/status/lib/load_status.test.ts | 152 ++++++++++++++++ .../public/core_app/status/lib/load_status.ts | 153 ++++++++++++++++ .../public/core_app/status/render_app.tsx | 44 +++++ .../public/core_app/status/status_app.tsx} | 71 ++++---- src/core/public/core_system.ts | 4 +- .../injected_metadata_service.mock.ts | 2 + .../injected_metadata_service.ts | 6 + src/core/server/core_app/core_app.test.ts | 71 ++++++++ src/core/server/core_app/core_app.ts | 18 ++ .../http_resources_service.mock.ts | 2 +- src/core/server/mocks.ts | 5 +- src/core/server/rendering/__mocks__/params.ts | 3 + .../rendering_service.test.ts.snap | 10 ++ .../server/rendering/rendering_service.tsx | 2 + src/core/server/rendering/types.ts | 3 + src/core/server/server.ts | 67 +++---- src/core/server/status/index.ts | 1 + .../server/status/status_config.ts} | 30 ++-- src/core/server/status/status_service.mock.ts | 1 + src/core/server/status/status_service.test.ts | 22 ++- src/core/server/status/status_service.ts | 9 +- src/core/server/status/types.ts | 1 + .../public/plugin.ts => core/types/status.ts} | 43 +++-- src/legacy/core_plugins/status_page/index.js | 9 +- .../__snapshots__/metric_tiles.test.js.snap | 33 ---- .../__snapshots__/server_status.test.js.snap | 44 ----- .../status_page/public/components/render.js | 13 +- .../public/components/status_table.js | 82 --------- .../status_page/public/lib/load_status.js | 163 ------------------ .../public/lib/load_status.test.js | 115 ------------ .../status_page/public/lib/prop_types.js | 33 ---- src/legacy/server/status/index.js | 3 +- src/legacy/server/status/routes/index.js | 1 - .../status/routes/page/register_status.js | 53 ------ src/legacy/ui/ui_render/ui_render_mixin.js | 7 +- src/plugins/status_page/kibana.json | 6 - .../apps/status_page/{index.js => index.ts} | 26 ++- .../translations/translations/ja-JP.json | 30 ++-- .../translations/translations/zh-CN.json | 30 ++-- .../functional/page_objects/status_page.js | 2 +- 56 files changed, 1064 insertions(+), 830 deletions(-) create mode 100644 src/core/public/core_app/status/components/__snapshots__/metric_tiles.test.tsx.snap create mode 100644 src/core/public/core_app/status/components/__snapshots__/server_status.test.tsx.snap rename src/{legacy/core_plugins/status_page/public/components/__snapshots__/status_table.test.js.snap => core/public/core_app/status/components/__snapshots__/status_table.test.tsx.snap} (89%) rename src/{plugins/status_page/public => core/public/core_app/status/components}/index.ts (75%) rename src/{legacy/core_plugins/status_page/public/components/metric_tiles.test.js => core/public/core_app/status/components/metric_tiles.test.tsx} (58%) rename src/{legacy/core_plugins/status_page/public/components/metric_tiles.js => core/public/core_app/status/components/metric_tiles.tsx} (50%) create mode 100644 src/core/public/core_app/status/components/server_status.test.tsx rename src/{legacy/core_plugins/status_page/public/components/server_status.js => core/public/core_app/status/components/server_status.tsx} (66%) rename src/{legacy/core_plugins/status_page/public/components/status_table.test.js => core/public/core_app/status/components/status_table.test.tsx} (67%) create mode 100644 src/core/public/core_app/status/components/status_table.tsx rename src/{legacy/core_plugins/status_page/public/components/server_status.test.js => core/public/core_app/status/index.ts} (69%) rename src/{legacy/core_plugins/status_page/public/lib/format_number.test.js => core/public/core_app/status/lib/format_number.test.ts} (61%) rename src/{legacy/core_plugins/status_page/public/lib/format_number.js => core/public/core_app/status/lib/format_number.ts} (78%) create mode 100644 src/core/public/core_app/status/lib/index.ts create mode 100644 src/core/public/core_app/status/lib/load_status.test.ts create mode 100644 src/core/public/core_app/status/lib/load_status.ts create mode 100644 src/core/public/core_app/status/render_app.tsx rename src/{legacy/core_plugins/status_page/public/components/status_app.js => core/public/core_app/status/status_app.tsx} (67%) create mode 100644 src/core/server/core_app/core_app.test.ts rename src/{legacy/core_plugins/status_page/public/lib/load_status.test.mocks.js => core/server/status/status_config.ts} (64%) rename src/{plugins/status_page/public/plugin.ts => core/types/status.ts} (56%) delete mode 100644 src/legacy/core_plugins/status_page/public/components/__snapshots__/metric_tiles.test.js.snap delete mode 100644 src/legacy/core_plugins/status_page/public/components/__snapshots__/server_status.test.js.snap delete mode 100644 src/legacy/core_plugins/status_page/public/components/status_table.js delete mode 100644 src/legacy/core_plugins/status_page/public/lib/load_status.js delete mode 100644 src/legacy/core_plugins/status_page/public/lib/load_status.test.js delete mode 100644 src/legacy/core_plugins/status_page/public/lib/prop_types.js delete mode 100644 src/legacy/server/status/routes/page/register_status.js delete mode 100644 src/plugins/status_page/kibana.json rename test/functional/apps/status_page/{index.js => index.ts} (56%) diff --git a/docs/developer/architecture/code-exploration.asciidoc b/docs/developer/architecture/code-exploration.asciidoc index 2f67ae002c91..f18a6c2f1492 100644 --- a/docs/developer/architecture/code-exploration.asciidoc +++ b/docs/developer/architecture/code-exploration.asciidoc @@ -186,11 +186,6 @@ WARNING: Missing README. Replaces the legacy ui/share module for registering share context menus. -- {kib-repo}blob/{branch}/src/plugins/status_page[statusPage] - -WARNING: Missing README. - - - {kib-repo}blob/{branch}/src/plugins/telemetry/README.md[telemetry] Telemetry allows Kibana features to have usage tracked in the wild. The general term "telemetry" refers to multiple things: diff --git a/src/core/public/core_app/core_app.ts b/src/core/public/core_app/core_app.ts index 04d58b7c3c65..ef6ea0a0e105 100644 --- a/src/core/public/core_app/core_app.ts +++ b/src/core/public/core_app/core_app.ts @@ -24,15 +24,19 @@ import { AppNavLinkStatus, AppMountParameters, } from '../application'; -import { HttpSetup, HttpStart } from '../http'; -import { CoreContext } from '../core_system'; -import { renderApp, setupUrlOverflowDetection } from './errors'; -import { NotificationsStart } from '../notifications'; -import { IUiSettingsClient } from '../ui_settings'; +import type { HttpSetup, HttpStart } from '../http'; +import type { CoreContext } from '../core_system'; +import type { NotificationsSetup, NotificationsStart } from '../notifications'; +import type { IUiSettingsClient } from '../ui_settings'; +import type { InjectedMetadataSetup } from '../injected_metadata'; +import { renderApp as renderErrorApp, setupUrlOverflowDetection } from './errors'; +import { renderApp as renderStatusApp } from './status'; interface SetupDeps { application: InternalApplicationSetup; http: HttpSetup; + injectedMetadata: InjectedMetadataSetup; + notifications: NotificationsSetup; } interface StartDeps { @@ -47,7 +51,7 @@ export class CoreApp { constructor(private readonly coreContext: CoreContext) {} - public setup({ http, application }: SetupDeps) { + public setup({ http, application, injectedMetadata, notifications }: SetupDeps) { application.register(this.coreContext.coreId, { id: 'error', title: 'App Error', @@ -56,7 +60,21 @@ export class CoreApp { // Do not use an async import here in order to ensure that network failures // cannot prevent the error UI from displaying. This UI is tiny so an async // import here is probably not useful anyways. - return renderApp(params, { basePath: http.basePath }); + return renderErrorApp(params, { basePath: http.basePath }); + }, + }); + + if (injectedMetadata.getAnonymousStatusPage()) { + http.anonymousPaths.register('/status'); + } + application.register(this.coreContext.coreId, { + id: 'status', + title: 'Server Status', + appRoute: '/status', + chromeless: true, + navLinkStatus: AppNavLinkStatus.hidden, + mount(params: AppMountParameters) { + return renderStatusApp(params, { http, notifications }); }, }); } diff --git a/src/core/public/core_app/status/components/__snapshots__/metric_tiles.test.tsx.snap b/src/core/public/core_app/status/components/__snapshots__/metric_tiles.test.tsx.snap new file mode 100644 index 000000000000..2219e0d7609b --- /dev/null +++ b/src/core/public/core_app/status/components/__snapshots__/metric_tiles.test.tsx.snap @@ -0,0 +1,37 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MetricTile correct displays a byte metric 1`] = ` + +`; + +exports[`MetricTile correct displays a float metric 1`] = ` + +`; + +exports[`MetricTile correct displays a time metric 1`] = ` + +`; + +exports[`MetricTile correct displays an untyped metric 1`] = ` + +`; diff --git a/src/core/public/core_app/status/components/__snapshots__/server_status.test.tsx.snap b/src/core/public/core_app/status/components/__snapshots__/server_status.test.tsx.snap new file mode 100644 index 000000000000..0ed784ef680f --- /dev/null +++ b/src/core/public/core_app/status/components/__snapshots__/server_status.test.tsx.snap @@ -0,0 +1,67 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ServerStatus renders correctly for green state 2`] = ` + + + + + + Green + + + + + +`; + +exports[`ServerStatus renders correctly for red state 2`] = ` + + + + + + Red + + + + + +`; diff --git a/src/legacy/core_plugins/status_page/public/components/__snapshots__/status_table.test.js.snap b/src/core/public/core_app/status/components/__snapshots__/status_table.test.tsx.snap similarity index 89% rename from src/legacy/core_plugins/status_page/public/components/__snapshots__/status_table.test.js.snap rename to src/core/public/core_app/status/components/__snapshots__/status_table.test.tsx.snap index 3379d6cd649c..f5d3b837ce71 100644 --- a/src/legacy/core_plugins/status_page/public/components/__snapshots__/status_table.test.js.snap +++ b/src/core/public/core_app/status/components/__snapshots__/status_table.test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`render 1`] = ` +exports[`StatusTable renders when statuses is provided 1`] = ` = () => - new StatusPagePlugin(); +export { MetricTile, MetricTiles } from './metric_tiles'; +export { ServerStatus } from './server_status'; +export { StatusTable } from './status_table'; diff --git a/src/legacy/core_plugins/status_page/public/components/metric_tiles.test.js b/src/core/public/core_app/status/components/metric_tiles.test.tsx similarity index 58% rename from src/legacy/core_plugins/status_page/public/components/metric_tiles.test.js rename to src/core/public/core_app/status/components/metric_tiles.test.tsx index 13d0a61bbc96..b22c5a494afe 100644 --- a/src/legacy/core_plugins/status_page/public/components/metric_tiles.test.js +++ b/src/core/public/core_app/status/components/metric_tiles.test.tsx @@ -20,47 +20,50 @@ import React from 'react'; import { shallow } from 'enzyme'; import { MetricTile } from './metric_tiles'; +import { Metric } from '../lib'; -const GENERAL_METRIC = { +const untypedMetric: Metric = { name: 'A metric', value: 1.8, // no type specified }; -const BYTE_METRIC = { +const byteMetric: Metric = { name: 'Heap Total', value: 1501560832, type: 'byte', }; -const FLOAT_METRIC = { +const floatMetric: Metric = { name: 'Load', type: 'float', value: [4.0537109375, 3.36669921875, 3.1220703125], }; -const MS_METRIC = { +const timeMetric: Metric = { name: 'Response Time Max', - type: 'ms', + type: 'time', value: 1234, }; -test('general metric', () => { - const component = shallow(); - expect(component).toMatchSnapshot(); -}); +describe('MetricTile', () => { + it('correct displays an untyped metric', () => { + const component = shallow(); + expect(component).toMatchSnapshot(); + }); -test('byte metric', () => { - const component = shallow(); - expect(component).toMatchSnapshot(); -}); + it('correct displays a byte metric', () => { + const component = shallow(); + expect(component).toMatchSnapshot(); + }); -test('float metric', () => { - const component = shallow(); - expect(component).toMatchSnapshot(); -}); + it('correct displays a float metric', () => { + const component = shallow(); + expect(component).toMatchSnapshot(); + }); -test('millisecond metric', () => { - const component = shallow(); - expect(component).toMatchSnapshot(); + it('correct displays a time metric', () => { + const component = shallow(); + expect(component).toMatchSnapshot(); + }); }); diff --git a/src/legacy/core_plugins/status_page/public/components/metric_tiles.js b/src/core/public/core_app/status/components/metric_tiles.tsx similarity index 50% rename from src/legacy/core_plugins/status_page/public/components/metric_tiles.js rename to src/core/public/core_app/status/components/metric_tiles.tsx index 6cde975875ad..4b1b5fcbc633 100644 --- a/src/legacy/core_plugins/status_page/public/components/metric_tiles.js +++ b/src/core/public/core_app/status/components/metric_tiles.tsx @@ -17,53 +17,43 @@ * under the License. */ -import formatNumber from '../lib/format_number'; -import React, { Component } from 'react'; -import { Metric as MetricPropType } from '../lib/prop_types'; -import PropTypes from 'prop-types'; +import React, { FunctionComponent } from 'react'; import { EuiFlexGrid, EuiFlexItem, EuiCard } from '@elastic/eui'; +import { formatNumber, Metric } from '../lib'; /* -Displays a metric with the correct format. -*/ -export class MetricTile extends Component { - static propTypes = { - metric: MetricPropType.isRequired, - }; - - formattedMetric() { - const { value, type } = this.props.metric; - - const metrics = [].concat(value); - return metrics - .map(function (metric) { - return formatNumber(metric, type); - }) - .join(', '); - } - - render() { - const { name } = this.props.metric; - - return ; - } -} + * Displays a metric with the correct format. + */ +export const MetricTile: FunctionComponent<{ metric: Metric }> = ({ metric }) => { + const { name } = metric; + return ( + + ); +}; /* -Wrapper component that simply maps each metric to MetricTile inside a FlexGroup -*/ -const MetricTiles = ({ metrics }) => ( + * Wrapper component that simply maps each metric to MetricTile inside a FlexGroup + */ +export const MetricTiles: FunctionComponent<{ metrics: Metric[] }> = ({ metrics }) => ( {metrics.map((metric) => ( - + ))} ); -MetricTiles.propTypes = { - metrics: PropTypes.arrayOf(MetricPropType).isRequired, +const formatMetric = ({ value, type }: Metric) => { + const metrics = Array.isArray(value) ? value : [value]; + return metrics.map((metric) => formatNumber(metric, type)).join(', '); }; -export default MetricTiles; +const formatMetricId = ({ name }: Metric) => { + return name.toLowerCase().replace(/[ ]+/g, '-'); +}; diff --git a/src/core/public/core_app/status/components/server_status.test.tsx b/src/core/public/core_app/status/components/server_status.test.tsx new file mode 100644 index 000000000000..a37697b6ab4e --- /dev/null +++ b/src/core/public/core_app/status/components/server_status.test.tsx @@ -0,0 +1,58 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { mount } from 'enzyme'; +import { ServerStatus } from './server_status'; +import { FormattedStatus } from '../lib'; + +const getStatus = (parts: Partial = {}): FormattedStatus['state'] => ({ + id: 'green', + title: 'Green', + uiColor: 'secondary', + message: '', + ...parts, +}); + +describe('ServerStatus', () => { + it('renders correctly for green state', () => { + const status = getStatus(); + const component = mount(); + expect(component.find('EuiTitle').text()).toMatchInlineSnapshot(`"Kibana status is Green"`); + expect(component.find('EuiBadge')).toMatchSnapshot(); + }); + + it('renders correctly for red state', () => { + const status = getStatus({ + id: 'red', + title: 'Red', + }); + const component = mount(); + expect(component.find('EuiTitle').text()).toMatchInlineSnapshot(`"Kibana status is Red"`); + expect(component.find('EuiBadge')).toMatchSnapshot(); + }); + + it('displays the correct `name`', () => { + let component = mount(); + expect(component.find('EuiText').text()).toMatchInlineSnapshot(`"Localhost"`); + + component = mount(); + expect(component.find('EuiText').text()).toMatchInlineSnapshot(`"Kibana"`); + }); +}); diff --git a/src/legacy/core_plugins/status_page/public/components/server_status.js b/src/core/public/core_app/status/components/server_status.tsx similarity index 66% rename from src/legacy/core_plugins/status_page/public/components/server_status.js rename to src/core/public/core_app/status/components/server_status.tsx index 0c32109b9464..5baa97cfabed 100644 --- a/src/legacy/core_plugins/status_page/public/components/server_status.js +++ b/src/core/public/core_app/status/components/server_status.tsx @@ -17,22 +17,34 @@ * under the License. */ -import React from 'react'; -import PropTypes from 'prop-types'; -import { State as StatePropType } from '../lib/prop_types'; +import React, { FunctionComponent } from 'react'; import { EuiText, EuiFlexGroup, EuiFlexItem, EuiTitle, EuiBadge } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; +import type { FormattedStatus } from '../lib'; -const ServerState = ({ name, serverState }) => ( +interface ServerStateProps { + name: string; + serverState: FormattedStatus['state']; +} + +export const ServerStatus: FunctionComponent = ({ name, serverState }) => ( -

+

{serverState.title}, + kibanaStatus: ( + + {serverState.title} + + ), }} />

@@ -45,10 +57,3 @@ const ServerState = ({ name, serverState }) => (
); - -ServerState.propTypes = { - name: PropTypes.string.isRequired, - serverState: StatePropType.isRequired, -}; - -export default ServerState; diff --git a/src/legacy/core_plugins/status_page/public/components/status_table.test.js b/src/core/public/core_app/status/components/status_table.test.tsx similarity index 67% rename from src/legacy/core_plugins/status_page/public/components/status_table.test.js rename to src/core/public/core_app/status/components/status_table.test.tsx index 303be0d17c79..4e25d274463e 100644 --- a/src/legacy/core_plugins/status_page/public/components/status_table.test.js +++ b/src/core/public/core_app/status/components/status_table.test.tsx @@ -19,20 +19,23 @@ import React from 'react'; import { shallow } from 'enzyme'; -import StatusTable from './status_table'; +import { StatusTable } from './status_table'; -const STATE = { +const state = { id: 'green', uiColor: 'secondary', message: 'Ready', + title: 'green', }; -test('render', () => { - const component = shallow(); - expect(component).toMatchSnapshot(); // eslint-disable-line -}); +describe('StatusTable', () => { + it('renders when statuses is provided', () => { + const component = shallow(); + expect(component).toMatchSnapshot(); + }); -test('render empty', () => { - const component = shallow(); - expect(component.isEmptyRender()).toBe(true); // eslint-disable-line + it('renders when statuses is not provided', () => { + const component = shallow(); + expect(component.isEmptyRender()).toBe(true); + }); }); diff --git a/src/core/public/core_app/status/components/status_table.tsx b/src/core/public/core_app/status/components/status_table.tsx new file mode 100644 index 000000000000..c1d66cc779cc --- /dev/null +++ b/src/core/public/core_app/status/components/status_table.tsx @@ -0,0 +1,67 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { FunctionComponent } from 'react'; +import { EuiBasicTable, EuiIcon } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import type { FormattedStatus } from '../lib'; + +interface StatusTableProps { + statuses?: FormattedStatus[]; +} + +const tableColumns = [ + { + field: 'state', + name: '', + render: (state: FormattedStatus['state']) => ( + + ), + width: '32px', + }, + { + field: 'id', + name: i18n.translate('core.statusPage.statusTable.columns.idHeader', { + defaultMessage: 'ID', + }), + }, + { + field: 'state', + name: i18n.translate('core.statusPage.statusTable.columns.statusHeader', { + defaultMessage: 'Status', + }), + render: (state: FormattedStatus['state']) => {state.message}, + }, +]; + +export const StatusTable: FunctionComponent = ({ statuses }) => { + if (!statuses) { + return null; + } + return ( + + columns={tableColumns} + items={statuses} + rowProps={({ state }) => ({ + className: `status-table-row-${state.uiColor}`, + })} + data-test-subj="statusBreakdown" + /> + ); +}; diff --git a/src/legacy/core_plugins/status_page/public/components/server_status.test.js b/src/core/public/core_app/status/index.ts similarity index 69% rename from src/legacy/core_plugins/status_page/public/components/server_status.test.js rename to src/core/public/core_app/status/index.ts index 79f217e18ecb..938a037ae60e 100644 --- a/src/legacy/core_plugins/status_page/public/components/server_status.test.js +++ b/src/core/public/core_app/status/index.ts @@ -17,17 +17,4 @@ * under the License. */ -import React from 'react'; -import { shallow } from 'enzyme'; -import ServerStatus from './server_status'; - -const STATE = { - id: 'green', - title: 'Green', - uiColor: 'secondary', -}; - -test('render', () => { - const component = shallow(); - expect(component).toMatchSnapshot(); // eslint-disable-line -}); +export { renderApp } from './render_app'; diff --git a/src/legacy/core_plugins/status_page/public/lib/format_number.test.js b/src/core/public/core_app/status/lib/format_number.test.ts similarity index 61% rename from src/legacy/core_plugins/status_page/public/lib/format_number.test.js rename to src/core/public/core_app/status/lib/format_number.test.ts index f70377dcba24..a3b602d210b7 100644 --- a/src/legacy/core_plugins/status_page/public/lib/format_number.test.js +++ b/src/core/public/core_app/status/lib/format_number.test.ts @@ -17,7 +17,7 @@ * under the License. */ -import formatNumber from './format_number'; +import { formatNumber } from './format_number'; describe('format byte', () => { test('zero', () => { @@ -33,17 +33,17 @@ describe('format byte', () => { }); }); -describe('format ms', () => { +describe('format time', () => { test('zero', () => { - expect(formatNumber(0, 'ms')).toMatchInlineSnapshot(`"0.00 ms"`); + expect(formatNumber(0, 'time')).toMatchInlineSnapshot(`"0.00 ms"`); }); test('sub ms', () => { - expect(formatNumber(0.128, 'ms')).toMatchInlineSnapshot(`"0.13 ms"`); + expect(formatNumber(0.128, 'time')).toMatchInlineSnapshot(`"0.13 ms"`); }); test('many ms', () => { - expect(formatNumber(3030.284, 'ms')).toMatchInlineSnapshot(`"3030.28 ms"`); + expect(formatNumber(3030.284, 'time')).toMatchInlineSnapshot(`"3030.28 ms"`); }); }); @@ -60,3 +60,31 @@ describe('format integer', () => { expect(formatNumber(3030.284, 'integer')).toMatchInlineSnapshot(`"3030"`); }); }); + +describe('format float', () => { + test('zero', () => { + expect(formatNumber(0, 'float')).toMatchInlineSnapshot(`"0.00"`); + }); + + test('sub integer', () => { + expect(formatNumber(0.728, 'float')).toMatchInlineSnapshot(`"0.73"`); + }); + + test('many integer', () => { + expect(formatNumber(3030.284, 'float')).toMatchInlineSnapshot(`"3030.28"`); + }); +}); + +describe('format default', () => { + test('zero', () => { + expect(formatNumber(0)).toMatchInlineSnapshot(`"0.00"`); + }); + + test('sub integer', () => { + expect(formatNumber(0.464)).toMatchInlineSnapshot(`"0.46"`); + }); + + test('many integer', () => { + expect(formatNumber(6237.291)).toMatchInlineSnapshot(`"6237.29"`); + }); +}); diff --git a/src/legacy/core_plugins/status_page/public/lib/format_number.js b/src/core/public/core_app/status/lib/format_number.ts similarity index 78% rename from src/legacy/core_plugins/status_page/public/lib/format_number.js rename to src/core/public/core_app/status/lib/format_number.ts index 4a8be4fc48a1..bfd5a4746b4d 100644 --- a/src/legacy/core_plugins/status_page/public/lib/format_number.js +++ b/src/core/public/core_app/status/lib/format_number.ts @@ -19,19 +19,25 @@ import numeral from '@elastic/numeral'; -export default function formatNumber(num, which) { - let format = '0.00'; +export type DataType = 'byte' | 'float' | 'integer' | 'time'; + +export function formatNumber(num: number, type?: DataType) { + let format: string; let postfix = ''; - switch (which) { + switch (type) { case 'byte': - format += ' b'; + format = '0.00 b'; break; - case 'ms': + case 'time': + format = '0.00'; postfix = ' ms'; break; case 'integer': format = '0'; break; + case 'float': + default: + format = '0.00'; } return numeral(num).format(format) + postfix; diff --git a/src/core/public/core_app/status/lib/index.ts b/src/core/public/core_app/status/lib/index.ts new file mode 100644 index 000000000000..eaa4e2ae4821 --- /dev/null +++ b/src/core/public/core_app/status/lib/index.ts @@ -0,0 +1,21 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { formatNumber, DataType } from './format_number'; +export { loadStatus, Metric, FormattedStatus, ProcessedServerResponse } from './load_status'; diff --git a/src/core/public/core_app/status/lib/load_status.test.ts b/src/core/public/core_app/status/lib/load_status.test.ts new file mode 100644 index 000000000000..3a444a444846 --- /dev/null +++ b/src/core/public/core_app/status/lib/load_status.test.ts @@ -0,0 +1,152 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { StatusResponse } from '../../../../types/status'; +import { httpServiceMock } from '../../../http/http_service.mock'; +import { notificationServiceMock } from '../../../notifications/notifications_service.mock'; +import { loadStatus } from './load_status'; + +const mockedResponse: StatusResponse = { + name: 'My computer', + uuid: 'uuid', + version: { + number: '8.0.0', + build_hash: '9007199254740991', + build_number: '12', + build_snapshot: 'XXXXXXXX', + }, + status: { + overall: { + id: 'overall', + state: 'yellow', + title: 'Yellow', + message: 'yellow', + uiColor: 'secondary', + }, + statuses: [ + { + id: 'plugin:1', + state: 'green', + title: 'Green', + message: 'Ready', + uiColor: 'secondary', + }, + { + id: 'plugin:2', + state: 'yellow', + title: 'Yellow', + message: 'Something is weird', + uiColor: 'warning', + }, + ], + }, + metrics: { + collection_interval_in_millis: 1000, + os: { + platform: 'darwin' as const, + platformRelease: 'test', + memory: { total_in_bytes: 1, free_in_bytes: 1, used_in_bytes: 1 }, + uptime_in_millis: 1, + load: { + '1m': 4.1, + '5m': 2.1, + '15m': 0.1, + }, + }, + process: { + memory: { + heap: { + size_limit: 1000000, + used_in_bytes: 100, + total_in_bytes: 0, + }, + resident_set_size_in_bytes: 1, + }, + event_loop_delay: 1, + pid: 1, + uptime_in_millis: 1, + }, + response_times: { + avg_in_millis: 4000, + max_in_millis: 8000, + }, + requests: { + disconnects: 1, + total: 400, + statusCodes: {}, + }, + concurrent_connections: 1, + }, +}; + +describe('response processing', () => { + let http: ReturnType; + let notifications: ReturnType; + + beforeEach(() => { + http = httpServiceMock.createSetupContract(); + http.get.mockResolvedValue(mockedResponse); + notifications = notificationServiceMock.createSetupContract(); + }); + + test('includes the name', async () => { + const data = await loadStatus({ http, notifications }); + expect(data.name).toEqual('My computer'); + }); + + test('includes the plugin statuses', async () => { + const data = await loadStatus({ http, notifications }); + expect(data.statuses).toEqual([ + { + id: 'plugin:1', + state: { id: 'green', title: 'Green', message: 'Ready', uiColor: 'secondary' }, + }, + { + id: 'plugin:2', + state: { id: 'yellow', title: 'Yellow', message: 'Something is weird', uiColor: 'warning' }, + }, + ]); + }); + + test('includes the serverState', async () => { + const data = await loadStatus({ http, notifications }); + expect(data.serverState).toEqual({ + id: 'yellow', + title: 'Yellow', + message: 'yellow', + uiColor: 'secondary', + }); + }); + + test('builds the metrics', async () => { + const data = await loadStatus({ http, notifications }); + const names = data.metrics.map((m) => m.name); + expect(names).toEqual([ + 'Heap total', + 'Heap used', + 'Load', + 'Response time avg', + 'Response time max', + 'Requests per second', + ]); + + const values = data.metrics.map((m) => m.value); + expect(values).toEqual([1000000, 100, [4.1, 2.1, 0.1], 4000, 8000, 400]); + }); +}); diff --git a/src/core/public/core_app/status/lib/load_status.ts b/src/core/public/core_app/status/lib/load_status.ts new file mode 100644 index 000000000000..95efa0bb87ae --- /dev/null +++ b/src/core/public/core_app/status/lib/load_status.ts @@ -0,0 +1,153 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { i18n } from '@kbn/i18n'; +import type { UnwrapPromise } from '@kbn/utility-types'; +import type { ServerStatus, StatusResponse } from '../../../../types/status'; +import type { HttpSetup } from '../../../http'; +import type { NotificationsSetup } from '../../../notifications'; +import type { DataType } from '../lib'; + +export interface Metric { + name: string; + value: number | number[]; + type?: DataType; +} + +export interface FormattedStatus { + id: string; + state: { + id: string; + title: string; + message: string; + uiColor: string; + }; +} + +/** + * Returns an object of any keys that should be included for metrics. + */ +function formatMetrics({ metrics }: StatusResponse): Metric[] { + if (!metrics) { + return []; + } + + return [ + { + name: i18n.translate('core.statusPage.metricsTiles.columns.heapTotalHeader', { + defaultMessage: 'Heap total', + }), + value: metrics.process.memory.heap.size_limit, + type: 'byte', + }, + { + name: i18n.translate('core.statusPage.metricsTiles.columns.heapUsedHeader', { + defaultMessage: 'Heap used', + }), + value: metrics.process.memory.heap.used_in_bytes, + type: 'byte', + }, + { + name: i18n.translate('core.statusPage.metricsTiles.columns.loadHeader', { + defaultMessage: 'Load', + }), + value: [metrics.os.load['1m'], metrics.os.load['5m'], metrics.os.load['15m']], + type: 'time', + }, + { + name: i18n.translate('core.statusPage.metricsTiles.columns.resTimeAvgHeader', { + defaultMessage: 'Response time avg', + }), + value: metrics.response_times.avg_in_millis, + type: 'time', + }, + { + name: i18n.translate('core.statusPage.metricsTiles.columns.resTimeMaxHeader', { + defaultMessage: 'Response time max', + }), + value: metrics.response_times.max_in_millis, + type: 'time', + }, + { + name: i18n.translate('core.statusPage.metricsTiles.columns.requestsPerSecHeader', { + defaultMessage: 'Requests per second', + }), + value: (metrics.requests.total * 1000) / metrics.collection_interval_in_millis, + type: 'float', + }, + ]; +} + +/** + * Reformat the backend data to make the frontend views simpler. + */ +function formatStatus(status: ServerStatus): FormattedStatus { + return { + id: status.id, + state: { + id: status.state, + title: status.title, + message: status.message, + uiColor: status.uiColor, + }, + }; +} + +/** + * Get the status from the server API and format it for display. + */ +export async function loadStatus({ + http, + notifications, +}: { + http: HttpSetup; + notifications: NotificationsSetup; +}) { + let response: StatusResponse; + + try { + response = await http.get('/api/status'); + } catch (e) { + if ((e.response?.status ?? 0) >= 400) { + notifications.toasts.addDanger( + i18n.translate('core.statusPage.loadStatus.serverStatusCodeErrorMessage', { + defaultMessage: 'Failed to request server status with status code {responseStatus}', + values: { responseStatus: e.response?.status }, + }) + ); + } else { + notifications.toasts.addDanger( + i18n.translate('core.statusPage.loadStatus.serverIsDownErrorMessage', { + defaultMessage: 'Failed to request server status. Perhaps your server is down?', + }) + ); + } + throw e; + } + + return { + name: response.name, + version: response.version, + statuses: response.status.statuses.map(formatStatus), + serverState: formatStatus(response.status.overall).state, + metrics: formatMetrics(response), + }; +} + +export type ProcessedServerResponse = UnwrapPromise>; diff --git a/src/core/public/core_app/status/render_app.tsx b/src/core/public/core_app/status/render_app.tsx new file mode 100644 index 000000000000..fdec85942b96 --- /dev/null +++ b/src/core/public/core_app/status/render_app.tsx @@ -0,0 +1,44 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import { I18nProvider } from '@kbn/i18n/react'; +import type { AppMountParameters } from '../../application'; +import type { HttpSetup } from '../../http'; +import type { NotificationsSetup } from '../../notifications'; +import { StatusApp } from './status_app'; + +interface Deps { + http: HttpSetup; + notifications: NotificationsSetup; +} + +export const renderApp = ({ element }: AppMountParameters, { http, notifications }: Deps) => { + ReactDOM.render( + + + , + element + ); + + return () => { + ReactDOM.unmountComponentAtNode(element); + }; +}; diff --git a/src/legacy/core_plugins/status_page/public/components/status_app.js b/src/core/public/core_app/status/status_app.tsx similarity index 67% rename from src/legacy/core_plugins/status_page/public/components/status_app.js rename to src/core/public/core_app/status/status_app.tsx index a6b0321e53a8..5ead0d39d72c 100644 --- a/src/legacy/core_plugins/status_page/public/components/status_app.js +++ b/src/core/public/core_app/status/status_app.tsx @@ -17,10 +17,7 @@ * under the License. */ -import loadStatus from '../lib/load_status'; - import React, { Component } from 'react'; -import PropTypes from 'prop-types'; import { EuiLoadingSpinner, EuiText, @@ -33,19 +30,25 @@ import { EuiFlexItem, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; +import { HttpSetup } from '../../http'; +import { NotificationsSetup } from '../../notifications'; +import { loadStatus, ProcessedServerResponse } from './lib'; +import { MetricTiles, StatusTable, ServerStatus } from './components'; + +interface StatusAppProps { + http: HttpSetup; + notifications: NotificationsSetup; +} -import MetricTiles from './metric_tiles'; -import StatusTable from './status_table'; -import ServerStatus from './server_status'; - -class StatusApp extends Component { - static propTypes = { - buildNum: PropTypes.number.isRequired, - buildSha: PropTypes.string.isRequired, - }; +interface StatusAppState { + loading: boolean; + fetchError: boolean; + data: ProcessedServerResponse | null; +} - constructor() { - super(); +export class StatusApp extends Component { + constructor(props: StatusAppProps) { + super(props); this.state = { loading: true, fetchError: false, @@ -53,18 +56,17 @@ class StatusApp extends Component { }; } - componentDidMount = async function () { - const data = await loadStatus(); - - if (data) { - this.setState({ loading: false, data: data }); - } else { - this.setState({ fetchError: true, loading: false }); + async componentDidMount() { + const { http, notifications } = this.props; + try { + const data = await loadStatus({ http, notifications }); + this.setState({ loading: false, fetchError: false, data }); + } catch (e) { + this.setState({ fetchError: true, loading: false, data: null }); } - }; + } render() { - const { buildNum, buildSha } = this.props; const { loading, fetchError, data } = this.state; // If we're still loading, return early with a spinner @@ -76,7 +78,7 @@ class StatusApp extends Component { return ( @@ -84,10 +86,11 @@ class StatusApp extends Component { } // Extract the items needed to render each component - const { metrics, statuses, serverState, name } = data; + const { metrics, statuses, serverState, name, version } = data!; + const { build_hash: buildHash, build_number: buildNumber } = version; return ( - + @@ -103,7 +106,7 @@ class StatusApp extends Component {

@@ -113,12 +116,12 @@ class StatusApp extends Component { -

+

{buildNum}, + buildNum: {buildNumber}, }} />

@@ -126,12 +129,12 @@ class StatusApp extends Component {
-

+

{buildSha}, + buildSha: {buildHash}, }} />

@@ -150,5 +153,3 @@ class StatusApp extends Component { ); } } - -export default StatusApp; diff --git a/src/core/public/core_system.ts b/src/core/public/core_system.ts index 00fabc2b6f2f..e08841b0271d 100644 --- a/src/core/public/core_system.ts +++ b/src/core/public/core_system.ts @@ -42,8 +42,8 @@ import { RenderingService } from './rendering'; import { SavedObjectsService } from './saved_objects'; import { ContextService } from './context'; import { IntegrationsService } from './integrations'; -import { InternalApplicationSetup, InternalApplicationStart } from './application/types'; import { CoreApp } from './core_app'; +import type { InternalApplicationSetup, InternalApplicationStart } from './application/types'; interface Params { rootDomElement: HTMLElement; @@ -180,7 +180,7 @@ export class CoreSystem { ]), }); const application = this.application.setup({ context, http, injectedMetadata }); - this.coreApp.setup({ application, http }); + this.coreApp.setup({ application, http, injectedMetadata, notifications }); const core: InternalCoreSetup = { application, diff --git a/src/core/public/injected_metadata/injected_metadata_service.mock.ts b/src/core/public/injected_metadata/injected_metadata_service.mock.ts index 5caa9830a643..e6b1c440519b 100644 --- a/src/core/public/injected_metadata/injected_metadata_service.mock.ts +++ b/src/core/public/injected_metadata/injected_metadata_service.mock.ts @@ -26,6 +26,7 @@ const createSetupContractMock = () => { getKibanaBranch: jest.fn(), getCspConfig: jest.fn(), getLegacyMode: jest.fn(), + getAnonymousStatusPage: jest.fn(), getLegacyMetadata: jest.fn(), getPlugins: jest.fn(), getInjectedVar: jest.fn(), @@ -35,6 +36,7 @@ const createSetupContractMock = () => { setupContract.getCspConfig.mockReturnValue({ warnLegacyBrowsers: true }); setupContract.getKibanaVersion.mockReturnValue('kibanaVersion'); setupContract.getLegacyMode.mockReturnValue(true); + setupContract.getAnonymousStatusPage.mockReturnValue(false); setupContract.getLegacyMetadata.mockReturnValue({ app: { id: 'foo', diff --git a/src/core/public/injected_metadata/injected_metadata_service.ts b/src/core/public/injected_metadata/injected_metadata_service.ts index 75abdd6d87d5..db4bfdf415bc 100644 --- a/src/core/public/injected_metadata/injected_metadata_service.ts +++ b/src/core/public/injected_metadata/injected_metadata_service.ts @@ -68,6 +68,7 @@ export interface InjectedMetadataParams { }; uiPlugins: InjectedPluginMetadata[]; legacyMode: boolean; + anonymousStatusPage: boolean; legacyMetadata: { app: { id: string; @@ -120,6 +121,10 @@ export class InjectedMetadataService { return this.state.serverBasePath; }, + getAnonymousStatusPage: () => { + return this.state.anonymousStatusPage; + }, + getKibanaVersion: () => { return this.state.version; }, @@ -179,6 +184,7 @@ export interface InjectedMetadataSetup { getPlugins: () => InjectedPluginMetadata[]; /** Indicates whether or not we are rendering a known legacy app. */ getLegacyMode: () => boolean; + getAnonymousStatusPage: () => boolean; getLegacyMetadata: () => { app: { id: string; diff --git a/src/core/server/core_app/core_app.test.ts b/src/core/server/core_app/core_app.test.ts new file mode 100644 index 000000000000..841088c45833 --- /dev/null +++ b/src/core/server/core_app/core_app.test.ts @@ -0,0 +1,71 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { mockCoreContext } from '../core_context.mock'; +import { coreMock } from '../mocks'; +import { httpResourcesMock } from '../http_resources/http_resources_service.mock'; +import { CoreApp } from './core_app'; + +describe('CoreApp', () => { + let coreApp: CoreApp; + let internalCoreSetup: ReturnType; + let httpResourcesRegistrar: ReturnType; + + beforeEach(() => { + const coreContext = mockCoreContext.create(); + internalCoreSetup = coreMock.createInternalSetup(); + httpResourcesRegistrar = httpResourcesMock.createRegistrar(); + internalCoreSetup.httpResources.createRegistrar.mockReturnValue(httpResourcesRegistrar); + coreApp = new CoreApp(coreContext); + }); + + describe('`/status` route', () => { + it('is registered with `authRequired: false` is the status page is anonymous', () => { + internalCoreSetup.status.isStatusPageAnonymous.mockReturnValue(true); + coreApp.setup(internalCoreSetup); + + expect(httpResourcesRegistrar.register).toHaveBeenCalledWith( + { + path: '/status', + validate: false, + options: { + authRequired: false, + }, + }, + expect.any(Function) + ); + }); + + it('is registered with `authRequired: true` is the status page is not anonymous', () => { + internalCoreSetup.status.isStatusPageAnonymous.mockReturnValue(false); + coreApp.setup(internalCoreSetup); + + expect(httpResourcesRegistrar.register).toHaveBeenCalledWith( + { + path: '/status', + validate: false, + options: { + authRequired: true, + }, + }, + expect.any(Function) + ); + }); + }); +}); diff --git a/src/core/server/core_app/core_app.ts b/src/core/server/core_app/core_app.ts index 5e1a3794632e..508e69ea1117 100644 --- a/src/core/server/core_app/core_app.ts +++ b/src/core/server/core_app/core_app.ts @@ -52,6 +52,24 @@ export class CoreApp { router.get({ path: '/core', validate: false }, async (context, req, res) => res.ok({ body: { version: '0.0.1' } }) ); + + const anonymousStatusPage = coreSetup.status.isStatusPageAnonymous(); + coreSetup.httpResources.createRegistrar(router).register( + { + path: '/status', + validate: false, + options: { + authRequired: !anonymousStatusPage, + }, + }, + async (context, request, response) => { + if (anonymousStatusPage) { + return response.renderAnonymousCoreApp(); + } else { + return response.renderCoreApp(); + } + } + ); } private registerStaticDirs(coreSetup: InternalCoreSetup) { coreSetup.http.registerStaticDir('/ui/{path*}', Path.resolve(__dirname, './assets')); diff --git a/src/core/server/http_resources/http_resources_service.mock.ts b/src/core/server/http_resources/http_resources_service.mock.ts index 4536b0898cad..9d7db3a5b727 100644 --- a/src/core/server/http_resources/http_resources_service.mock.ts +++ b/src/core/server/http_resources/http_resources_service.mock.ts @@ -25,7 +25,7 @@ const createHttpResourcesMock = (): jest.Mocked => ({ function createInternalHttpResourcesSetup() { return { - createRegistrar: createHttpResourcesMock, + createRegistrar: jest.fn(() => createHttpResourcesMock()), }; } diff --git a/src/core/server/mocks.ts b/src/core/server/mocks.ts index a3dbb279d19e..84e4b4741b71 100644 --- a/src/core/server/mocks.ts +++ b/src/core/server/mocks.ts @@ -31,7 +31,6 @@ import { typeRegistryMock as savedObjectsTypeRegistryMock } from './saved_object import { renderingMock } from './rendering/rendering_service.mock'; import { uiSettingsServiceMock } from './ui_settings/ui_settings_service.mock'; import { SharedGlobalConfig } from './plugins'; -import { InternalCoreSetup, InternalCoreStart } from './internal_types'; import { capabilitiesServiceMock } from './capabilities/capabilities_service.mock'; import { metricsServiceMock } from './metrics/metrics_service.mock'; import { uuidServiceMock } from './uuid/uuid_service.mock'; @@ -157,7 +156,7 @@ function createCoreStartMock() { } function createInternalCoreSetupMock() { - const setupDeps: InternalCoreSetup = { + const setupDeps = { capabilities: capabilitiesServiceMock.createSetupContract(), context: contextServiceMock.createSetupContract(), elasticsearch: elasticsearchServiceMock.createInternalSetup(), @@ -175,7 +174,7 @@ function createInternalCoreSetupMock() { } function createInternalCoreStartMock() { - const startDeps: InternalCoreStart = { + const startDeps = { capabilities: capabilitiesServiceMock.createStartContract(), elasticsearch: elasticsearchServiceMock.createInternalStart(), http: httpServiceMock.createInternalStartContract(), diff --git a/src/core/server/rendering/__mocks__/params.ts b/src/core/server/rendering/__mocks__/params.ts index ce2eea119d1b..0901cec768cd 100644 --- a/src/core/server/rendering/__mocks__/params.ts +++ b/src/core/server/rendering/__mocks__/params.ts @@ -21,15 +21,18 @@ import { mockCoreContext } from '../../core_context.mock'; import { httpServiceMock } from '../../http/http_service.mock'; import { pluginServiceMock } from '../../plugins/plugins_service.mock'; import { legacyServiceMock } from '../../legacy/legacy_service.mock'; +import { statusServiceMock } from '../../status/status_service.mock'; const context = mockCoreContext.create(); const http = httpServiceMock.createInternalSetupContract(); const uiPlugins = pluginServiceMock.createUiPlugins(); const legacyPlugins = legacyServiceMock.createDiscoverPlugins(); +const status = statusServiceMock.createInternalSetupContract(); export const mockRenderingServiceParams = context; export const mockRenderingSetupDeps = { http, legacyPlugins, uiPlugins, + status, }; diff --git a/src/core/server/rendering/__snapshots__/rendering_service.test.ts.snap b/src/core/server/rendering/__snapshots__/rendering_service.test.ts.snap index 3b11313367d9..95230b52c5c0 100644 --- a/src/core/server/rendering/__snapshots__/rendering_service.test.ts.snap +++ b/src/core/server/rendering/__snapshots__/rendering_service.test.ts.snap @@ -2,6 +2,7 @@ exports[`RenderingService setup() render() renders "core" from legacy request 1`] = ` Object { + "anonymousStatusPage": false, "basePath": "/mock-server-basepath", "branch": Any, "buildNumber": Any, @@ -74,6 +75,7 @@ Object { exports[`RenderingService setup() render() renders "core" page 1`] = ` Object { + "anonymousStatusPage": false, "basePath": "/mock-server-basepath", "branch": Any, "buildNumber": Any, @@ -146,6 +148,7 @@ Object { exports[`RenderingService setup() render() renders "core" page driven by settings 1`] = ` Object { + "anonymousStatusPage": false, "basePath": "/mock-server-basepath", "branch": Any, "buildNumber": Any, @@ -222,6 +225,7 @@ Object { exports[`RenderingService setup() render() renders "core" page for blank basepath 1`] = ` Object { + "anonymousStatusPage": false, "basePath": "", "branch": Any, "buildNumber": Any, @@ -294,6 +298,7 @@ Object { exports[`RenderingService setup() render() renders "core" with excluded user settings 1`] = ` Object { + "anonymousStatusPage": false, "basePath": "/mock-server-basepath", "branch": Any, "buildNumber": Any, @@ -366,6 +371,7 @@ Object { exports[`RenderingService setup() render() renders "legacy" page 1`] = ` Object { + "anonymousStatusPage": false, "basePath": "/mock-server-basepath", "branch": Any, "buildNumber": Any, @@ -438,6 +444,7 @@ Object { exports[`RenderingService setup() render() renders "legacy" page for blank basepath 1`] = ` Object { + "anonymousStatusPage": false, "basePath": "", "branch": Any, "buildNumber": Any, @@ -510,6 +517,7 @@ Object { exports[`RenderingService setup() render() renders "legacy" with custom vars 1`] = ` Object { + "anonymousStatusPage": false, "basePath": "/mock-server-basepath", "branch": Any, "buildNumber": Any, @@ -584,6 +592,7 @@ Object { exports[`RenderingService setup() render() renders "legacy" with excluded user settings 1`] = ` Object { + "anonymousStatusPage": false, "basePath": "/mock-server-basepath", "branch": Any, "buildNumber": Any, @@ -656,6 +665,7 @@ Object { exports[`RenderingService setup() render() renders "legacy" with excluded user settings and custom vars 1`] = ` Object { + "anonymousStatusPage": false, "basePath": "/mock-server-basepath", "branch": Any, "buildNumber": Any, diff --git a/src/core/server/rendering/rendering_service.tsx b/src/core/server/rendering/rendering_service.tsx index a02d85d22b2c..8f87d6249689 100644 --- a/src/core/server/rendering/rendering_service.tsx +++ b/src/core/server/rendering/rendering_service.tsx @@ -42,6 +42,7 @@ export class RenderingService implements CoreService { @@ -79,6 +80,7 @@ export class RenderingService implements CoreService]> = [ - [pathConfig.path, pathConfig.schema], - [cspConfig.path, cspConfig.schema], - [elasticsearchConfig.path, elasticsearchConfig.schema], - [loggingConfig.path, loggingConfig.schema], - [httpConfig.path, httpConfig.schema], - [pluginsConfig.path, pluginsConfig.schema], - [devConfig.path, devConfig.schema], - [kibanaConfig.path, kibanaConfig.schema], - [savedObjectsConfig.path, savedObjectsConfig.schema], - [savedObjectsMigrationConfig.path, savedObjectsMigrationConfig.schema], - [uiSettingsConfig.path, uiSettingsConfig.schema], - [opsConfig.path, opsConfig.schema], + const configDescriptors: Array> = [ + pathConfig, + cspConfig, + elasticsearchConfig, + loggingConfig, + httpConfig, + pluginsConfig, + devConfig, + kibanaConfig, + savedObjectsConfig, + savedObjectsMigrationConfig, + uiSettingsConfig, + opsConfig, + statusConfig, ]; this.configService.addDeprecationProvider(rootConfigPath, coreDeprecationProvider); - this.configService.addDeprecationProvider( - elasticsearchConfig.path, - elasticsearchConfig.deprecations! - ); - this.configService.addDeprecationProvider( - uiSettingsConfig.path, - uiSettingsConfig.deprecations! - ); - - for (const [path, schema] of schemas) { - await this.configService.setSchema(path, schema); + for (const descriptor of configDescriptors) { + if (descriptor.deprecations) { + this.configService.addDeprecationProvider(descriptor.path, descriptor.deprecations); + } + await this.configService.setSchema(descriptor.path, descriptor.schema); } } } diff --git a/src/core/server/status/index.ts b/src/core/server/status/index.ts index c39115d55a68..79d62390b3d4 100644 --- a/src/core/server/status/index.ts +++ b/src/core/server/status/index.ts @@ -18,4 +18,5 @@ */ export { StatusService } from './status_service'; +export { config } from './status_config'; export * from './types'; diff --git a/src/legacy/core_plugins/status_page/public/lib/load_status.test.mocks.js b/src/core/server/status/status_config.ts similarity index 64% rename from src/legacy/core_plugins/status_page/public/lib/load_status.test.mocks.js rename to src/core/server/status/status_config.ts index ec633a429b2e..34e61dc2bb1f 100644 --- a/src/legacy/core_plugins/status_page/public/lib/load_status.test.mocks.js +++ b/src/core/server/status/status_config.ts @@ -17,22 +17,16 @@ * under the License. */ -import { - fatalErrorsServiceMock, - notificationServiceMock, - overlayServiceMock, -} from '../../../../../core/public/mocks'; +import { schema, TypeOf } from '@kbn/config-schema'; +import { ServiceConfigDescriptor } from '../internal_types'; -jest.doMock('ui/new_platform', () => ({ - npSetup: { - core: { - fatalErrors: fatalErrorsServiceMock.createSetupContract(), - notifications: notificationServiceMock.createSetupContract(), - }, - }, - npStart: { - core: { - overlays: overlayServiceMock.createStartContract(), - }, - }, -})); +const statusConfigSchema = schema.object({ + allowAnonymous: schema.boolean({ defaultValue: false }), +}); + +export type StatusConfigType = TypeOf; + +export const config: ServiceConfigDescriptor = { + path: 'status', + schema: statusConfigSchema, +}; diff --git a/src/core/server/status/status_service.mock.ts b/src/core/server/status/status_service.mock.ts index d550c2f06750..c6eb11be6967 100644 --- a/src/core/server/status/status_service.mock.ts +++ b/src/core/server/status/status_service.mock.ts @@ -48,6 +48,7 @@ const createInternalSetupContractMock = () => { const setupContract: jest.Mocked = { core$: new BehaviorSubject(availableCoreStatus), overall$: new BehaviorSubject(available), + isStatusPageAnonymous: jest.fn().mockReturnValue(false), }; return setupContract; diff --git a/src/core/server/status/status_service.test.ts b/src/core/server/status/status_service.test.ts index b692cf316190..863fe34e8ece 100644 --- a/src/core/server/status/status_service.test.ts +++ b/src/core/server/status/status_service.test.ts @@ -28,6 +28,12 @@ import { ServiceStatusLevelSnapshotSerializer } from './test_utils'; expect.addSnapshotSerializer(ServiceStatusLevelSnapshotSerializer); describe('StatusService', () => { + let service: StatusService; + + beforeEach(() => { + service = new StatusService(mockCoreContext.create()); + }); + const available: ServiceStatus = { level: ServiceStatusLevels.available, summary: 'Available', @@ -40,7 +46,7 @@ describe('StatusService', () => { describe('setup', () => { describe('core$', () => { it('rolls up core status observables into single observable', async () => { - const setup = new StatusService(mockCoreContext.create()).setup({ + const setup = await service.setup({ elasticsearch: { status$: of(available), }, @@ -55,7 +61,7 @@ describe('StatusService', () => { }); it('replays last event', async () => { - const setup = new StatusService(mockCoreContext.create()).setup({ + const setup = await service.setup({ elasticsearch: { status$: of(available), }, @@ -80,10 +86,10 @@ describe('StatusService', () => { }); }); - it('does not emit duplicate events', () => { + it('does not emit duplicate events', async () => { const elasticsearch$ = new BehaviorSubject(available); const savedObjects$ = new BehaviorSubject(degraded); - const setup = new StatusService(mockCoreContext.create()).setup({ + const setup = await service.setup({ elasticsearch: { status$: elasticsearch$, }, @@ -145,7 +151,7 @@ describe('StatusService', () => { describe('overall$', () => { it('exposes an overall summary', async () => { - const setup = new StatusService(mockCoreContext.create()).setup({ + const setup = await service.setup({ elasticsearch: { status$: of(degraded), }, @@ -160,7 +166,7 @@ describe('StatusService', () => { }); it('replays last event', async () => { - const setup = new StatusService(mockCoreContext.create()).setup({ + const setup = await service.setup({ elasticsearch: { status$: of(degraded), }, @@ -185,10 +191,10 @@ describe('StatusService', () => { }); }); - it('does not emit duplicate events', () => { + it('does not emit duplicate events', async () => { const elasticsearch$ = new BehaviorSubject(available); const savedObjects$ = new BehaviorSubject(degraded); - const setup = new StatusService(mockCoreContext.create()).setup({ + const setup = await service.setup({ elasticsearch: { status$: elasticsearch$, }, diff --git a/src/core/server/status/status_service.ts b/src/core/server/status/status_service.ts index ef7bed958724..569b044a4fb2 100644 --- a/src/core/server/status/status_service.ts +++ b/src/core/server/status/status_service.ts @@ -20,7 +20,7 @@ /* eslint-disable max-classes-per-file */ import { Observable, combineLatest } from 'rxjs'; -import { map, distinctUntilChanged, shareReplay } from 'rxjs/operators'; +import { map, distinctUntilChanged, shareReplay, take } from 'rxjs/operators'; import { isDeepStrictEqual } from 'util'; import { CoreService } from '../../types'; @@ -29,6 +29,7 @@ import { Logger } from '../logging'; import { InternalElasticsearchServiceSetup } from '../elasticsearch'; import { InternalSavedObjectsServiceSetup } from '../saved_objects'; +import { config, StatusConfigType } from './status_config'; import { ServiceStatus, CoreStatus, InternalStatusServiceSetup } from './types'; import { getSummaryStatus } from './get_summary_status'; @@ -39,12 +40,15 @@ interface SetupDeps { export class StatusService implements CoreService { private readonly logger: Logger; + private readonly config$: Observable; constructor(coreContext: CoreContext) { this.logger = coreContext.logger.get('status'); + this.config$ = coreContext.configService.atPath(config.path); } - public setup(core: SetupDeps) { + public async setup(core: SetupDeps) { + const statusConfig = await this.config$.pipe(take(1)).toPromise(); const core$ = this.setupCoreStatus(core); const overall$: Observable = core$.pipe( map((coreStatus) => { @@ -58,6 +62,7 @@ export class StatusService implements CoreService { return { core$, overall$, + isStatusPageAnonymous: () => statusConfig.allowAnonymous, }; } diff --git a/src/core/server/status/types.ts b/src/core/server/status/types.ts index 84a7356c66bb..b04c25a1eee9 100644 --- a/src/core/server/status/types.ts +++ b/src/core/server/status/types.ts @@ -131,4 +131,5 @@ export interface InternalStatusServiceSetup extends StatusServiceSetup { * Overall system status used for HTTP API */ overall$: Observable; + isStatusPageAnonymous: () => boolean; } diff --git a/src/plugins/status_page/public/plugin.ts b/src/core/types/status.ts similarity index 56% rename from src/plugins/status_page/public/plugin.ts rename to src/core/types/status.ts index d072fd4a67c3..20b012e960a6 100644 --- a/src/plugins/status_page/public/plugin.ts +++ b/src/core/types/status.ts @@ -17,23 +17,36 @@ * under the License. */ -import { Plugin, CoreSetup } from 'kibana/public'; +import type { OpsMetrics } from '../server/metrics'; -export class StatusPagePlugin implements Plugin { - public setup(core: CoreSetup) { - const isStatusPageAnonymous = core.injectedMetadata.getInjectedVar( - 'isStatusPageAnonymous' - ) as boolean; - - if (isStatusPageAnonymous) { - core.http.anonymousPaths.register('/status'); - } - } +export interface ServerStatus { + id: string; + title: string; + state: string; + message: string; + uiColor: string; + icon?: string; + since?: string; +} - public start() {} +export type ServerMetrics = OpsMetrics & { + collection_interval_in_millis: number; +}; - public stop() {} +export interface ServerVersion { + number: string; + build_hash: string; + build_number: string; + build_snapshot: string; } -export type StatusPagePluginSetup = ReturnType; -export type StatusPagePluginStart = ReturnType; +export interface StatusResponse { + name: string; + uuid: string; + version: ServerVersion; + status: { + overall: ServerStatus; + statuses: ServerStatus[]; + }; + metrics: ServerMetrics; +} diff --git a/src/legacy/core_plugins/status_page/index.js b/src/legacy/core_plugins/status_page/index.js index 01991d8439a0..5a94eb9c7716 100644 --- a/src/legacy/core_plugins/status_page/index.js +++ b/src/legacy/core_plugins/status_page/index.js @@ -21,15 +21,10 @@ export default function (kibana) { return new kibana.Plugin({ uiExports: { app: { - title: 'Server Status', + title: 'Legacy Server Status', main: 'plugins/status_page/status_page', hidden: true, - url: '/status', - }, - injectDefaultVars(server) { - return { - isStatusPageAnonymous: server.config().get('status.allowAnonymous'), - }; + url: '/__legacy__/status', }, }, }); diff --git a/src/legacy/core_plugins/status_page/public/components/__snapshots__/metric_tiles.test.js.snap b/src/legacy/core_plugins/status_page/public/components/__snapshots__/metric_tiles.test.js.snap deleted file mode 100644 index 7d4b245021c4..000000000000 --- a/src/legacy/core_plugins/status_page/public/components/__snapshots__/metric_tiles.test.js.snap +++ /dev/null @@ -1,33 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`byte metric 1`] = ` - -`; - -exports[`float metric 1`] = ` - -`; - -exports[`general metric 1`] = ` - -`; - -exports[`millisecond metric 1`] = ` - -`; diff --git a/src/legacy/core_plugins/status_page/public/components/__snapshots__/server_status.test.js.snap b/src/legacy/core_plugins/status_page/public/components/__snapshots__/server_status.test.js.snap deleted file mode 100644 index 6ff046557afa..000000000000 --- a/src/legacy/core_plugins/status_page/public/components/__snapshots__/server_status.test.js.snap +++ /dev/null @@ -1,44 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`render 1`] = ` - - - -

- - Green - , - } - } - /> -

-
-
- - -

- My Computer -

-
-
-
-`; diff --git a/src/legacy/core_plugins/status_page/public/components/render.js b/src/legacy/core_plugins/status_page/public/components/render.js index b9462bf21797..dca79d783a29 100644 --- a/src/legacy/core_plugins/status_page/public/components/render.js +++ b/src/legacy/core_plugins/status_page/public/components/render.js @@ -20,24 +20,19 @@ import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; import { I18nContext } from 'ui/i18n'; - -import StatusApp from './status_app'; +// just to import eui into legacy +import '@elastic/eui'; const STATUS_PAGE_DOM_NODE_ID = 'createStatusPageReact'; -export function renderStatusPage(buildNum, buildSha) { +export function renderStatusPage() { const node = document.getElementById(STATUS_PAGE_DOM_NODE_ID); if (!node) { return; } - render( - - - , - node - ); + render(Foo, node); } export function destroyStatusPage() { diff --git a/src/legacy/core_plugins/status_page/public/components/status_table.js b/src/legacy/core_plugins/status_page/public/components/status_table.js deleted file mode 100644 index 68b93153951c..000000000000 --- a/src/legacy/core_plugins/status_page/public/components/status_table.js +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import { State as StatePropType } from '../lib/prop_types'; -import { EuiBasicTable, EuiIcon } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; - -class StatusTable extends Component { - static propTypes = { - statuses: PropTypes.arrayOf( - PropTypes.shape({ - id: PropTypes.string.isRequired, // plugin id - state: StatePropType.isRequired, // state of the plugin - }) - ), // can be null - }; - - static columns = [ - { - field: 'state', - name: '', - render: (state) => , - width: '32px', - }, - { - field: 'id', - name: i18n.translate('statusPage.statusTable.columns.idHeader', { - defaultMessage: 'ID', - }), - }, - { - field: 'state', - name: i18n.translate('statusPage.statusTable.columns.statusHeader', { - defaultMessage: 'Status', - }), - render: (state) => {state.message}, - }, - ]; - - static getRowProps = ({ state }) => { - return { - className: `status-table-row-${state.uiColor}`, - }; - }; - - render() { - const { statuses } = this.props; - - if (!statuses) { - return null; - } - - return ( - - ); - } -} - -export default StatusTable; diff --git a/src/legacy/core_plugins/status_page/public/lib/load_status.js b/src/legacy/core_plugins/status_page/public/lib/load_status.js deleted file mode 100644 index d033e5f147d9..000000000000 --- a/src/legacy/core_plugins/status_page/public/lib/load_status.js +++ /dev/null @@ -1,163 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import _ from 'lodash'; - -import chrome from 'ui/chrome'; -import { toastNotifications } from 'ui/notify'; -import { i18n } from '@kbn/i18n'; - -// Module-level error returned by notify.error -let errorNotif; - -/* -Returns an object of any keys that should be included for metrics. -*/ -function formatMetrics(data) { - if (!data.metrics) { - return null; - } - - return [ - { - name: i18n.translate('statusPage.metricsTiles.columns.heapTotalHeader', { - defaultMessage: 'Heap total', - }), - value: _.get(data.metrics, 'process.memory.heap.size_limit'), - type: 'byte', - }, - { - name: i18n.translate('statusPage.metricsTiles.columns.heapUsedHeader', { - defaultMessage: 'Heap used', - }), - value: _.get(data.metrics, 'process.memory.heap.used_in_bytes'), - type: 'byte', - }, - { - name: i18n.translate('statusPage.metricsTiles.columns.loadHeader', { - defaultMessage: 'Load', - }), - value: [ - _.get(data.metrics, 'os.load.1m'), - _.get(data.metrics, 'os.load.5m'), - _.get(data.metrics, 'os.load.15m'), - ], - type: 'float', - }, - { - name: i18n.translate('statusPage.metricsTiles.columns.resTimeAvgHeader', { - defaultMessage: 'Response time avg', - }), - value: _.get(data.metrics, 'response_times.avg_in_millis'), - type: 'ms', - }, - { - name: i18n.translate('statusPage.metricsTiles.columns.resTimeMaxHeader', { - defaultMessage: 'Response time max', - }), - value: _.get(data.metrics, 'response_times.max_in_millis'), - type: 'ms', - }, - { - name: i18n.translate('statusPage.metricsTiles.columns.requestsPerSecHeader', { - defaultMessage: 'Requests per second', - }), - value: - (_.get(data.metrics, 'requests.total') * 1000) / - _.get(data.metrics, 'collection_interval_in_millis'), - }, - ]; -} - -/** - * Reformat the backend data to make the frontend views simpler. - */ -function formatStatus(status) { - return { - id: status.id, - state: { - id: status.state, - title: status.title, - message: status.message, - uiColor: status.uiColor, - }, - }; -} - -async function fetchData() { - return fetch(chrome.addBasePath('/api/status'), { - method: 'get', - credentials: 'same-origin', - }); -} - -/* -Get the status from the server API and format it for display. - -`fetchFn` can be injected for testing, defaults to the implementation above. -*/ -async function loadStatus(fetchFn = fetchData) { - // Clear any existing error banner. - if (errorNotif) { - errorNotif.clear(); - errorNotif = null; - } - - let response; - - try { - response = await fetchFn(); - } catch (e) { - // If the fetch failed to connect, display an error and bail. - const serverIsDownErrorMessage = i18n.translate( - 'statusPage.loadStatus.serverIsDownErrorMessage', - { - defaultMessage: 'Failed to request server status. Perhaps your server is down?', - } - ); - - errorNotif = toastNotifications.addDanger(serverIsDownErrorMessage); - return e; - } - - if (response.status >= 400) { - // If the server does not respond with a successful status, display an error and bail. - const serverStatusCodeErrorMessage = i18n.translate( - 'statusPage.loadStatus.serverStatusCodeErrorMessage', - { - defaultMessage: 'Failed to request server status with status code {responseStatus}', - values: { responseStatus: response.status }, - } - ); - - errorNotif = toastNotifications.addDanger(serverStatusCodeErrorMessage); - return; - } - - const data = await response.json(); - - return { - name: data.name, - statuses: data.status.statuses.map(formatStatus), - serverState: formatStatus(data.status.overall).state, - metrics: formatMetrics(data), - }; -} - -export default loadStatus; diff --git a/src/legacy/core_plugins/status_page/public/lib/load_status.test.js b/src/legacy/core_plugins/status_page/public/lib/load_status.test.js deleted file mode 100644 index a0f1930ca766..000000000000 --- a/src/legacy/core_plugins/status_page/public/lib/load_status.test.js +++ /dev/null @@ -1,115 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import './load_status.test.mocks'; -import loadStatus from './load_status'; - -// A faked response to the `fetch` call -const mockFetch = async () => ({ - status: 200, - json: async () => ({ - name: 'My computer', - status: { - overall: { - state: 'yellow', - title: 'Yellow', - }, - statuses: [ - { id: 'plugin:1', state: 'green', title: 'Green', message: 'Ready', uiColor: 'secondary' }, - { - id: 'plugin:2', - state: 'yellow', - title: 'Yellow', - message: 'Something is weird', - uiColor: 'warning', - }, - ], - }, - metrics: { - collection_interval_in_millis: 1000, - os: { - load: { - '1m': 4.1, - '5m': 2.1, - '15m': 0.1, - }, - }, - - process: { - memory: { - heap: { - size_limit: 1000000, - used_in_bytes: 100, - }, - }, - }, - - response_times: { - avg_in_millis: 4000, - max_in_millis: 8000, - }, - - requests: { - total: 400, - }, - }, - }), -}); - -describe('response processing', () => { - test('includes the name', async () => { - const data = await loadStatus(mockFetch); - expect(data.name).toEqual('My computer'); - }); - - test('includes the plugin statuses', async () => { - const data = await loadStatus(mockFetch); - expect(data.statuses).toEqual([ - { - id: 'plugin:1', - state: { id: 'green', title: 'Green', message: 'Ready', uiColor: 'secondary' }, - }, - { - id: 'plugin:2', - state: { id: 'yellow', title: 'Yellow', message: 'Something is weird', uiColor: 'warning' }, - }, - ]); - }); - - test('includes the serverState', async () => { - const data = await loadStatus(mockFetch); - expect(data.serverState).toEqual({ id: 'yellow', title: 'Yellow' }); - }); - - test('builds the metrics', async () => { - const data = await loadStatus(mockFetch); - const names = data.metrics.map((m) => m.name); - expect(names).toEqual([ - 'Heap total', - 'Heap used', - 'Load', - 'Response time avg', - 'Response time max', - 'Requests per second', - ]); - - const values = data.metrics.map((m) => m.value); - expect(values).toEqual([1000000, 100, [4.1, 2.1, 0.1], 4000, 8000, 400]); - }); -}); diff --git a/src/legacy/core_plugins/status_page/public/lib/prop_types.js b/src/legacy/core_plugins/status_page/public/lib/prop_types.js deleted file mode 100644 index d1f665f97475..000000000000 --- a/src/legacy/core_plugins/status_page/public/lib/prop_types.js +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import PropTypes from 'prop-types'; - -export const State = PropTypes.shape({ - id: PropTypes.string.isRequired, - message: PropTypes.string, // optional - title: PropTypes.string, // optional - uiColor: PropTypes.string.isRequired, -}); - -export const Metric = PropTypes.shape({ - name: PropTypes.string.isRequired, - value: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.number), PropTypes.number]).isRequired, - type: PropTypes.string, // optional -}); diff --git a/src/legacy/server/status/index.js b/src/legacy/server/status/index.js index 377a5d74610a..ab7ec471a67f 100644 --- a/src/legacy/server/status/index.js +++ b/src/legacy/server/status/index.js @@ -19,7 +19,7 @@ import ServerStatus from './server_status'; import { Metrics } from './lib/metrics'; -import { registerStatusPage, registerStatusApi, registerStatsApi } from './routes'; +import { registerStatusApi, registerStatsApi } from './routes'; import Oppsy from 'oppsy'; import { cloneDeep } from 'lodash'; import { getOSInfo } from './lib/get_os_info'; @@ -53,7 +53,6 @@ export function statusMixin(kbnServer, server, config) { }); // init routes - registerStatusPage(kbnServer, server, config); registerStatusApi(kbnServer, server, config); registerStatsApi(usageCollection, server, config, kbnServer); diff --git a/src/legacy/server/status/routes/index.js b/src/legacy/server/status/routes/index.js index 720309c3fd74..12736a76d491 100644 --- a/src/legacy/server/status/routes/index.js +++ b/src/legacy/server/status/routes/index.js @@ -17,6 +17,5 @@ * under the License. */ -export { registerStatusPage } from './page/register_status'; export { registerStatusApi } from './api/register_status'; export { registerStatsApi } from './api/register_stats'; diff --git a/src/legacy/server/status/routes/page/register_status.js b/src/legacy/server/status/routes/page/register_status.js deleted file mode 100644 index 47bd3c34eba5..000000000000 --- a/src/legacy/server/status/routes/page/register_status.js +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { wrapAuthConfig } from '../../wrap_auth_config'; - -export function registerStatusPage(kbnServer, server, config) { - const allowAnonymous = config.get('status.allowAnonymous'); - const wrapAuth = wrapAuthConfig(allowAnonymous); - - server.decorate('toolkit', 'renderStatusPage', async function () { - const app = server.getHiddenUiAppById('status_page'); - const h = this; - - let response; - // An unauthenticated (anonymous) user may not have access to the customized configuration. - // For this scenario, render with the default config. - if (app) { - response = allowAnonymous ? await h.renderAppWithDefaultConfig(app) : await h.renderApp(app); - } else { - h.response(kbnServer.status.toString()); - } - - if (response) { - return response.code(kbnServer.status.isGreen() ? 200 : 503); - } - }); - - server.route( - wrapAuth({ - method: 'GET', - path: '/status', - handler(request, h) { - return h.renderStatusPage(); - }, - }) - ); -} diff --git a/src/legacy/ui/ui_render/ui_render_mixin.js b/src/legacy/ui/ui_render/ui_render_mixin.js index 7788aeaee72e..12ae6390fdc2 100644 --- a/src/legacy/ui/ui_render/ui_render_mixin.js +++ b/src/legacy/ui/ui_render/ui_render_mixin.js @@ -204,13 +204,8 @@ export function uiRenderMixin(kbnServer, server, config) { async handler(req, h) { const id = req.params.id; const app = server.getUiAppById(id); - try { - if (kbnServer.status.isGreen()) { - return await h.renderApp(app); - } else { - return await h.renderStatusPage(); - } + return await h.renderApp(app); } catch (err) { throw Boom.boomify(err); } diff --git a/src/plugins/status_page/kibana.json b/src/plugins/status_page/kibana.json deleted file mode 100644 index 0d54f6a39e2b..000000000000 --- a/src/plugins/status_page/kibana.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "id": "statusPage", - "version": "kibana", - "server": false, - "ui": true -} diff --git a/test/functional/apps/status_page/index.js b/test/functional/apps/status_page/index.ts similarity index 56% rename from test/functional/apps/status_page/index.js rename to test/functional/apps/status_page/index.ts index 34f2df287dd6..65349aba93b9 100644 --- a/test/functional/apps/status_page/index.js +++ b/test/functional/apps/status_page/index.ts @@ -18,8 +18,9 @@ */ import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; -export default function ({ getService, getPageObjects }) { +export default function ({ getService, getPageObjects }: FtrProviderContext) { const retry = getService('retry'); const testSubjects = getService('testSubjects'); const PageObjects = getPageObjects(['common']); @@ -31,11 +32,32 @@ export default function ({ getService, getPageObjects }) { await PageObjects.common.navigateToApp('status_page'); }); - it('should show the kibana plugin as ready', async function () { + it('should show the kibana plugin as ready', async () => { await retry.tryForTime(6000, async () => { const text = await testSubjects.getVisibleText('statusBreakdown'); expect(text.indexOf('plugin:kibana')).to.be.above(-1); }); }); + + it('should show the build hash and number', async () => { + const buildNumberText = await testSubjects.getVisibleText('statusBuildNumber'); + expect(buildNumberText).to.contain('BUILD '); + + const hashText = await testSubjects.getVisibleText('statusBuildHash'); + expect(hashText).to.contain('COMMIT '); + }); + + it('should display the server metrics', async () => { + const metrics = await testSubjects.findAll('serverMetric'); + expect(metrics).to.have.length(6); + }); + + it('should display the server status', async () => { + const titleText = await testSubjects.getVisibleText('serverStatusTitle'); + expect(titleText).to.contain('Kibana status is'); + + const serverStatus = await testSubjects.getAttribute('serverStatusTitleBadge', 'aria-label'); + expect(serverStatus).to.be('Green'); + }); }); } diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index d07d92a028d8..846330146cf0 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -360,6 +360,21 @@ "core.fatalErrors.tryRefreshingPageDescription": "ページを更新してみてください。うまくいかない場合は、前のページに戻るか、セッションデータを消去してください。", "core.notifications.errorToast.closeModal": "閉じる", "core.notifications.unableUpdateUISettingNotificationMessageTitle": "UI 設定を更新できません", + "core.statusPage.loadStatus.serverIsDownErrorMessage": "サーバーステータスのリクエストに失敗しました。サーバーがダウンしている可能性があります。", + "core.statusPage.loadStatus.serverStatusCodeErrorMessage": "サーバーステータスのリクエストに失敗しました。ステータスコード: {responseStatus}", + "core.statusPage.metricsTiles.columns.heapTotalHeader": "ヒープ合計", + "core.statusPage.metricsTiles.columns.heapUsedHeader": "使用ヒープ", + "core.statusPage.metricsTiles.columns.loadHeader": "読み込み", + "core.statusPage.metricsTiles.columns.requestsPerSecHeader": "1 秒あたりのリクエスト", + "core.statusPage.metricsTiles.columns.resTimeAvgHeader": "平均応答時間", + "core.statusPage.metricsTiles.columns.resTimeMaxHeader": "最長応答時間", + "core.statusPage.serverStatus.statusTitle": "Kibana のステータス: {kibanaStatus}", + "core.statusPage.statusApp.loadingErrorText": "ステータスの読み込み中にエラーが発生しました", + "core.statusPage.statusApp.statusActions.buildText": "{buildNum} を作成", + "core.statusPage.statusApp.statusActions.commitText": "{buildSha} を確定", + "core.statusPage.statusApp.statusTitle": "プラグインステータス", + "core.statusPage.statusTable.columns.idHeader": "ID", + "core.statusPage.statusTable.columns.statusHeader": "ステータス", "core.toasts.errorToast.seeFullError": "完全なエラーを表示", "core.ui.chrome.headerGlobalNav.goHomePageIconAriaLabel": "ホームページに移動", "core.ui.chrome.headerGlobalNav.helpMenuAskElasticTitle": "Elasticに確認する", @@ -2470,21 +2485,6 @@ "server.status.redTitle": "赤", "server.status.uninitializedTitle": "アンインストールしました", "server.status.yellowTitle": "黄色", - "statusPage.loadStatus.serverIsDownErrorMessage": "サーバーステータスのリクエストに失敗しました。サーバーがダウンしている可能性があります。", - "statusPage.loadStatus.serverStatusCodeErrorMessage": "サーバーステータスのリクエストに失敗しました。ステータスコード: {responseStatus}", - "statusPage.metricsTiles.columns.heapTotalHeader": "ヒープ合計", - "statusPage.metricsTiles.columns.heapUsedHeader": "使用ヒープ", - "statusPage.metricsTiles.columns.loadHeader": "読み込み", - "statusPage.metricsTiles.columns.requestsPerSecHeader": "1 秒あたりのリクエスト", - "statusPage.metricsTiles.columns.resTimeAvgHeader": "平均応答時間", - "statusPage.metricsTiles.columns.resTimeMaxHeader": "最長応答時間", - "statusPage.serverStatus.statusTitle": "Kibana のステータス: {kibanaStatus}", - "statusPage.statusApp.loadingErrorText": "ステータスの読み込み中にエラーが発生しました", - "statusPage.statusApp.statusActions.buildText": "{buildNum} を作成", - "statusPage.statusApp.statusActions.commitText": "{buildSha} を確定", - "statusPage.statusApp.statusTitle": "プラグインステータス", - "statusPage.statusTable.columns.idHeader": "ID", - "statusPage.statusTable.columns.statusHeader": "ステータス", "telemetry.callout.appliesSettingTitle": "この設定に加えた変更は {allOfKibanaText} に適用され、自動的に保存されます。", "telemetry.callout.appliesSettingTitle.allOfKibanaText": "Kibana のすべて", "telemetry.callout.clusterStatisticsDescription": "これは収集される基本的なクラスター統計の例です。インデックス、シャード、ノードの数が含まれます。監視がオンになっているかどうかなどのハイレベルの使用統計も含まれます。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 8f844de735dc..477858d2e74d 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -360,6 +360,21 @@ "core.fatalErrors.tryRefreshingPageDescription": "请尝试刷新页面。如果无效,请返回上一页或清除您的会话数据。", "core.notifications.errorToast.closeModal": "关闭", "core.notifications.unableUpdateUISettingNotificationMessageTitle": "无法更新 UI 设置", + "core.statusPage.loadStatus.serverIsDownErrorMessage": "无法请求服务器状态。也许您的服务器已关闭?", + "core.statusPage.loadStatus.serverStatusCodeErrorMessage": "无法使用状态代码 {responseStatus} 请求服务器状态", + "core.statusPage.metricsTiles.columns.heapTotalHeader": "堆总计", + "core.statusPage.metricsTiles.columns.heapUsedHeader": "已使用堆", + "core.statusPage.metricsTiles.columns.loadHeader": "负载", + "core.statusPage.metricsTiles.columns.requestsPerSecHeader": "每秒请求数", + "core.statusPage.metricsTiles.columns.resTimeAvgHeader": "响应时间平均值", + "core.statusPage.metricsTiles.columns.resTimeMaxHeader": "响应时间最大值", + "core.statusPage.serverStatus.statusTitle": "Kibana 状态为“{kibanaStatus}”", + "core.statusPage.statusApp.loadingErrorText": "加载状态时出错", + "core.statusPage.statusApp.statusActions.buildText": "BUILD {buildNum}", + "core.statusPage.statusApp.statusActions.commitText": "COMMIT {buildSha}", + "core.statusPage.statusApp.statusTitle": "插件状态", + "core.statusPage.statusTable.columns.idHeader": "ID", + "core.statusPage.statusTable.columns.statusHeader": "状态", "core.toasts.errorToast.seeFullError": "请参阅完整的错误信息", "core.ui.chrome.headerGlobalNav.goHomePageIconAriaLabel": "前往主页", "core.ui.chrome.headerGlobalNav.helpMenuAskElasticTitle": "问询 Elastic", @@ -2473,21 +2488,6 @@ "server.status.redTitle": "红", "server.status.uninitializedTitle": "未初始化", "server.status.yellowTitle": "黄", - "statusPage.loadStatus.serverIsDownErrorMessage": "无法请求服务器状态。也许您的服务器已关闭?", - "statusPage.loadStatus.serverStatusCodeErrorMessage": "无法使用状态代码 {responseStatus} 请求服务器状态", - "statusPage.metricsTiles.columns.heapTotalHeader": "堆总计", - "statusPage.metricsTiles.columns.heapUsedHeader": "已使用堆", - "statusPage.metricsTiles.columns.loadHeader": "负载", - "statusPage.metricsTiles.columns.requestsPerSecHeader": "每秒请求数", - "statusPage.metricsTiles.columns.resTimeAvgHeader": "响应时间平均值", - "statusPage.metricsTiles.columns.resTimeMaxHeader": "响应时间最大值", - "statusPage.serverStatus.statusTitle": "Kibana 状态为“{kibanaStatus}”", - "statusPage.statusApp.loadingErrorText": "加载状态时出错", - "statusPage.statusApp.statusActions.buildText": "BUILD {buildNum}", - "statusPage.statusApp.statusActions.commitText": "COMMIT {buildSha}", - "statusPage.statusApp.statusTitle": "插件状态", - "statusPage.statusTable.columns.idHeader": "ID", - "statusPage.statusTable.columns.statusHeader": "状态", "telemetry.callout.appliesSettingTitle": "对此设置的更改将应用到{allOfKibanaText} 且会自动保存。", "telemetry.callout.appliesSettingTitle.allOfKibanaText": "整个 Kibana", "telemetry.callout.clusterStatisticsDescription": "这是我们将收集的基本集群统计信息的示例。其包括索引、分片和节点的数目。还包括概括性的使用情况统计信息,例如监测是否打开。", diff --git a/x-pack/test/functional/page_objects/status_page.js b/x-pack/test/functional/page_objects/status_page.js index 68fc931a9140..eba5e7dd1849 100644 --- a/x-pack/test/functional/page_objects/status_page.js +++ b/x-pack/test/functional/page_objects/status_page.js @@ -29,7 +29,7 @@ export function StatusPagePageProvider({ getService, getPageObjects }) { async expectStatusPage() { return await retry.try(async () => { log.debug(`expectStatusPage()`); - await find.byCssSelector('[data-test-subj="kibanaChrome"] nav:not(.ng-hide) ', 20000); + await find.byCssSelector('[data-test-subj="statusPageRoot"]', 20000); const url = await browser.getCurrentUrl(); expect(url).to.contain(`/status`); }); From cf3aa2c6415dbdd002da97d864022673dd710dea Mon Sep 17 00:00:00 2001 From: Uladzislau Lasitsa Date: Thu, 23 Jul 2020 14:03:07 +0300 Subject: [PATCH 085/142] Migrated karma tests to jest (#72649) --- .../public/__tests__/discover/legacy.ts | 27 - .../public/__tests__/discover/row_headers.js | 428 ---------------- .../angular/directives/fixed_scroll.test.js | 4 + .../doc_table/components/row_headers.test.js | 485 ++++++++++++++++++ .../angular/doc_table/doc_table.test.js} | 84 +-- 5 files changed, 544 insertions(+), 484 deletions(-) delete mode 100644 src/legacy/core_plugins/kibana/public/__tests__/discover/legacy.ts delete mode 100644 src/legacy/core_plugins/kibana/public/__tests__/discover/row_headers.js create mode 100644 src/plugins/discover/public/application/angular/doc_table/components/row_headers.test.js rename src/{legacy/core_plugins/kibana/public/__tests__/discover/doc_table.js => plugins/discover/public/application/angular/doc_table/doc_table.test.js} (52%) diff --git a/src/legacy/core_plugins/kibana/public/__tests__/discover/legacy.ts b/src/legacy/core_plugins/kibana/public/__tests__/discover/legacy.ts deleted file mode 100644 index ecda2a8c1539..000000000000 --- a/src/legacy/core_plugins/kibana/public/__tests__/discover/legacy.ts +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { npSetup, npStart } from 'ui/new_platform'; -import { plugin } from '../../../../../../plugins/discover/public'; -import { coreMock } from '../../../../../../core/public/mocks'; -const context = coreMock.createPluginInitializerContext(); - -export const pluginInstance = plugin(context); -export const setup = pluginInstance.setup(npSetup.core, npSetup.plugins); -export const start = pluginInstance.start(npStart.core, npStart.plugins); diff --git a/src/legacy/core_plugins/kibana/public/__tests__/discover/row_headers.js b/src/legacy/core_plugins/kibana/public/__tests__/discover/row_headers.js deleted file mode 100644 index 29c301bf065c..000000000000 --- a/src/legacy/core_plugins/kibana/public/__tests__/discover/row_headers.js +++ /dev/null @@ -1,428 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -import angular from 'angular'; -import _ from 'lodash'; -import sinon from 'sinon'; -import expect from '@kbn/expect'; -import ngMock from 'ng_mock'; -import { getFakeRow, getFakeRowVals } from 'fixtures/fake_row'; -import $ from 'jquery'; -import { pluginInstance } from './legacy'; -import FixturesStubbedLogstashIndexPatternProvider from 'fixtures/stubbed_logstash_index_pattern'; -import { setScopedHistory } from '../../../../../../plugins/discover/public/kibana_services'; -import { createBrowserHistory } from 'history'; - -describe('Doc Table', function () { - let $parentScope; - let $scope; - - // Stub out a minimal mapping of 4 fields - let mapping; - - let fakeRowVals; - let stubFieldFormatConverter; - beforeEach(() => pluginInstance.initializeServices()); - beforeEach(() => pluginInstance.initializeInnerAngular()); - before(() => setScopedHistory(createBrowserHistory())); - beforeEach(ngMock.module('app/discover')); - beforeEach( - ngMock.inject(function ($rootScope, Private) { - $parentScope = $rootScope; - $parentScope.indexPattern = Private(FixturesStubbedLogstashIndexPatternProvider); - mapping = $parentScope.indexPattern.fields; - - // Stub `getConverterFor` for a field in the indexPattern to return mock data. - // Returns `val` if provided, otherwise generates fake data for the field. - fakeRowVals = getFakeRowVals('formatted', 0, mapping); - stubFieldFormatConverter = function ($root, field, val) { - const convertFn = (value, type, options) => { - if (val) { - return val; - } - const fieldName = _.get(options, 'field.name', null); - - return fakeRowVals[fieldName] || ''; - }; - - $root.indexPattern.fields.getByName(field).format.convert = convertFn; - $root.indexPattern.fields.getByName(field).format.getConverterFor = () => convertFn; - }; - }) - ); - - // Sets up the directive, take an element, and a list of properties to attach to the parent scope. - const init = function ($elem, props) { - ngMock.inject(function ($compile) { - _.assign($parentScope, props); - $compile($elem)($parentScope); - $elem.scope().$digest(); - $scope = $elem.isolateScope(); - }); - }; - - const destroy = function () { - $scope.$destroy(); - $parentScope.$destroy(); - }; - - // For testing column removing/adding for the header and the rows - const columnTests = function (elemType, parentElem) { - it('should create a time column if the timefield is defined', function () { - const childElems = parentElem.find(elemType); - expect(childElems.length).to.be(1); - }); - - it('should be able to add and remove columns', function () { - let childElems; - - stubFieldFormatConverter($parentScope, 'bytes'); - stubFieldFormatConverter($parentScope, 'request_body'); - - // Should include a column for toggling and the time column by default - $parentScope.columns = ['bytes']; - parentElem.scope().$digest(); - childElems = parentElem.find(elemType); - expect(childElems.length).to.be(2); - expect($(childElems[1]).text()).to.contain('bytes'); - - $parentScope.columns = ['bytes', 'request_body']; - parentElem.scope().$digest(); - childElems = parentElem.find(elemType); - expect(childElems.length).to.be(3); - expect($(childElems[2]).text()).to.contain('request_body'); - - $parentScope.columns = ['request_body']; - parentElem.scope().$digest(); - childElems = parentElem.find(elemType); - expect(childElems.length).to.be(2); - expect($(childElems[1]).text()).to.contain('request_body'); - }); - - it('should create only the toggle column if there is no timeField', function () { - delete parentElem.scope().indexPattern.timeFieldName; - parentElem.scope().$digest(); - - const childElems = parentElem.find(elemType); - expect(childElems.length).to.be(0); - }); - }; - - describe('kbnTableRow', function () { - const $elem = angular.element( - '' - ); - let row; - - beforeEach(function () { - row = getFakeRow(0, mapping); - - init($elem, { - row, - columns: [], - sorting: [], - filter: sinon.spy(), - maxLength: 50, - }); - }); - afterEach(function () { - destroy(); - }); - - describe('adding and removing columns', function () { - columnTests('[data-test-subj~="docTableField"]', $elem); - }); - - describe('details row', function () { - it('should be an empty tr by default', function () { - expect($elem.next().is('tr')).to.be(true); - expect($elem.next().text()).to.be(''); - }); - - it('should expand the detail row when the toggle arrow is clicked', function () { - $elem.children(':first-child').click(); - $scope.$digest(); - expect($elem.next().text()).to.not.be(''); - }); - - describe('expanded', function () { - let $details; - beforeEach(function () { - // Open the row - $scope.toggleRow(); - $scope.$digest(); - $details = $elem.next(); - }); - afterEach(function () { - // Close the row - $scope.toggleRow(); - $scope.$digest(); - }); - - it('should be a tr with something in it', function () { - expect($details.is('tr')).to.be(true); - expect($details.text()).to.not.be.empty(); - }); - }); - }); - }); - - describe('kbnTableRow meta', function () { - const $elem = angular.element( - '' - ); - let row; - - beforeEach(function () { - row = getFakeRow(0, mapping); - - init($elem, { - row: row, - columns: [], - sorting: [], - filtering: sinon.spy(), - maxLength: 50, - }); - - // Open the row - $scope.toggleRow(); - $scope.$digest(); - $elem.next(); - }); - - afterEach(function () { - destroy(); - }); - - /** this no longer works with the new plugin approach - it('should render even when the row source contains a field with the same name as a meta field', function () { - setTimeout(() => { - //this should be overridden by later changes - }, 100); - expect($details.find('tr').length).to.be(_.keys($parentScope.indexPattern.flattenHit($scope.row)).length); - }); */ - }); - - describe('row diffing', function () { - let $row; - let $scope; - let $root; - let $before; - - beforeEach( - ngMock.inject(function ($rootScope, $compile, Private) { - $root = $rootScope; - $root.row = getFakeRow(0, mapping); - $root.columns = ['_source']; - $root.sorting = []; - $root.filtering = sinon.spy(); - $root.maxLength = 50; - $root.mapping = mapping; - $root.indexPattern = Private(FixturesStubbedLogstashIndexPatternProvider); - - // Stub field format converters for every field in the indexPattern - $root.indexPattern.fields.forEach((f) => stubFieldFormatConverter($root, f.name)); - - $row = $('').attr({ - 'kbn-table-row': 'row', - columns: 'columns', - sorting: 'sorting', - filtering: 'filtering', - 'index-pattern': 'indexPattern', - }); - - $scope = $root.$new(); - $compile($row)($scope); - $root.$apply(); - - $before = $row.find('td'); - expect($before).to.have.length(3); - expect($before.eq(0).text().trim()).to.be(''); - expect($before.eq(1).text().trim()).to.match(/^time_formatted/); - }) - ); - - afterEach(function () { - $row.remove(); - }); - - it('handles a new column', function () { - $root.columns.push('bytes'); - $root.$apply(); - - const $after = $row.find('td'); - expect($after).to.have.length(4); - expect($after[0]).to.be($before[0]); - expect($after[1]).to.be($before[1]); - expect($after[2]).to.be($before[2]); - expect($after.eq(3).text().trim()).to.match(/^bytes_formatted/); - }); - - it('handles two new columns at once', function () { - $root.columns.push('bytes'); - $root.columns.push('request_body'); - $root.$apply(); - - const $after = $row.find('td'); - expect($after).to.have.length(5); - expect($after[0]).to.be($before[0]); - expect($after[1]).to.be($before[1]); - expect($after[2]).to.be($before[2]); - expect($after.eq(3).text().trim()).to.match(/^bytes_formatted/); - expect($after.eq(4).text().trim()).to.match(/^request_body_formatted/); - }); - - it('handles three new columns in odd places', function () { - $root.columns = ['@timestamp', 'bytes', '_source', 'request_body']; - $root.$apply(); - - const $after = $row.find('td'); - expect($after).to.have.length(6); - expect($after[0]).to.be($before[0]); - expect($after[1]).to.be($before[1]); - expect($after.eq(2).text().trim()).to.match(/^@timestamp_formatted/); - expect($after.eq(3).text().trim()).to.match(/^bytes_formatted/); - expect($after[4]).to.be($before[2]); - expect($after.eq(5).text().trim()).to.match(/^request_body_formatted/); - }); - - it('handles a removed column', function () { - _.pull($root.columns, '_source'); - $root.$apply(); - - const $after = $row.find('td'); - expect($after).to.have.length(2); - expect($after[0]).to.be($before[0]); - expect($after[1]).to.be($before[1]); - }); - - it('handles two removed columns', function () { - // first add a column - $root.columns.push('@timestamp'); - $root.$apply(); - - const $mid = $row.find('td'); - expect($mid).to.have.length(4); - - $root.columns.pop(); - $root.columns.pop(); - $root.$apply(); - - const $after = $row.find('td'); - expect($after).to.have.length(2); - expect($after[0]).to.be($before[0]); - expect($after[1]).to.be($before[1]); - }); - - it('handles three removed random columns', function () { - // first add two column - $root.columns.push('@timestamp', 'bytes'); - $root.$apply(); - - const $mid = $row.find('td'); - expect($mid).to.have.length(5); - - $root.columns[0] = false; // _source - $root.columns[2] = false; // bytes - $root.columns = $root.columns.filter(Boolean); - $root.$apply(); - - const $after = $row.find('td'); - expect($after).to.have.length(3); - expect($after[0]).to.be($before[0]); - expect($after[1]).to.be($before[1]); - expect($after.eq(2).text().trim()).to.match(/^@timestamp_formatted/); - }); - - it('handles two columns with the same content', function () { - stubFieldFormatConverter($root, 'request_body', fakeRowVals.bytes); - - $root.columns.length = 0; - $root.columns.push('bytes'); - $root.columns.push('request_body'); - $root.$apply(); - - const $after = $row.find('td'); - expect($after).to.have.length(4); - expect($after.eq(2).text().trim()).to.match(/^bytes_formatted/); - expect($after.eq(3).text().trim()).to.match(/^bytes_formatted/); - }); - - it('handles two columns swapping position', function () { - $root.columns.push('bytes'); - $root.$apply(); - - const $mid = $row.find('td'); - expect($mid).to.have.length(4); - - $root.columns.reverse(); - $root.$apply(); - - const $after = $row.find('td'); - expect($after).to.have.length(4); - expect($after[0]).to.be($before[0]); - expect($after[1]).to.be($before[1]); - expect($after[2]).to.be($mid[3]); - expect($after[3]).to.be($mid[2]); - }); - - it('handles four columns all reversing position', function () { - $root.columns.push('bytes', 'response', '@timestamp'); - $root.$apply(); - - const $mid = $row.find('td'); - expect($mid).to.have.length(6); - - $root.columns.reverse(); - $root.$apply(); - - const $after = $row.find('td'); - expect($after).to.have.length(6); - expect($after[0]).to.be($before[0]); - expect($after[1]).to.be($before[1]); - expect($after[2]).to.be($mid[5]); - expect($after[3]).to.be($mid[4]); - expect($after[4]).to.be($mid[3]); - expect($after[5]).to.be($mid[2]); - }); - - it('handles multiple columns with the same name', function () { - $root.columns.push('bytes', 'bytes', 'bytes'); - $root.$apply(); - - const $after = $row.find('td'); - expect($after).to.have.length(6); - expect($after[0]).to.be($before[0]); - expect($after[1]).to.be($before[1]); - expect($after[2]).to.be($before[2]); - expect($after.eq(3).text().trim()).to.match(/^bytes_formatted/); - expect($after.eq(4).text().trim()).to.match(/^bytes_formatted/); - expect($after.eq(5).text().trim()).to.match(/^bytes_formatted/); - }); - }); -}); diff --git a/src/plugins/discover/public/application/angular/directives/fixed_scroll.test.js b/src/plugins/discover/public/application/angular/directives/fixed_scroll.test.js index 16293ca621e0..65255d6c0c4a 100644 --- a/src/plugins/discover/public/application/angular/directives/fixed_scroll.test.js +++ b/src/plugins/discover/public/application/angular/directives/fixed_scroll.test.js @@ -230,6 +230,10 @@ describe('FixedScroll directive', function () { $to = els[names.to]; }); + afterAll(() => { + delete angular.element.prototype.scrollLeft; + }); + test('transfers the scrollLeft', function () { expect(spyJQueryScrollLeft.callCount).toBe(0); expect(spyJQLiteScrollLeft.callCount).toBe(0); diff --git a/src/plugins/discover/public/application/angular/doc_table/components/row_headers.test.js b/src/plugins/discover/public/application/angular/doc_table/components/row_headers.test.js new file mode 100644 index 000000000000..b30b13b1f0b6 --- /dev/null +++ b/src/plugins/discover/public/application/angular/doc_table/components/row_headers.test.js @@ -0,0 +1,485 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import angular from 'angular'; +import 'angular-mocks'; +import 'angular-sanitize'; +import 'angular-route'; +import _ from 'lodash'; +import sinon from 'sinon'; +import { getFakeRow, getFakeRowVals } from 'fixtures/fake_row'; +import $ from 'jquery'; +import FixturesStubbedLogstashIndexPatternProvider from 'fixtures/stubbed_logstash_index_pattern'; +import { setScopedHistory, setServices, setDocViewsRegistry } from '../../../../kibana_services'; +import { coreMock } from '../../../../../../../core/public/mocks'; +import { dataPluginMock } from '../../../../../../data/public/mocks'; +import { navigationPluginMock } from '../../../../../../navigation/public/mocks'; +import { getInnerAngularModule } from '../../../../get_inner_angular'; +import { createBrowserHistory } from 'history'; + +describe('Doc Table', () => { + const core = coreMock.createStart(); + const dataMock = dataPluginMock.createStartContract(); + let $parentScope; + let $scope; + let $elementScope; + let timeout; + let registry = []; + + // Stub out a minimal mapping of 4 fields + let mapping; + + let fakeRowVals; + let stubFieldFormatConverter; + beforeAll(() => setScopedHistory(createBrowserHistory())); + beforeEach(() => { + angular.element.prototype.slice = jest.fn(function (index) { + return $(this).slice(index); + }); + angular.element.prototype.filter = jest.fn(function (condition) { + return $(this).filter(condition); + }); + angular.element.prototype.toggle = jest.fn(function (name) { + return $(this).toggle(name); + }); + angular.element.prototype.is = jest.fn(function (name) { + return $(this).is(name); + }); + setServices({ + uiSettings: core.uiSettings, + filterManager: dataMock.query.filterManager, + }); + + setDocViewsRegistry({ + addDocView(view) { + registry.push(view); + }, + getDocViewsSorted() { + return registry; + }, + resetRegistry: () => { + registry = []; + }, + }); + + getInnerAngularModule( + 'app/discover', + core, + { + data: dataMock, + navigation: navigationPluginMock.createStartContract(), + }, + coreMock.createPluginInitializerContext() + ); + angular.mock.module('app/discover'); + }); + beforeEach( + angular.mock.inject(function ($rootScope, Private, $timeout) { + $parentScope = $rootScope; + timeout = $timeout; + $parentScope.indexPattern = Private(FixturesStubbedLogstashIndexPatternProvider); + mapping = $parentScope.indexPattern.fields; + + // Stub `getConverterFor` for a field in the indexPattern to return mock data. + // Returns `val` if provided, otherwise generates fake data for the field. + fakeRowVals = getFakeRowVals('formatted', 0, mapping); + stubFieldFormatConverter = function ($root, field, val) { + const convertFn = (value, type, options) => { + if (val) { + return val; + } + const fieldName = _.get(options, 'field.name', null); + + return fakeRowVals[fieldName] || ''; + }; + + $root.indexPattern.fields.getByName(field).format.convert = convertFn; + $root.indexPattern.fields.getByName(field).format.getConverterFor = () => convertFn; + }; + }) + ); + + afterEach(() => { + delete angular.element.prototype.slice; + delete angular.element.prototype.filter; + delete angular.element.prototype.toggle; + delete angular.element.prototype.is; + }); + + // Sets up the directive, take an element, and a list of properties to attach to the parent scope. + const init = function ($elem, props) { + angular.mock.inject(function ($compile) { + _.assign($parentScope, props); + const el = $compile($elem)($parentScope); + $elementScope = el.scope(); + el.scope().$digest(); + $scope = el.isolateScope(); + }); + }; + + const destroy = () => { + $scope.$destroy(); + $parentScope.$destroy(); + }; + + // For testing column removing/adding for the header and the rows + const columnTests = function (elemType, parentElem) { + test('should create a time column if the timefield is defined', () => { + const childElems = parentElem.find(elemType); + expect(childElems.length).toBe(1); + }); + + test('should be able to add and remove columns', () => { + let childElems; + + stubFieldFormatConverter($parentScope, 'bytes'); + stubFieldFormatConverter($parentScope, 'request_body'); + + // Should include a column for toggling and the time column by default + $parentScope.columns = ['bytes']; + $elementScope.$digest(); + childElems = parentElem.find(elemType); + expect(childElems.length).toBe(2); + expect($(childElems[1]).text()).toContain('bytes'); + + $parentScope.columns = ['bytes', 'request_body']; + $elementScope.$digest(); + childElems = parentElem.find(elemType); + expect(childElems.length).toBe(3); + expect($(childElems[2]).text()).toContain('request_body'); + + $parentScope.columns = ['request_body']; + $elementScope.$digest(); + childElems = parentElem.find(elemType); + expect(childElems.length).toBe(2); + expect($(childElems[1]).text()).toContain('request_body'); + }); + + test('should create only the toggle column if there is no timeField', () => { + delete $scope.indexPattern.timeFieldName; + $scope.$digest(); + timeout.flush(); + + const childElems = parentElem.find(elemType); + expect(childElems.length).toBe(0); + }); + }; + + describe('kbnTableRow', () => { + const $elem = $( + '' + ); + let row; + + beforeEach(() => { + row = getFakeRow(0, mapping); + + init($elem, { + row, + columns: [], + sorting: [], + filter: sinon.spy(), + maxLength: 50, + }); + }); + afterEach(() => { + destroy(); + }); + + describe('adding and removing columns', () => { + columnTests('[data-test-subj~="docTableField"]', $elem); + }); + + describe('details row', () => { + test('should be an empty tr by default', () => { + expect($elem.next().is('tr')).toBe(true); + expect($elem.next().text()).toBe(''); + }); + + test('should expand the detail row when the toggle arrow is clicked', () => { + $elem.children(':first-child').click(); + expect($elem.next().text()).not.toBe(''); + }); + + describe('expanded', () => { + let $details; + beforeEach(() => { + // Open the row + $scope.toggleRow(); + timeout.flush(); + $details = $elem.next(); + }); + afterEach(() => { + // Close the row + $scope.toggleRow(); + }); + + test('should be a tr with something in it', () => { + expect($details.is('tr')).toBe(true); + expect($details.text()).toBeTruthy(); + }); + }); + }); + }); + + describe('kbnTableRow meta', () => { + const $elem = angular.element( + '' + ); + let row; + + beforeEach(() => { + row = getFakeRow(0, mapping); + + init($elem, { + row: row, + columns: [], + sorting: [], + filtering: sinon.spy(), + maxLength: 50, + }); + + // Open the row + $scope.toggleRow(); + $scope.$digest(); + timeout.flush(); + $elem.next(); + }); + + afterEach(() => { + destroy(); + }); + + /** this no longer works with the new plugin approach + test('should render even when the row source contains a field with the same name as a meta field', () => { + setTimeout(() => { + //this should be overridden by later changes + }, 100); + expect($details.find('tr').length).toBe(_.keys($parentScope.indexPattern.flattenHit($scope.row)).length); + }); */ + }); + + describe('row diffing', () => { + let $row; + let $scope; + let $root; + let $before; + + beforeEach( + angular.mock.inject(function ($rootScope, $compile, Private) { + $root = $rootScope; + $root.row = getFakeRow(0, mapping); + $root.columns = ['_source']; + $root.sorting = []; + $root.filtering = sinon.spy(); + $root.maxLength = 50; + $root.mapping = mapping; + $root.indexPattern = Private(FixturesStubbedLogstashIndexPatternProvider); + + // Stub field format converters for every field in the indexPattern + $root.indexPattern.fields.forEach((f) => stubFieldFormatConverter($root, f.name)); + + $row = $('').attr({ + 'kbn-table-row': 'row', + columns: 'columns', + sorting: 'sorting', + filtering: 'filtering', + 'index-pattern': 'indexPattern', + }); + + $scope = $root.$new(); + $compile($row)($scope); + $root.$apply(); + + $before = $row.find('td'); + expect($before).toHaveLength(3); + expect($before.eq(0).text().trim()).toBe(''); + expect($before.eq(1).text().trim()).toMatch(/^time_formatted/); + }) + ); + + afterEach(() => { + $row.remove(); + }); + + test('handles a new column', () => { + $root.columns.push('bytes'); + $root.$apply(); + + const $after = $row.find('td'); + expect($after).toHaveLength(4); + expect($after[0].outerHTML).toBe($before[0].outerHTML); + expect($after[1].outerHTML).toBe($before[1].outerHTML); + expect($after[2].outerHTML).toBe($before[2].outerHTML); + expect($after.eq(3).text().trim()).toMatch(/^bytes_formatted/); + }); + + test('handles two new columns at once', () => { + $root.columns.push('bytes'); + $root.columns.push('request_body'); + $root.$apply(); + + const $after = $row.find('td'); + expect($after).toHaveLength(5); + expect($after[0].outerHTML).toBe($before[0].outerHTML); + expect($after[1].outerHTML).toBe($before[1].outerHTML); + expect($after[2].outerHTML).toBe($before[2].outerHTML); + expect($after.eq(3).text().trim()).toMatch(/^bytes_formatted/); + expect($after.eq(4).text().trim()).toMatch(/^request_body_formatted/); + }); + + test('handles three new columns in odd places', () => { + $root.columns = ['@timestamp', 'bytes', '_source', 'request_body']; + $root.$apply(); + + const $after = $row.find('td'); + expect($after).toHaveLength(6); + expect($after[0].outerHTML).toBe($before[0].outerHTML); + expect($after[1].outerHTML).toBe($before[1].outerHTML); + expect($after.eq(2).text().trim()).toMatch(/^@timestamp_formatted/); + expect($after.eq(3).text().trim()).toMatch(/^bytes_formatted/); + expect($after[4].outerHTML).toBe($before[2].outerHTML); + expect($after.eq(5).text().trim()).toMatch(/^request_body_formatted/); + }); + + test('handles a removed column', () => { + _.pull($root.columns, '_source'); + $root.$apply(); + + const $after = $row.find('td'); + expect($after).toHaveLength(2); + expect($after[0].outerHTML).toBe($before[0].outerHTML); + expect($after[1].outerHTML).toBe($before[1].outerHTML); + }); + + test('handles two removed columns', () => { + // first add a column + $root.columns.push('@timestamp'); + $root.$apply(); + + const $mid = $row.find('td'); + expect($mid).toHaveLength(4); + + $root.columns.pop(); + $root.columns.pop(); + $root.$apply(); + + const $after = $row.find('td'); + expect($after).toHaveLength(2); + expect($after[0].outerHTML).toBe($before[0].outerHTML); + expect($after[1].outerHTML).toBe($before[1].outerHTML); + }); + + test('handles three removed random columns', () => { + // first add two column + $root.columns.push('@timestamp', 'bytes'); + $root.$apply(); + + const $mid = $row.find('td'); + expect($mid).toHaveLength(5); + + $root.columns[0] = false; // _source + $root.columns[2] = false; // bytes + $root.columns = $root.columns.filter(Boolean); + $root.$apply(); + + const $after = $row.find('td'); + expect($after).toHaveLength(3); + expect($after[0].outerHTML).toBe($before[0].outerHTML); + expect($after[1].outerHTML).toBe($before[1].outerHTML); + expect($after.eq(2).text().trim()).toMatch(/^@timestamp_formatted/); + }); + + test('handles two columns with the same content', () => { + stubFieldFormatConverter($root, 'request_body', fakeRowVals.bytes); + + $root.columns.length = 0; + $root.columns.push('bytes'); + $root.columns.push('request_body'); + $root.$apply(); + + const $after = $row.find('td'); + expect($after).toHaveLength(4); + expect($after.eq(2).text().trim()).toMatch(/^bytes_formatted/); + expect($after.eq(3).text().trim()).toMatch(/^bytes_formatted/); + }); + + test('handles two columns swapping position', () => { + $root.columns.push('bytes'); + $root.$apply(); + + const $mid = $row.find('td'); + expect($mid).toHaveLength(4); + + $root.columns.reverse(); + $root.$apply(); + + const $after = $row.find('td'); + expect($after).toHaveLength(4); + expect($after[0].outerHTML).toBe($before[0].outerHTML); + expect($after[1].outerHTML).toBe($before[1].outerHTML); + expect($after[2].outerHTML).toBe($mid[3].outerHTML); + expect($after[3].outerHTML).toBe($mid[2].outerHTML); + }); + + test('handles four columns all reversing position', () => { + $root.columns.push('bytes', 'response', '@timestamp'); + $root.$apply(); + + const $mid = $row.find('td'); + expect($mid).toHaveLength(6); + + $root.columns.reverse(); + $root.$apply(); + + const $after = $row.find('td'); + expect($after).toHaveLength(6); + expect($after[0].outerHTML).toBe($before[0].outerHTML); + expect($after[1].outerHTML).toBe($before[1].outerHTML); + expect($after[2].outerHTML).toBe($mid[5].outerHTML); + expect($after[3].outerHTML).toBe($mid[4].outerHTML); + expect($after[4].outerHTML).toBe($mid[3].outerHTML); + expect($after[5].outerHTML).toBe($mid[2].outerHTML); + }); + + test('handles multiple columns with the same name', () => { + $root.columns.push('bytes', 'bytes', 'bytes'); + $root.$apply(); + + const $after = $row.find('td'); + expect($after).toHaveLength(6); + expect($after[0].outerHTML).toBe($before[0].outerHTML); + expect($after[1].outerHTML).toBe($before[1].outerHTML); + expect($after[2].outerHTML).toBe($before[2].outerHTML); + expect($after.eq(3).text().trim()).toMatch(/^bytes_formatted/); + expect($after.eq(4).text().trim()).toMatch(/^bytes_formatted/); + expect($after.eq(5).text().trim()).toMatch(/^bytes_formatted/); + }); + }); +}); diff --git a/src/legacy/core_plugins/kibana/public/__tests__/discover/doc_table.js b/src/plugins/discover/public/application/angular/doc_table/doc_table.test.js similarity index 52% rename from src/legacy/core_plugins/kibana/public/__tests__/discover/doc_table.js rename to src/plugins/discover/public/application/angular/doc_table/doc_table.test.js index 504b00808718..9722981df42b 100644 --- a/src/legacy/core_plugins/kibana/public/__tests__/discover/doc_table.js +++ b/src/plugins/discover/public/application/angular/doc_table/doc_table.test.js @@ -17,15 +17,18 @@ * under the License. */ import angular from 'angular'; -import expect from '@kbn/expect'; import _ from 'lodash'; -import ngMock from 'ng_mock'; -import 'ui/private'; -import { pluginInstance } from './legacy'; +import 'angular-mocks'; +import 'angular-sanitize'; +import 'angular-route'; +import { createBrowserHistory } from 'history'; import FixturesStubbedLogstashIndexPatternProvider from 'fixtures/stubbed_logstash_index_pattern'; import hits from 'fixtures/real_hits'; -import { setScopedHistory } from '../../../../../../plugins/discover/public/kibana_services'; -import { createBrowserHistory } from 'history'; +import { coreMock } from '../../../../../../core/public/mocks'; +import { dataPluginMock } from '../../../../../data/public/mocks'; +import { navigationPluginMock } from '../../../../../navigation/public/mocks'; +import { setScopedHistory, setServices } from '../../../kibana_services'; +import { getInnerAngularModule } from '../../../get_inner_angular'; let $parentScope; @@ -36,7 +39,7 @@ let $timeout; let indexPattern; const init = function ($elem, props) { - ngMock.inject(function ($rootScope, $compile, _$timeout_) { + angular.mock.inject(function ($rootScope, $compile, _$timeout_) { $timeout = _$timeout_; $parentScope = $rootScope; _.assign($parentScope, props); @@ -44,7 +47,7 @@ const init = function ($elem, props) { $compile($elem)($parentScope); // I think the prereq requires this? - $timeout(function () { + $timeout(() => { $elem.scope().$digest(); }, 0); @@ -52,19 +55,40 @@ const init = function ($elem, props) { }); }; -const destroy = function () { +const destroy = () => { $scope.$destroy(); $parentScope.$destroy(); }; -describe('docTable', function () { +describe('docTable', () => { + const core = coreMock.createStart(); let $elem; - before(() => setScopedHistory(createBrowserHistory())); - beforeEach(() => pluginInstance.initializeInnerAngular()); - beforeEach(() => pluginInstance.initializeServices()); - beforeEach(ngMock.module('app/discover')); - beforeEach(function () { + beforeAll(() => setScopedHistory(createBrowserHistory())); + beforeEach(() => { + angular.element.prototype.slice = jest.fn(() => { + return null; + }); + angular.element.prototype.filter = jest.fn(() => { + return { + remove: jest.fn(), + }; + }); + setServices({ + uiSettings: core.uiSettings, + }); + getInnerAngularModule( + 'app/discover', + core, + { + data: dataPluginMock.createStartContract(), + navigation: navigationPluginMock.createStartContract(), + }, + coreMock.createPluginInitializerContext() + ); + angular.mock.module('app/discover'); + }); + beforeEach(() => { $elem = angular.element(` `); - ngMock.inject(function (Private) { + angular.mock.inject(function (Private) { indexPattern = Private(FixturesStubbedLogstashIndexPatternProvider); }); init($elem, { @@ -87,34 +111,36 @@ describe('docTable', function () { $scope.$digest(); }); - afterEach(function () { + afterEach(() => { + delete angular.element.prototype.slice; + delete angular.element.prototype.filter; destroy(); }); - it('should compile', function () { - expect($elem.text()).to.not.be.empty(); + test('should compile', () => { + expect($elem.text()).toBeTruthy(); }); - it('should have an addRows function that increases the row count', function () { - expect($scope.addRows).to.be.a(Function); + test('should have an addRows function that increases the row count', () => { + expect($scope.addRows).toBeInstanceOf(Function); $scope.$digest(); - expect($scope.limit).to.be(50); + expect($scope.limit).toBe(50); $scope.addRows(); - expect($scope.limit).to.be(100); + expect($scope.limit).toBe(100); }); - it('should reset the row limit when results are received', function () { + test('should reset the row limit when results are received', () => { $scope.limit = 100; - expect($scope.limit).to.be(100); + expect($scope.limit).toBe(100); $scope.hits = [...hits]; $scope.$digest(); - expect($scope.limit).to.be(50); + expect($scope.limit).toBe(50); }); - it('should have a header and a table element', function () { + test('should have a header and a table element', () => { $scope.$digest(); - expect($elem.find('thead').length).to.be(1); - expect($elem.find('table').length).to.be(1); + expect($elem.find('thead').length).toBe(1); + expect($elem.find('table').length).toBe(1); }); }); From 8f8cba50135e6d692add3bc7aa160bbddfdd4c81 Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Thu, 23 Jul 2020 04:36:52 -0700 Subject: [PATCH 086/142] =?UTF-8?q?fix:=20=F0=9F=90=9B=20don't=20show=20ac?= =?UTF-8?q?tion=20in=20dashboard=5Fonly=20mode=20(#73010)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- x-pack/plugins/discover_enhanced/kibana.json | 2 +- .../abstract_explore_data_action.ts | 12 ++++++++++++ .../explore_data_chart_action.test.ts | 17 ++++++++++++++++- .../explore_data_context_menu_action.test.ts | 14 +++++++++++++- .../plugins/discover_enhanced/public/plugin.ts | 3 +++ 5 files changed, 45 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/discover_enhanced/kibana.json b/x-pack/plugins/discover_enhanced/kibana.json index fbd04fe00968..531a84cd4c0e 100644 --- a/x-pack/plugins/discover_enhanced/kibana.json +++ b/x-pack/plugins/discover_enhanced/kibana.json @@ -5,7 +5,7 @@ "server": true, "ui": true, "requiredPlugins": ["uiActions", "embeddable", "discover"], - "optionalPlugins": ["share"], + "optionalPlugins": ["share", "kibanaLegacy"], "configPath": ["xpack", "discoverEnhanced"], "requiredBundles": ["kibanaUtils", "data"] } diff --git a/x-pack/plugins/discover_enhanced/public/actions/explore_data/abstract_explore_data_action.ts b/x-pack/plugins/discover_enhanced/public/actions/explore_data/abstract_explore_data_action.ts index 59359fb35f54..3aec0ce238c3 100644 --- a/x-pack/plugins/discover_enhanced/public/actions/explore_data/abstract_explore_data_action.ts +++ b/x-pack/plugins/discover_enhanced/public/actions/explore_data/abstract_explore_data_action.ts @@ -9,6 +9,7 @@ import { DiscoverStart } from '../../../../../../src/plugins/discover/public'; import { EmbeddableStart } from '../../../../../../src/plugins/embeddable/public'; import { ViewMode, IEmbeddable } from '../../../../../../src/plugins/embeddable/public'; import { StartServicesGetter } from '../../../../../../src/plugins/kibana_utils/public'; +import { KibanaLegacyStart } from '../../../../../../src/plugins/kibana_legacy/public'; import { CoreStart } from '../../../../../../src/core/public'; import { KibanaURL } from './kibana_url'; import * as shared from './shared'; @@ -18,6 +19,11 @@ export const ACTION_EXPLORE_DATA = 'ACTION_EXPLORE_DATA'; export interface PluginDeps { discover: Pick; embeddable: Pick; + kibanaLegacy?: { + dashboardConfig: { + getHideWriteControls: KibanaLegacyStart['dashboardConfig']['getHideWriteControls']; + }; + }; } export interface CoreDeps { @@ -42,6 +48,12 @@ export abstract class AbstractExploreDataAction { if (!embeddable) return false; + + const isDashboardOnlyMode = !!this.params + .start() + .plugins.kibanaLegacy?.dashboardConfig.getHideWriteControls(); + if (isDashboardOnlyMode) return false; + if (!this.params.start().plugins.discover.urlGenerator) return false; if (!shared.hasExactlyOneIndexPattern(embeddable)) return false; if (embeddable.getInput().viewMode !== ViewMode.VIEW) return false; diff --git a/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_chart_action.test.ts b/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_chart_action.test.ts index 0d22f0a36d41..6c3ed7a2fe77 100644 --- a/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_chart_action.test.ts +++ b/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_chart_action.test.ts @@ -34,7 +34,10 @@ afterEach(() => { i18nTranslateSpy.mockClear(); }); -const setup = ({ useRangeEvent = false }: { useRangeEvent?: boolean } = {}) => { +const setup = ({ + useRangeEvent = false, + dashboardOnlyMode = false, +}: { useRangeEvent?: boolean; dashboardOnlyMode?: boolean } = {}) => { type UrlGenerator = UrlGeneratorContract<'DISCOVER_APP_URL_GENERATOR'>; const core = coreMock.createStart(); @@ -54,6 +57,11 @@ const setup = ({ useRangeEvent = false }: { useRangeEvent?: boolean } = {}) => { embeddable: { filtersAndTimeRangeFromContext, }, + kibanaLegacy: { + dashboardConfig: { + getHideWriteControls: () => dashboardOnlyMode, + }, + }, }; const params: Params = { @@ -181,6 +189,13 @@ describe('"Explore underlying data" panel action', () => { expect(isCompatible).toBe(false); }); + + test('return false for dashboard_only mode', async () => { + const { action, context } = setup({ dashboardOnlyMode: true }); + const isCompatible = await action.isCompatible(context); + + expect(isCompatible).toBe(false); + }); }); describe('getHref()', () => { diff --git a/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_context_menu_action.test.ts b/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_context_menu_action.test.ts index c362e554e96c..1422cc871cde 100644 --- a/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_context_menu_action.test.ts +++ b/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_context_menu_action.test.ts @@ -28,7 +28,7 @@ afterEach(() => { i18nTranslateSpy.mockClear(); }); -const setup = () => { +const setup = ({ dashboardOnlyMode = false }: { dashboardOnlyMode?: boolean } = {}) => { type UrlGenerator = UrlGeneratorContract<'DISCOVER_APP_URL_GENERATOR'>; const core = coreMock.createStart(); @@ -48,6 +48,11 @@ const setup = () => { embeddable: { filtersAndTimeRangeFromContext, }, + kibanaLegacy: { + dashboardConfig: { + getHideWriteControls: () => dashboardOnlyMode, + }, + }, }; const params: Params = { @@ -167,6 +172,13 @@ describe('"Explore underlying data" panel action', () => { expect(isCompatible).toBe(false); }); + + test('return false for dashboard_only mode', async () => { + const { action, context } = setup({ dashboardOnlyMode: true }); + const isCompatible = await action.isCompatible(context); + + expect(isCompatible).toBe(false); + }); }); describe('getHref()', () => { diff --git a/x-pack/plugins/discover_enhanced/public/plugin.ts b/x-pack/plugins/discover_enhanced/public/plugin.ts index 9613a9a8e3c8..4b018354aa09 100644 --- a/x-pack/plugins/discover_enhanced/public/plugin.ts +++ b/x-pack/plugins/discover_enhanced/public/plugin.ts @@ -15,6 +15,7 @@ import { import { createStartServicesGetter } from '../../../../src/plugins/kibana_utils/public'; import { DiscoverSetup, DiscoverStart } from '../../../../src/plugins/discover/public'; import { SharePluginSetup, SharePluginStart } from '../../../../src/plugins/share/public'; +import { KibanaLegacySetup, KibanaLegacyStart } from '../../../../src/plugins/kibana_legacy/public'; import { EmbeddableSetup, EmbeddableStart, @@ -39,6 +40,7 @@ declare module '../../../../src/plugins/ui_actions/public' { export interface DiscoverEnhancedSetupDependencies { discover: DiscoverSetup; embeddable: EmbeddableSetup; + kibanaLegacy?: KibanaLegacySetup; share?: SharePluginSetup; uiActions: UiActionsSetup; } @@ -46,6 +48,7 @@ export interface DiscoverEnhancedSetupDependencies { export interface DiscoverEnhancedStartDependencies { discover: DiscoverStart; embeddable: EmbeddableStart; + kibanaLegacy?: KibanaLegacyStart; share?: SharePluginStart; uiActions: UiActionsStart; } From 5f6b9353e734e6da39fbbe34548700ae4025eed0 Mon Sep 17 00:00:00 2001 From: Kevin Logan <56395104+kevinlog@users.noreply.github.com> Date: Thu, 23 Jul 2020 07:38:27 -0400 Subject: [PATCH 087/142] [SECURITY_SOLUTION] update Elastic Endpoint text in rules (#72613) --- x-pack/plugins/lists/common/constants.ts | 4 +- ...collection_cloudtrail_logging_created.json | 1 + ...l_access_attempted_bypass_of_okta_mfa.json | 1 + ...ccess_aws_iam_assume_role_brute_force.json | 49 ++++++++++++++++++ ...ial_access_iam_user_addition_to_group.json | 1 + ...okta_brute_force_or_password_spraying.json | 51 +++++++++++++++++++ ..._access_secretsmanager_getsecretvalue.json | 1 + ...se_evasion_cloudtrail_logging_deleted.json | 1 + ..._evasion_cloudtrail_logging_suspended.json | 1 + ...nse_evasion_cloudwatch_alarm_deletion.json | 1 + ..._evasion_config_service_rule_deletion.json | 1 + ...vasion_configuration_recorder_stopped.json | 1 + ...defense_evasion_ec2_flow_log_deletion.json | 1 + ...ense_evasion_ec2_network_acl_deletion.json | 1 + ...e_evasion_guardduty_detector_deletion.json | 1 + ...sion_s3_bucket_configuration_deletion.json | 1 + .../defense_evasion_waf_acl_deletion.json | 1 + ...asion_waf_rule_or_rule_group_deletion.json | 1 + .../prepackaged_rules/elastic_endpoint.json | 4 +- .../endpoint_adversary_behavior_detected.json | 4 +- .../endpoint_cred_dumping_detected.json | 4 +- .../endpoint_cred_dumping_prevented.json | 4 +- .../endpoint_cred_manipulation_detected.json | 4 +- .../endpoint_cred_manipulation_prevented.json | 4 +- .../endpoint_exploit_detected.json | 4 +- .../endpoint_exploit_prevented.json | 4 +- .../endpoint_malware_detected.json | 4 +- .../endpoint_malware_prevented.json | 4 +- .../endpoint_permission_theft_detected.json | 4 +- .../endpoint_permission_theft_prevented.json | 4 +- .../endpoint_process_injection_detected.json | 4 +- .../endpoint_process_injection_prevented.json | 4 +- .../endpoint_ransomware_detected.json | 4 +- .../endpoint_ransomware_prevented.json | 4 +- .../execution_via_system_manager.json | 1 + ...ltration_ec2_snapshot_change_activity.json | 1 + .../prepackaged_rules/external_alerts.json | 11 +++- ...pact_attempt_to_revoke_okta_api_token.json | 1 + .../impact_cloudtrail_logging_updated.json | 1 + .../impact_cloudwatch_log_group_deletion.json | 1 + ...impact_cloudwatch_log_stream_deletion.json | 1 + .../impact_ec2_disable_ebs_encryption.json | 1 + .../impact_iam_deactivate_mfa_device.json | 1 + .../impact_iam_group_deletion.json | 1 + .../impact_possible_okta_dos_attack.json | 1 + .../impact_rds_cluster_deletion.json | 1 + .../impact_rds_instance_cluster_stoppage.json | 1 + .../rules/prepackaged_rules/index.ts | 4 ++ .../initial_access_console_login_root.json | 1 + .../initial_access_password_recovery.json | 1 + ...icious_activity_reported_by_okta_user.json | 1 + ...a_attempt_to_deactivate_okta_mfa_rule.json | 1 + .../okta_attempt_to_delete_okta_policy.json | 1 + .../okta_attempt_to_modify_okta_mfa_rule.json | 1 + ...a_attempt_to_modify_okta_network_zone.json | 1 + .../okta_attempt_to_modify_okta_policy.json | 1 + ..._or_delete_application_sign_on_policy.json | 1 + ...threat_detected_by_okta_threatinsight.json | 1 + ...tor_privileges_assigned_to_okta_group.json | 1 + ...ence_attempt_to_create_okta_api_token.json | 1 + ..._deactivate_mfa_for_okta_user_account.json | 1 + ...nce_attempt_to_deactivate_okta_policy.json | 1 + ...set_mfa_factors_for_okta_user_account.json | 1 + .../persistence_ec2_network_acl_creation.json | 1 + .../persistence_iam_group_creation.json | 1 + .../persistence_rds_cluster_creation.json | 1 + ...ege_escalation_root_login_without_mfa.json | 1 + ...ege_escalation_updateassumerolepolicy.json | 1 + 68 files changed, 195 insertions(+), 35 deletions(-) create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_aws_iam_assume_role_brute_force.json create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_okta_brute_force_or_password_spraying.json diff --git a/x-pack/plugins/lists/common/constants.ts b/x-pack/plugins/lists/common/constants.ts index 7bb83cddd433..df16085b5340 100644 --- a/x-pack/plugins/lists/common/constants.ts +++ b/x-pack/plugins/lists/common/constants.ts @@ -44,7 +44,7 @@ export const ENDPOINT_LIST_ITEM_URL = '/api/endpoint_list/items'; export const ENDPOINT_LIST_ID = 'endpoint_list'; /** The name of the single global space agnostic endpoint list */ -export const ENDPOINT_LIST_NAME = 'Elastic Endpoint Exception List'; +export const ENDPOINT_LIST_NAME = 'Elastic Endpoint Security Exception List'; /** The description of the single global space agnostic endpoint list */ -export const ENDPOINT_LIST_DESCRIPTION = 'Elastic Endpoint Exception List'; +export const ENDPOINT_LIST_DESCRIPTION = 'Elastic Endpoint Security Exception List'; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/collection_cloudtrail_logging_created.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/collection_cloudtrail_logging_created.json index 4437612a5056..ee39661ee9b1 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/collection_cloudtrail_logging_created.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/collection_cloudtrail_logging_created.json @@ -14,6 +14,7 @@ "language": "kuery", "license": "Elastic License", "name": "AWS CloudTrail Log Created", + "note": "The AWS Filebeat module must be enabled to use this rule.", "query": "event.action:CreateTrail and event.dataset:aws.cloudtrail and event.provider:cloudtrail.amazonaws.com and event.outcome:success", "references": [ "https://docs.aws.amazon.com/awscloudtrail/latest/APIReference/API_CreateTrail.html", diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_attempted_bypass_of_okta_mfa.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_attempted_bypass_of_okta_mfa.json index e3e4b7b54c3b..eb8523b797dd 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_attempted_bypass_of_okta_mfa.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_attempted_bypass_of_okta_mfa.json @@ -9,6 +9,7 @@ "language": "kuery", "license": "Elastic License", "name": "Attempted Bypass of Okta MFA", + "note": "The Okta Filebeat module must be enabled to use this rule.", "query": "event.module:okta and event.dataset:okta.system and event.action:user.mfa.attempt_bypass", "references": [ "https://developer.okta.com/docs/reference/api/system-log/", diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_aws_iam_assume_role_brute_force.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_aws_iam_assume_role_brute_force.json new file mode 100644 index 000000000000..ddc9e9178213 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_aws_iam_assume_role_brute_force.json @@ -0,0 +1,49 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies a high number of failed attempts to assume an AWS Identity and Access Management (IAM) role. IAM roles are used to delegate access to users or services. An adversary may attempt to enumerate IAM roles in order to determine if a role exists before attempting to assume or hijack the discovered role.", + "from": "now-20m", + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "AWS IAM Brute Force of Assume Role Policy", + "note": "The AWS Filebeat module must be enabled to use this rule.", + "query": "event.module:aws and event.dataset:aws.cloudtrail and event.provider:iam.amazonaws.com and event.action:UpdateAssumeRolePolicy and aws.cloudtrail.error_code:MalformedPolicyDocumentException and event.outcome:failure", + "references": [ + "https://www.praetorian.com/blog/aws-iam-assume-role-vulnerabilities", + "https://rhinosecuritylabs.com/aws/assume-worst-aws-assume-role-enumeration/" + ], + "risk_score": 47, + "rule_id": "ea248a02-bc47-4043-8e94-2885b19b2636", + "severity": "medium", + "tags": [ + "AWS", + "Elastic" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0006", + "name": "Credential Access", + "reference": "https://attack.mitre.org/tactics/TA0006/" + }, + "technique": [ + { + "id": "T1110", + "name": "Brute Force", + "reference": "https://attack.mitre.org/techniques/T1110/" + } + ] + } + ], + "threshold": { + "field": "", + "value": 25 + }, + "type": "threshold", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_iam_user_addition_to_group.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_iam_user_addition_to_group.json index 1e268d2f6bf0..ecbf268550b6 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_iam_user_addition_to_group.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_iam_user_addition_to_group.json @@ -14,6 +14,7 @@ "language": "kuery", "license": "Elastic License", "name": "AWS IAM User Addition to Group", + "note": "The AWS Filebeat module must be enabled to use this rule.", "query": "event.action:AddUserToGroup and event.dataset:aws.cloudtrail and event.provider:iam.amazonaws.com and event.outcome:success", "references": [ "https://docs.aws.amazon.com/IAM/latest/APIReference/API_AddUserToGroup.html" diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_okta_brute_force_or_password_spraying.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_okta_brute_force_or_password_spraying.json new file mode 100644 index 000000000000..87f20525203f --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_okta_brute_force_or_password_spraying.json @@ -0,0 +1,51 @@ +{ + "author": [ + "Elastic" + ], + "description": "Identifies a high number of failed Okta user authentication attempts from a single IP address, which could be indicative of a brute force or password spraying attack. An adversary may attempt a brute force or password spraying attack to obtain unauthorized access to user accounts.", + "false_positives": [ + "Automated processes that attempt to authenticate using expired credentials and unbounded retries may lead to false positives." + ], + "index": [ + "filebeat-*" + ], + "language": "kuery", + "license": "Elastic License", + "name": "Okta Brute Force or Password Spraying Attack", + "note": "The Okta Filebeat module must be enabled to use this rule.", + "query": "event.module:okta and event.dataset:okta.system and event.category:authentication and event.outcome:failure", + "references": [ + "https://developer.okta.com/docs/reference/api/system-log/", + "https://developer.okta.com/docs/reference/api/event-types/" + ], + "risk_score": 47, + "rule_id": "42bf698b-4738-445b-8231-c834ddefd8a0", + "severity": "medium", + "tags": [ + "Elastic", + "Okta" + ], + "threat": [ + { + "framework": "MITRE ATT&CK", + "tactic": { + "id": "TA0006", + "name": "Credential Access", + "reference": "https://attack.mitre.org/tactics/TA0006/" + }, + "technique": [ + { + "id": "T1110", + "name": "Brute Force", + "reference": "https://attack.mitre.org/techniques/T1110/" + } + ] + } + ], + "threshold": { + "field": "source.ip", + "value": 25 + }, + "type": "threshold", + "version": 1 +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_secretsmanager_getsecretvalue.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_secretsmanager_getsecretvalue.json index 740805f71a3c..f570b7fb3e94 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_secretsmanager_getsecretvalue.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/credential_access_secretsmanager_getsecretvalue.json @@ -15,6 +15,7 @@ "language": "kuery", "license": "Elastic License", "name": "AWS Access Secret in Secrets Manager", + "note": "The AWS Filebeat module must be enabled to use this rule.", "query": "event.dataset:aws.cloudtrail and event.provider:secretsmanager.amazonaws.com and event.action:GetSecretValue", "references": [ "https://docs.aws.amazon.com/secretsmanager/latest/apireference/API_GetSecretValue.html", diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_cloudtrail_logging_deleted.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_cloudtrail_logging_deleted.json index 2a74b8fecd80..78f4c9e853f6 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_cloudtrail_logging_deleted.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_cloudtrail_logging_deleted.json @@ -14,6 +14,7 @@ "language": "kuery", "license": "Elastic License", "name": "AWS CloudTrail Log Deleted", + "note": "The AWS Filebeat module must be enabled to use this rule.", "query": "event.action:DeleteTrail and event.dataset:aws.cloudtrail and event.provider:cloudtrail.amazonaws.com and event.outcome:success", "references": [ "https://docs.aws.amazon.com/awscloudtrail/latest/APIReference/API_DeleteTrail.html", diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_cloudtrail_logging_suspended.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_cloudtrail_logging_suspended.json index 5d6c1a93bab1..f412ad9b2e2f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_cloudtrail_logging_suspended.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_cloudtrail_logging_suspended.json @@ -14,6 +14,7 @@ "language": "kuery", "license": "Elastic License", "name": "AWS CloudTrail Log Suspended", + "note": "The AWS Filebeat module must be enabled to use this rule.", "query": "event.action:StopLogging and event.dataset:aws.cloudtrail and event.provider:cloudtrail.amazonaws.com and event.outcome:success", "references": [ "https://docs.aws.amazon.com/awscloudtrail/latest/APIReference/API_StopLogging.html", diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_cloudwatch_alarm_deletion.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_cloudwatch_alarm_deletion.json index 9ac45ba87280..b76ea0944f85 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_cloudwatch_alarm_deletion.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_cloudwatch_alarm_deletion.json @@ -14,6 +14,7 @@ "language": "kuery", "license": "Elastic License", "name": "AWS CloudWatch Alarm Deletion", + "note": "The AWS Filebeat module must be enabled to use this rule.", "query": "event.action:DeleteAlarms and event.dataset:aws.cloudtrail and event.provider:monitoring.amazonaws.com and event.outcome:success", "references": [ "https://awscli.amazonaws.com/v2/documentation/api/latest/reference/cloudwatch/delete-alarms.html", diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_config_service_rule_deletion.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_config_service_rule_deletion.json index 9ef37bd4e44e..353067e6db83 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_config_service_rule_deletion.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_config_service_rule_deletion.json @@ -14,6 +14,7 @@ "language": "kuery", "license": "Elastic License", "name": "AWS Config Service Tampering", + "note": "The AWS Filebeat module must be enabled to use this rule.", "query": "event.dataset: aws.cloudtrail and event.action: DeleteConfigRule and event.provider: config.amazonaws.com", "references": [ "https://docs.aws.amazon.com/config/latest/developerguide/how-does-config-work.html", diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_configuration_recorder_stopped.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_configuration_recorder_stopped.json index 0aed7aa5ad0c..b70aa5cd11b5 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_configuration_recorder_stopped.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_configuration_recorder_stopped.json @@ -14,6 +14,7 @@ "language": "kuery", "license": "Elastic License", "name": "AWS Configuration Recorder Stopped", + "note": "The AWS Filebeat module must be enabled to use this rule.", "query": "event.action:StopConfigurationRecorder and event.dataset:aws.cloudtrail and event.provider:config.amazonaws.com and event.outcome:success", "references": [ "https://awscli.amazonaws.com/v2/documentation/api/latest/reference/configservice/stop-configuration-recorder.html", diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_ec2_flow_log_deletion.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_ec2_flow_log_deletion.json index b1f6c42f6f61..a1b0ec0f01d2 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_ec2_flow_log_deletion.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_ec2_flow_log_deletion.json @@ -14,6 +14,7 @@ "language": "kuery", "license": "Elastic License", "name": "AWS EC2 Flow Log Deletion", + "note": "The AWS Filebeat module must be enabled to use this rule.", "query": "event.action:DeleteFlowLogs and event.dataset:aws.cloudtrail and event.provider:ec2.amazonaws.com and event.outcome:success", "references": [ "https://awscli.amazonaws.com/v2/documentation/api/latest/reference/ec2/delete-flow-logs.html", diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_ec2_network_acl_deletion.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_ec2_network_acl_deletion.json index 7dc4e33afcd3..21ce4e498cca 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_ec2_network_acl_deletion.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_ec2_network_acl_deletion.json @@ -14,6 +14,7 @@ "language": "kuery", "license": "Elastic License", "name": "AWS EC2 Network Access Control List Deletion", + "note": "The AWS Filebeat module must be enabled to use this rule.", "query": "event.action:(DeleteNetworkAcl or DeleteNetworkAclEntry) and event.dataset:aws.cloudtrail and event.provider:ec2.amazonaws.com and event.outcome:success", "references": [ "https://awscli.amazonaws.com/v2/documentation/api/latest/reference/ec2/delete-network-acl.html", diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_guardduty_detector_deletion.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_guardduty_detector_deletion.json index c456396c85cd..989eff90aaf0 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_guardduty_detector_deletion.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_guardduty_detector_deletion.json @@ -14,6 +14,7 @@ "language": "kuery", "license": "Elastic License", "name": "AWS GuardDuty Detector Deletion", + "note": "The AWS Filebeat module must be enabled to use this rule.", "query": "event.action:DeleteDetector and event.dataset:aws.cloudtrail and event.provider:guardduty.amazonaws.com and event.outcome:success", "references": [ "https://awscli.amazonaws.com/v2/documentation/api/latest/reference/guardduty/delete-detector.html", diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_s3_bucket_configuration_deletion.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_s3_bucket_configuration_deletion.json index 77f9e0f4a313..b1e8d0cd0d3e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_s3_bucket_configuration_deletion.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_s3_bucket_configuration_deletion.json @@ -14,6 +14,7 @@ "language": "kuery", "license": "Elastic License", "name": "AWS S3 Bucket Configuration Deletion", + "note": "The AWS Filebeat module must be enabled to use this rule.", "query": "event.action:(DeleteBucketPolicy or DeleteBucketReplication or DeleteBucketCors or DeleteBucketEncryption or DeleteBucketLifecycle) and event.dataset:aws.cloudtrail and event.provider:s3.amazonaws.com and event.outcome:success", "references": [ "https://docs.aws.amazon.com/AmazonS3/latest/API/API_DeleteBucketPolicy.html", diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_waf_acl_deletion.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_waf_acl_deletion.json index 708f931a5f8a..b2092dc78b01 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_waf_acl_deletion.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_waf_acl_deletion.json @@ -14,6 +14,7 @@ "language": "kuery", "license": "Elastic License", "name": "AWS WAF Access Control List Deletion", + "note": "The AWS Filebeat module must be enabled to use this rule.", "query": "event.action:DeleteWebACL and event.dataset:aws.cloudtrail and event.outcome:success", "references": [ "https://awscli.amazonaws.com/v2/documentation/api/latest/reference/waf-regional/delete-web-acl.html", diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_waf_rule_or_rule_group_deletion.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_waf_rule_or_rule_group_deletion.json index 37dae51ec312..ccec76b7f797 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_waf_rule_or_rule_group_deletion.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/defense_evasion_waf_rule_or_rule_group_deletion.json @@ -14,6 +14,7 @@ "language": "kuery", "license": "Elastic License", "name": "AWS WAF Rule or Rule Group Deletion", + "note": "The AWS Filebeat module must be enabled to use this rule.", "query": "event.module:aws and event.dataset:aws.cloudtrail and event.action:(DeleteRule or DeleteRuleGroup) and event.outcome:success", "references": [ "https://awscli.amazonaws.com/v2/documentation/api/latest/reference/waf/delete-rule-group.html", diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint.json index 396803086552..e6a517d85db8 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint.json @@ -2,7 +2,7 @@ "author": [ "Elastic" ], - "description": "Generates a detection alert each time an Elastic Endpoint alert is received. Enabling this rule allows you to immediately begin investigating your Elastic Endpoint alerts.", + "description": "Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Elastic Endpoint alerts.", "enabled": true, "exceptions_list": [ { @@ -18,7 +18,7 @@ "language": "kuery", "license": "Elastic License", "max_signals": 10000, - "name": "Elastic Endpoint", + "name": "Elastic Endpoint Security", "query": "event.kind:alert and event.module:(endpoint and not endgame)", "risk_score": 47, "risk_score_mapping": [ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_adversary_behavior_detected.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_adversary_behavior_detected.json index 5075630e24f2..16584a03a3c9 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_adversary_behavior_detected.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_adversary_behavior_detected.json @@ -2,7 +2,7 @@ "author": [ "Elastic" ], - "description": "Elastic Endpoint detected an Adversary Behavior. Click the Elastic Endpoint icon in the event.module column or the link in the rule.reference column in the External Alerts tab of the SIEM Detections page for additional information.", + "description": "Elastic Endpoint Security detected an Adversary Behavior. Click the Elastic Endpoint Security icon in the event.module column or the link in the rule.reference column for additional information.", "from": "now-15m", "index": [ "endgame-*" @@ -10,7 +10,7 @@ "interval": "10m", "language": "kuery", "license": "Elastic License", - "name": "Adversary Behavior - Detected - Elastic Endpoint", + "name": "Adversary Behavior - Detected - Elastic Endpoint Security", "query": "event.kind:alert and event.module:endgame and (event.action:rules_engine_event or endgame.event_subtype_full:rules_engine_event)", "risk_score": 47, "rule_id": "77a3c3df-8ec4-4da4-b758-878f551dee69", diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_cred_dumping_detected.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_cred_dumping_detected.json index 4bf9ba8ec36e..5717c490114b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_cred_dumping_detected.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_cred_dumping_detected.json @@ -2,7 +2,7 @@ "author": [ "Elastic" ], - "description": "Elastic Endpoint detected Credential Dumping. Click the Elastic Endpoint icon in the event.module column or the link in the rule.reference column in the External Alerts tab of the SIEM Detections page for additional information.", + "description": "Elastic Endpoint Security detected Credential Dumping. Click the Elastic Endpoint Security icon in the event.module column or the link in the rule.reference column for additional information.", "from": "now-15m", "index": [ "endgame-*" @@ -10,7 +10,7 @@ "interval": "10m", "language": "kuery", "license": "Elastic License", - "name": "Credential Dumping - Detected - Elastic Endpoint", + "name": "Credential Dumping - Detected - Elastic Endpoint Security", "query": "event.kind:alert and event.module:endgame and endgame.metadata.type:detection and (event.action:cred_theft_event or endgame.event_subtype_full:cred_theft_event)", "risk_score": 73, "rule_id": "571afc56-5ed9-465d-a2a9-045f099f6e7e", diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_cred_dumping_prevented.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_cred_dumping_prevented.json index bed473b12b04..5c1b2cb02b84 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_cred_dumping_prevented.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_cred_dumping_prevented.json @@ -2,7 +2,7 @@ "author": [ "Elastic" ], - "description": "Elastic Endpoint prevented Credential Dumping. Click the Elastic Endpoint icon in the event.module column or the link in the rule.reference column in the External Alerts tab of the SIEM Detections page for additional information.", + "description": "Elastic Endpoint Security prevented Credential Dumping. Click the Elastic Endpoint Security icon in the event.module column or the link in the rule.reference column for additional information.", "from": "now-15m", "index": [ "endgame-*" @@ -10,7 +10,7 @@ "interval": "10m", "language": "kuery", "license": "Elastic License", - "name": "Credential Dumping - Prevented - Elastic Endpoint", + "name": "Credential Dumping - Prevented - Elastic Endpoint Security", "query": "event.kind:alert and event.module:endgame and endgame.metadata.type:prevention and (event.action:cred_theft_event or endgame.event_subtype_full:cred_theft_event)", "risk_score": 47, "rule_id": "db8c33a8-03cd-4988-9e2c-d0a4863adb13", diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_cred_manipulation_detected.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_cred_manipulation_detected.json index 02ba20bb59ae..16ad12a94ec4 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_cred_manipulation_detected.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_cred_manipulation_detected.json @@ -2,7 +2,7 @@ "author": [ "Elastic" ], - "description": "Elastic Endpoint detected Credential Manipulation. Click the Elastic Endpoint icon in the event.module column or the link in the rule.reference column in the External Alerts tab of the SIEM Detections page for additional information.", + "description": "Elastic Endpoint Security detected Credential Manipulation. Click the Elastic Endpoint Security icon in the event.module column or the link in the rule.reference column for additional information.", "from": "now-15m", "index": [ "endgame-*" @@ -10,7 +10,7 @@ "interval": "10m", "language": "kuery", "license": "Elastic License", - "name": "Credential Manipulation - Detected - Elastic Endpoint", + "name": "Credential Manipulation - Detected - Elastic Endpoint Security", "query": "event.kind:alert and event.module:endgame and endgame.metadata.type:detection and (event.action:token_manipulation_event or endgame.event_subtype_full:token_manipulation_event)", "risk_score": 73, "rule_id": "c0be5f31-e180-48ed-aa08-96b36899d48f", diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_cred_manipulation_prevented.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_cred_manipulation_prevented.json index 128f8d5639d5..9addcbf2fba3 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_cred_manipulation_prevented.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_cred_manipulation_prevented.json @@ -2,7 +2,7 @@ "author": [ "Elastic" ], - "description": "Elastic Endpoint prevented Credential Manipulation. Click the Elastic Endpoint icon in the event.module column or the link in the rule.reference column in the External Alerts tab of the SIEM Detections page for additional information.", + "description": "Elastic Endpoint Security prevented Credential Manipulation. Click the Elastic Endpoint Security icon in the event.module column or the link in the rule.reference column for additional information.", "from": "now-15m", "index": [ "endgame-*" @@ -10,7 +10,7 @@ "interval": "10m", "language": "kuery", "license": "Elastic License", - "name": "Credential Manipulation - Prevented - Elastic Endpoint", + "name": "Credential Manipulation - Prevented - Elastic Endpoint Security", "query": "event.kind:alert and event.module:endgame and endgame.metadata.type:prevention and (event.action:token_manipulation_event or endgame.event_subtype_full:token_manipulation_event)", "risk_score": 47, "rule_id": "c9e38e64-3f4c-4bf3-ad48-0e61a60ea1fa", diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_exploit_detected.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_exploit_detected.json index a11b839792b7..f51a38781c95 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_exploit_detected.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_exploit_detected.json @@ -2,7 +2,7 @@ "author": [ "Elastic" ], - "description": "Elastic Endpoint detected an Exploit. Click the Elastic Endpoint icon in the event.module column or the link in the rule.reference column in the External Alerts tab of the SIEM Detections page for additional information.", + "description": "Elastic Endpoint Security detected an Exploit. Click the Elastic Endpoint Security icon in the event.module column or the link in the rule.reference column for additional information.", "from": "now-15m", "index": [ "endgame-*" @@ -10,7 +10,7 @@ "interval": "10m", "language": "kuery", "license": "Elastic License", - "name": "Exploit - Detected - Elastic Endpoint", + "name": "Exploit - Detected - Elastic Endpoint Security", "query": "event.kind:alert and event.module:endgame and endgame.metadata.type:detection and (event.action:exploit_event or endgame.event_subtype_full:exploit_event)", "risk_score": 73, "rule_id": "2003cdc8-8d83-4aa5-b132-1f9a8eb48514", diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_exploit_prevented.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_exploit_prevented.json index 2deb7bce3b20..8b96c5a63fbe 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_exploit_prevented.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_exploit_prevented.json @@ -2,7 +2,7 @@ "author": [ "Elastic" ], - "description": "Elastic Endpoint prevented an Exploit. Click the Elastic Endpoint icon in the event.module column or the link in the rule.reference column in the External Alerts tab of the SIEM Detections page for additional information.", + "description": "Elastic Endpoint Security prevented an Exploit. Click the Elastic Endpoint Security icon in the event.module column or the link in the rule.reference column for additional information.", "from": "now-15m", "index": [ "endgame-*" @@ -10,7 +10,7 @@ "interval": "10m", "language": "kuery", "license": "Elastic License", - "name": "Exploit - Prevented - Elastic Endpoint", + "name": "Exploit - Prevented - Elastic Endpoint Security", "query": "event.kind:alert and event.module:endgame and endgame.metadata.type:prevention and (event.action:exploit_event or endgame.event_subtype_full:exploit_event)", "risk_score": 47, "rule_id": "2863ffeb-bf77-44dd-b7a5-93ef94b72036", diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_malware_detected.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_malware_detected.json index d1389b21f2d7..28ff73468deb 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_malware_detected.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_malware_detected.json @@ -2,7 +2,7 @@ "author": [ "Elastic" ], - "description": "Elastic Endpoint detected Malware. Click the Elastic Endpoint icon in the event.module column or the link in the rule.reference column in the External Alerts tab of the SIEM Detections page for additional information.", + "description": "Elastic Endpoint Security detected Malware. Click the Elastic Endpoint Security icon in the event.module column or the link in the rule.reference column for additional information.", "from": "now-15m", "index": [ "endgame-*" @@ -10,7 +10,7 @@ "interval": "10m", "language": "kuery", "license": "Elastic License", - "name": "Malware - Detected - Elastic Endpoint", + "name": "Malware - Detected - Elastic Endpoint Security", "query": "event.kind:alert and event.module:endgame and endgame.metadata.type:detection and (event.action:file_classification_event or endgame.event_subtype_full:file_classification_event)", "risk_score": 99, "rule_id": "0a97b20f-4144-49ea-be32-b540ecc445de", diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_malware_prevented.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_malware_prevented.json index b83bc259175c..3d32abf2bf8f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_malware_prevented.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_malware_prevented.json @@ -2,7 +2,7 @@ "author": [ "Elastic" ], - "description": "Elastic Endpoint prevented Malware. Click the Elastic Endpoint icon in the event.module column or the link in the rule.reference column in the External Alerts tab of the SIEM Detections page for additional information.", + "description": "Elastic Endpoint Security prevented Malware. Click the Elastic Endpoint Security icon in the event.module column or the link in the rule.reference column for additional information.", "from": "now-15m", "index": [ "endgame-*" @@ -10,7 +10,7 @@ "interval": "10m", "language": "kuery", "license": "Elastic License", - "name": "Malware - Prevented - Elastic Endpoint", + "name": "Malware - Prevented - Elastic Endpoint Security", "query": "event.kind:alert and event.module:endgame and endgame.metadata.type:prevention and (event.action:file_classification_event or endgame.event_subtype_full:file_classification_event)", "risk_score": 73, "rule_id": "3b382770-efbb-44f4-beed-f5e0a051b895", diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_permission_theft_detected.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_permission_theft_detected.json index b81b9c67644c..a89a7f7d5918 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_permission_theft_detected.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_permission_theft_detected.json @@ -2,7 +2,7 @@ "author": [ "Elastic" ], - "description": "Elastic Endpoint detected Permission Theft. Click the Elastic Endpoint icon in the event.module column or the link in the rule.reference column in the External Alerts tab of the SIEM Detections page for additional information.", + "description": "Elastic Endpoint Security detected Permission Theft. Click the Elastic Endpoint Security icon in the event.module column or the link in the rule.reference column for additional information.", "from": "now-15m", "index": [ "endgame-*" @@ -10,7 +10,7 @@ "interval": "10m", "language": "kuery", "license": "Elastic License", - "name": "Permission Theft - Detected - Elastic Endpoint", + "name": "Permission Theft - Detected - Elastic Endpoint Security", "query": "event.kind:alert and event.module:endgame and endgame.metadata.type:detection and (event.action:token_protection_event or endgame.event_subtype_full:token_protection_event)", "risk_score": 73, "rule_id": "c3167e1b-f73c-41be-b60b-87f4df707fe3", diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_permission_theft_prevented.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_permission_theft_prevented.json index b69598cffc23..fb9dbe3dadb1 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_permission_theft_prevented.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_permission_theft_prevented.json @@ -2,7 +2,7 @@ "author": [ "Elastic" ], - "description": "Elastic Endpoint prevented Permission Theft. Click the Elastic Endpoint icon in the event.module column or the link in the rule.reference column in the External Alerts tab of the SIEM Detections page for additional information.", + "description": "Elastic Endpoint Security prevented Permission Theft. Click the Elastic Endpoint Security icon in the event.module column or the link in the rule.reference column for additional information.", "from": "now-15m", "index": [ "endgame-*" @@ -10,7 +10,7 @@ "interval": "10m", "language": "kuery", "license": "Elastic License", - "name": "Permission Theft - Prevented - Elastic Endpoint", + "name": "Permission Theft - Prevented - Elastic Endpoint Security", "query": "event.kind:alert and event.module:endgame and endgame.metadata.type:prevention and (event.action:token_protection_event or endgame.event_subtype_full:token_protection_event)", "risk_score": 47, "rule_id": "453f659e-0429-40b1-bfdb-b6957286e04b", diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_process_injection_detected.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_process_injection_detected.json index 8299e1139239..e022d058d756 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_process_injection_detected.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_process_injection_detected.json @@ -2,7 +2,7 @@ "author": [ "Elastic" ], - "description": "Elastic Endpoint detected Process Injection. Click the Elastic Endpoint icon in the event.module column or the link in the rule.reference column in the External Alerts tab of the SIEM Detections page for additional information.", + "description": "Elastic Endpoint Security detected Process Injection. Click the Elastic Endpoint Security icon in the event.module column or the link in the rule.reference column for additional information.", "from": "now-15m", "index": [ "endgame-*" @@ -10,7 +10,7 @@ "interval": "10m", "language": "kuery", "license": "Elastic License", - "name": "Process Injection - Detected - Elastic Endpoint", + "name": "Process Injection - Detected - Elastic Endpoint Security", "query": "event.kind:alert and event.module:endgame and endgame.metadata.type:detection and (event.action:kernel_shellcode_event or endgame.event_subtype_full:kernel_shellcode_event)", "risk_score": 73, "rule_id": "80c52164-c82a-402c-9964-852533d58be1", diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_process_injection_prevented.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_process_injection_prevented.json index 237558ae372a..2d189707293f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_process_injection_prevented.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_process_injection_prevented.json @@ -2,7 +2,7 @@ "author": [ "Elastic" ], - "description": "Elastic Endpoint prevented Process Injection. Click the Elastic Endpoint icon in the event.module column or the link in the rule.reference column in the External Alerts tab of the SIEM Detections page for additional information.", + "description": "Elastic Endpoint Security prevented Process Injection. Click the Elastic Endpoint Security icon in the event.module column or the link in the rule.reference column for additional information.", "from": "now-15m", "index": [ "endgame-*" @@ -10,7 +10,7 @@ "interval": "10m", "language": "kuery", "license": "Elastic License", - "name": "Process Injection - Prevented - Elastic Endpoint", + "name": "Process Injection - Prevented - Elastic Endpoint Security", "query": "event.kind:alert and event.module:endgame and endgame.metadata.type:prevention and (event.action:kernel_shellcode_event or endgame.event_subtype_full:kernel_shellcode_event)", "risk_score": 47, "rule_id": "990838aa-a953-4f3e-b3cb-6ddf7584de9e", diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_ransomware_detected.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_ransomware_detected.json index 4ead850c60e8..077c20bca5d8 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_ransomware_detected.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_ransomware_detected.json @@ -2,7 +2,7 @@ "author": [ "Elastic" ], - "description": "Elastic Endpoint detected Ransomware. Click the Elastic Endpoint icon in the event.module column or the link in the rule.reference column in the External Alerts tab of the SIEM Detections page for additional information.", + "description": "Elastic Endpoint Security detected Ransomware. Click the Elastic Endpoint Security icon in the event.module column or the link in the rule.reference column for additional information.", "from": "now-15m", "index": [ "endgame-*" @@ -10,7 +10,7 @@ "interval": "10m", "language": "kuery", "license": "Elastic License", - "name": "Ransomware - Detected - Elastic Endpoint", + "name": "Ransomware - Detected - Elastic Endpoint Security", "query": "event.kind:alert and event.module:endgame and endgame.metadata.type:detection and (event.action:ransomware_event or endgame.event_subtype_full:ransomware_event)", "risk_score": 99, "rule_id": "8cb4f625-7743-4dfb-ae1b-ad92be9df7bd", diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_ransomware_prevented.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_ransomware_prevented.json index 25d167afa204..b615fcb04895 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_ransomware_prevented.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/endpoint_ransomware_prevented.json @@ -2,7 +2,7 @@ "author": [ "Elastic" ], - "description": "Elastic Endpoint prevented Ransomware. Click the Elastic Endpoint icon in the event.module column or the link in the rule.reference column in the External Alerts tab of the SIEM Detections page for additional information.", + "description": "Elastic Endpoint Security prevented Ransomware. Click the Elastic Endpoint Security icon in the event.module column or the link in the rule.reference column for additional information.", "from": "now-15m", "index": [ "endgame-*" @@ -10,7 +10,7 @@ "interval": "10m", "language": "kuery", "license": "Elastic License", - "name": "Ransomware - Prevented - Elastic Endpoint", + "name": "Ransomware - Prevented - Elastic Endpoint Security", "query": "event.kind:alert and event.module:endgame and endgame.metadata.type:prevention and (event.action:ransomware_event or endgame.event_subtype_full:ransomware_event)", "risk_score": 73, "rule_id": "e3c5d5cb-41d5-4206-805c-f30561eae3ac", diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_via_system_manager.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_via_system_manager.json index 90338f446072..a9f8ee1af8bf 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_via_system_manager.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/execution_via_system_manager.json @@ -14,6 +14,7 @@ "language": "kuery", "license": "Elastic License", "name": "AWS Execution via System Manager", + "note": "The AWS Filebeat module must be enabled to use this rule.", "query": "event.module:aws and event.dataset:aws.cloudtrail and event.provider:ssm.amazonaws.com and event.action:SendCommand and event.outcome:success", "references": [ "https://docs.aws.amazon.com/systems-manager/latest/userguide/ssm-plugins.html" diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/exfiltration_ec2_snapshot_change_activity.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/exfiltration_ec2_snapshot_change_activity.json index 04cc697cf36f..25711afbb4c6 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/exfiltration_ec2_snapshot_change_activity.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/exfiltration_ec2_snapshot_change_activity.json @@ -14,6 +14,7 @@ "language": "kuery", "license": "Elastic License", "name": "AWS EC2 Snapshot Activity", + "note": "The AWS Filebeat module must be enabled to use this rule.", "query": "event.module:aws and event.dataset:aws.cloudtrail and event.provider:ec2.amazonaws.com and event.action:ModifySnapshotAttribute", "references": [ "https://awscli.amazonaws.com/v2/documentation/api/latest/reference/ec2/modify-snapshot-attribute.html", diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/external_alerts.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/external_alerts.json index c8ebb2ed0e5d..678ad9eb03b5 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/external_alerts.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/external_alerts.json @@ -2,7 +2,16 @@ "author": [ "Elastic" ], - "description": "Generates a detection alert for each external alert written to the configured securitySolution:defaultIndex. Enabling this rule allows you to immediately begin investigating external alerts in the app.", + "description": "Generates a detection alert for each external alert written to the configured indices. Enabling this rule allows you to immediately begin investigating external alerts in the app.", + "index": [ + "apm-*-transaction*", + "auditbeat-*", + "endgame-*", + "filebeat-*", + "logs-*", + "packetbeat-*", + "winlogbeat-*" + ], "language": "kuery", "license": "Elastic License", "max_signals": 10000, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_attempt_to_revoke_okta_api_token.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_attempt_to_revoke_okta_api_token.json index 0f4ded9fcfe8..27e50313c8f8 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_attempt_to_revoke_okta_api_token.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_attempt_to_revoke_okta_api_token.json @@ -12,6 +12,7 @@ "language": "kuery", "license": "Elastic License", "name": "Attempt to Revoke Okta API Token", + "note": "The Okta Filebeat module must be enabled to use this rule.", "query": "event.module:okta and event.dataset:okta.system and event.action:system.api_token.revoke", "references": [ "https://developer.okta.com/docs/reference/api/system-log/", diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_cloudtrail_logging_updated.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_cloudtrail_logging_updated.json index d969ef21027f..0bafa56c9af4 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_cloudtrail_logging_updated.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_cloudtrail_logging_updated.json @@ -14,6 +14,7 @@ "language": "kuery", "license": "Elastic License", "name": "AWS CloudTrail Log Updated", + "note": "The AWS Filebeat module must be enabled to use this rule.", "query": "event.action:UpdateTrail and event.dataset:aws.cloudtrail and event.provider:cloudtrail.amazonaws.com and event.outcome:success", "references": [ "https://docs.aws.amazon.com/awscloudtrail/latest/APIReference/API_UpdateTrail.html", diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_cloudwatch_log_group_deletion.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_cloudwatch_log_group_deletion.json index d33593d4a44b..74b5e0d93c44 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_cloudwatch_log_group_deletion.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_cloudwatch_log_group_deletion.json @@ -14,6 +14,7 @@ "language": "kuery", "license": "Elastic License", "name": "AWS CloudWatch Log Group Deletion", + "note": "The AWS Filebeat module must be enabled to use this rule.", "query": "event.action:DeleteLogGroup and event.dataset:aws.cloudtrail and event.provider:logs.amazonaws.com and event.outcome:success", "references": [ "https://awscli.amazonaws.com/v2/documentation/api/latest/reference/logs/delete-log-group.html", diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_cloudwatch_log_stream_deletion.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_cloudwatch_log_stream_deletion.json index a1108dd07abd..59c659117c09 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_cloudwatch_log_stream_deletion.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_cloudwatch_log_stream_deletion.json @@ -14,6 +14,7 @@ "language": "kuery", "license": "Elastic License", "name": "AWS CloudWatch Log Stream Deletion", + "note": "The AWS Filebeat module must be enabled to use this rule.", "query": "event.action:DeleteLogStream and event.dataset:aws.cloudtrail and event.provider:logs.amazonaws.com and event.outcome:success", "references": [ "https://awscli.amazonaws.com/v2/documentation/api/latest/reference/logs/delete-log-stream.html", diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_ec2_disable_ebs_encryption.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_ec2_disable_ebs_encryption.json index 4681b475d92e..10a1989ad642 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_ec2_disable_ebs_encryption.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_ec2_disable_ebs_encryption.json @@ -14,6 +14,7 @@ "language": "kuery", "license": "Elastic License", "name": "AWS EC2 Encryption Disabled", + "note": "The AWS Filebeat module must be enabled to use this rule.", "query": "event.action:DisableEbsEncryptionByDefault and event.dataset:aws.cloudtrail and event.provider:ec2.amazonaws.com and event.outcome:success", "references": [ "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/EBSEncryption.html", diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_iam_deactivate_mfa_device.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_iam_deactivate_mfa_device.json index f873e3483a34..4aa0b355171f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_iam_deactivate_mfa_device.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_iam_deactivate_mfa_device.json @@ -14,6 +14,7 @@ "language": "kuery", "license": "Elastic License", "name": "AWS IAM Deactivation of MFA Device", + "note": "The AWS Filebeat module must be enabled to use this rule.", "query": "event.action:DeactivateMFADevice and event.dataset:aws.cloudtrail and event.provider:iam.amazonaws.com and event.outcome:success", "references": [ "https://awscli.amazonaws.com/v2/documentation/api/latest/reference/iam/deactivate-mfa-device.html", diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_iam_group_deletion.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_iam_group_deletion.json index 23364c8b3aa2..25b300d33cce 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_iam_group_deletion.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_iam_group_deletion.json @@ -14,6 +14,7 @@ "language": "kuery", "license": "Elastic License", "name": "AWS IAM Group Deletion", + "note": "The AWS Filebeat module must be enabled to use this rule.", "query": "event.action:DeleteGroup and event.dataset:aws.cloudtrail and event.provider:iam.amazonaws.com and event.outcome:success", "references": [ "https://awscli.amazonaws.com/v2/documentation/api/latest/reference/iam/delete-group.html", diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_possible_okta_dos_attack.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_possible_okta_dos_attack.json index 8c76f182442a..9ca8b7ed21ac 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_possible_okta_dos_attack.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_possible_okta_dos_attack.json @@ -9,6 +9,7 @@ "language": "kuery", "license": "Elastic License", "name": "Possible Okta DoS Attack", + "note": "The Okta Filebeat module must be enabled to use this rule.", "query": "event.module:okta and event.dataset:okta.system and event.action:(application.integration.rate_limit_exceeded or system.org.rate_limit.warning or system.org.rate_limit.violation or core.concurrency.org.limit.violation)", "references": [ "https://developer.okta.com/docs/reference/api/system-log/", diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_rds_cluster_deletion.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_rds_cluster_deletion.json index 88ec942b0e5e..e8343f1b7b7c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_rds_cluster_deletion.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_rds_cluster_deletion.json @@ -14,6 +14,7 @@ "language": "kuery", "license": "Elastic License", "name": "AWS RDS Cluster Deletion", + "note": "The AWS Filebeat module must be enabled to use this rule.", "query": "event.action:(DeleteDBCluster or DeleteGlobalCluster) and event.dataset:aws.cloudtrail and event.provider:rds.amazonaws.com and event.outcome:success", "references": [ "https://awscli.amazonaws.com/v2/documentation/api/latest/reference/rds/delete-db-cluster.html", diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_rds_instance_cluster_stoppage.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_rds_instance_cluster_stoppage.json index 2c25781e24d1..8c4387e60d28 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_rds_instance_cluster_stoppage.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/impact_rds_instance_cluster_stoppage.json @@ -14,6 +14,7 @@ "language": "kuery", "license": "Elastic License", "name": "AWS RDS Instance/Cluster Stoppage", + "note": "The AWS Filebeat module must be enabled to use this rule.", "query": "event.action:(StopDBCluster or StopDBInstance) and event.dataset:aws.cloudtrail and event.provider:rds.amazonaws.com and event.outcome:success", "references": [ "https://awscli.amazonaws.com/v2/documentation/api/latest/reference/rds/stop-db-cluster.html", diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/index.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/index.ts index f2e2137eec41..685c869630ca 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/index.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/index.ts @@ -210,6 +210,8 @@ import rule198 from './ml_cloudtrail_rare_error_code.json'; import rule199 from './ml_cloudtrail_rare_method_by_city.json'; import rule200 from './ml_cloudtrail_rare_method_by_country.json'; import rule201 from './ml_cloudtrail_rare_method_by_user.json'; +import rule202 from './credential_access_aws_iam_assume_role_brute_force.json'; +import rule203 from './credential_access_okta_brute_force_or_password_spraying.json'; export const rawRules = [ rule1, @@ -413,4 +415,6 @@ export const rawRules = [ rule199, rule200, rule201, + rule202, + rule203, ]; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_console_login_root.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_console_login_root.json index 0f761f0d2a5f..829d87c1964c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_console_login_root.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_console_login_root.json @@ -14,6 +14,7 @@ "language": "kuery", "license": "Elastic License", "name": "AWS Management Console Root Login", + "note": "The AWS Filebeat module must be enabled to use this rule.", "query": "event.action:ConsoleLogin and event.module:aws and event.dataset:aws.cloudtrail and event.provider:signin.amazonaws.com and aws.cloudtrail.user_identity.type:Root and event.outcome:success", "references": [ "https://docs.aws.amazon.com/IAM/latest/UserGuide/id_root-user.html" diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_password_recovery.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_password_recovery.json index 1042ce19a14c..7429c69fc317 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_password_recovery.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_password_recovery.json @@ -14,6 +14,7 @@ "language": "kuery", "license": "Elastic License", "name": "AWS IAM Password Recovery Requested", + "note": "The AWS Filebeat module must be enabled to use this rule.", "query": "event.action:PasswordRecoveryRequested and event.provider:signin.amazonaws.com and event.outcome:success", "references": [ "https://www.cadosecurity.com/2020/06/11/an-ongoing-aws-phishing-campaign/" diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_suspicious_activity_reported_by_okta_user.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_suspicious_activity_reported_by_okta_user.json index 5fa8a655c08b..25bf7dd287d0 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_suspicious_activity_reported_by_okta_user.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/initial_access_suspicious_activity_reported_by_okta_user.json @@ -12,6 +12,7 @@ "language": "kuery", "license": "Elastic License", "name": "Suspicious Activity Reported by Okta User", + "note": "The Okta Filebeat module must be enabled to use this rule.", "query": "event.module:okta and event.dataset:okta.system and event.action:user.account.report_suspicious_activity_by_enduser", "references": [ "https://developer.okta.com/docs/reference/api/system-log/", diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_deactivate_okta_mfa_rule.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_deactivate_okta_mfa_rule.json index 737044d5a9bd..1d15db83bb18 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_deactivate_okta_mfa_rule.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_deactivate_okta_mfa_rule.json @@ -12,6 +12,7 @@ "language": "kuery", "license": "Elastic License", "name": "Attempt to Deactivate Okta MFA Rule", + "note": "The Okta Filebeat module must be enabled to use this rule.", "query": "event.module:okta and event.dataset:okta.system and event.action:policy.rule.deactivate", "references": [ "https://developer.okta.com/docs/reference/api/system-log/", diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_delete_okta_policy.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_delete_okta_policy.json index ea8ba7223095..6df2ed6cb34a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_delete_okta_policy.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_delete_okta_policy.json @@ -12,6 +12,7 @@ "language": "kuery", "license": "Elastic License", "name": "Attempt to Delete Okta Policy", + "note": "The Okta Filebeat module must be enabled to use this rule.", "query": "event.module:okta and event.dataset:okta.system and event.action:policy.lifecycle.delete", "references": [ "https://developer.okta.com/docs/reference/api/system-log/", diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_okta_mfa_rule.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_okta_mfa_rule.json index dfe16f56da0e..e276166f6130 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_okta_mfa_rule.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_okta_mfa_rule.json @@ -12,6 +12,7 @@ "language": "kuery", "license": "Elastic License", "name": "Attempt to Modify Okta MFA Rule", + "note": "The Okta Filebeat module must be enabled to use this rule.", "query": "event.module:okta and event.dataset:okta.system and event.action:(policy.rule.update or policy.rule.delete)", "references": [ "https://developer.okta.com/docs/reference/api/system-log/", diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_okta_network_zone.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_okta_network_zone.json index 61c45f8e7d85..bdfe7d25092b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_okta_network_zone.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_okta_network_zone.json @@ -12,6 +12,7 @@ "language": "kuery", "license": "Elastic License", "name": "Attempt to Modify Okta Network Zone", + "note": "The Okta Filebeat module must be enabled to use this rule.", "query": "event.module:okta and event.dataset:okta.system and event.action:(zone.update or zone.deactivate or zone.delete or network_zone.rule.disabled or zone.remove_blacklist)", "references": [ "https://developer.okta.com/docs/reference/api/system-log/", diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_okta_policy.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_okta_policy.json index a864b900a599..e3e0d5fef7b2 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_okta_policy.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_okta_policy.json @@ -12,6 +12,7 @@ "language": "kuery", "license": "Elastic License", "name": "Attempt to Modify Okta Policy", + "note": "The Okta Filebeat module must be enabled to use this rule.", "query": "event.module:okta and event.dataset:okta.system and event.action:policy.lifecycle.update", "references": [ "https://developer.okta.com/docs/reference/api/system-log/", diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_or_delete_application_sign_on_policy.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_or_delete_application_sign_on_policy.json index ff7546ac2f1a..ad21ebe065f8 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_or_delete_application_sign_on_policy.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_attempt_to_modify_or_delete_application_sign_on_policy.json @@ -12,6 +12,7 @@ "language": "kuery", "license": "Elastic License", "name": "Modification or Removal of an Okta Application Sign-On Policy", + "note": "The Okta Filebeat module must be enabled to use this rule.", "query": "event.module:okta and event.dataset:okta.system and event.action:(application.policy.sign_on.update or application.policy.sign_on.rule.delete)", "references": [ "https://developer.okta.com/docs/reference/api/system-log/", diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_threat_detected_by_okta_threatinsight.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_threat_detected_by_okta_threatinsight.json index 7a1b6e3d82d7..e92cf3d67d31 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_threat_detected_by_okta_threatinsight.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/okta_threat_detected_by_okta_threatinsight.json @@ -9,6 +9,7 @@ "language": "kuery", "license": "Elastic License", "name": "Threat Detected by Okta ThreatInsight", + "note": "The Okta Filebeat module must be enabled to use this rule.", "query": "event.module:okta and event.dataset:okta.system and event.action:security.threat.detected", "references": [ "https://developer.okta.com/docs/reference/api/system-log/", diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_administrator_privileges_assigned_to_okta_group.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_administrator_privileges_assigned_to_okta_group.json index 70e7eb1706e1..d5f3995fb8bc 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_administrator_privileges_assigned_to_okta_group.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_administrator_privileges_assigned_to_okta_group.json @@ -12,6 +12,7 @@ "language": "kuery", "license": "Elastic License", "name": "Administrator Privileges Assigned to Okta Group", + "note": "The Okta Filebeat module must be enabled to use this rule.", "query": "event.module:okta and event.dataset:okta.system and event.action:group.privilege.grant", "references": [ "https://developer.okta.com/docs/reference/api/system-log/", diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_attempt_to_create_okta_api_token.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_attempt_to_create_okta_api_token.json index 453580d58034..5f6c006c5d17 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_attempt_to_create_okta_api_token.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_attempt_to_create_okta_api_token.json @@ -12,6 +12,7 @@ "language": "kuery", "license": "Elastic License", "name": "Attempt to Create Okta API Token", + "note": "The Okta Filebeat module must be enabled to use this rule.", "query": "event.module:okta and event.dataset:okta.system and event.action:system.api_token.create", "references": [ "https://developer.okta.com/docs/reference/api/system-log/", diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_attempt_to_deactivate_mfa_for_okta_user_account.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_attempt_to_deactivate_mfa_for_okta_user_account.json index e5648285c528..d3a66ef8d9c7 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_attempt_to_deactivate_mfa_for_okta_user_account.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_attempt_to_deactivate_mfa_for_okta_user_account.json @@ -12,6 +12,7 @@ "language": "kuery", "license": "Elastic License", "name": "Attempt to Deactivate MFA for Okta User Account", + "note": "The Okta Filebeat module must be enabled to use this rule.", "query": "event.module:okta and event.dataset:okta.system and event.action:user.mfa.factor.deactivate", "references": [ "https://developer.okta.com/docs/reference/api/system-log/", diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_attempt_to_deactivate_okta_policy.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_attempt_to_deactivate_okta_policy.json index 53da25904273..7104cace1c5d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_attempt_to_deactivate_okta_policy.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_attempt_to_deactivate_okta_policy.json @@ -12,6 +12,7 @@ "language": "kuery", "license": "Elastic License", "name": "Attempt to Deactivate Okta Policy", + "note": "The Okta Filebeat module must be enabled to use this rule.", "query": "event.module:okta and event.dataset:okta.system and event.action:policy.lifecycle.deactivate", "references": [ "https://developer.okta.com/docs/reference/api/system-log/", diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_attempt_to_reset_mfa_factors_for_okta_user_account.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_attempt_to_reset_mfa_factors_for_okta_user_account.json index f662c0c0b8eb..c38f71d8e00a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_attempt_to_reset_mfa_factors_for_okta_user_account.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_attempt_to_reset_mfa_factors_for_okta_user_account.json @@ -12,6 +12,7 @@ "language": "kuery", "license": "Elastic License", "name": "Attempt to Reset MFA Factors for Okta User Account", + "note": "The Okta Filebeat module must be enabled to use this rule.", "query": "event.module:okta and event.dataset:okta.system and event.action:user.mfa.factor.reset_all", "references": [ "https://developer.okta.com/docs/reference/api/system-log/", diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_ec2_network_acl_creation.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_ec2_network_acl_creation.json index 911536d2567f..99bb07fe9660 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_ec2_network_acl_creation.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_ec2_network_acl_creation.json @@ -14,6 +14,7 @@ "language": "kuery", "license": "Elastic License", "name": "AWS EC2 Network Access Control List Creation", + "note": "The AWS Filebeat module must be enabled to use this rule.", "query": "event.action:(CreateNetworkAcl or CreateNetworkAclEntry) and event.dataset:aws.cloudtrail and event.provider:ec2.amazonaws.com and event.outcome:success", "references": [ "https://awscli.amazonaws.com/v2/documentation/api/latest/reference/ec2/create-network-acl.html", diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_iam_group_creation.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_iam_group_creation.json index 7c1c4d02737a..9b2478b97fb3 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_iam_group_creation.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_iam_group_creation.json @@ -14,6 +14,7 @@ "language": "kuery", "license": "Elastic License", "name": "AWS IAM Group Creation", + "note": "The AWS Filebeat module must be enabled to use this rule.", "query": "event.action:CreateGroup and event.dataset:aws.cloudtrail and event.provider:iam.amazonaws.com and event.outcome:success", "references": [ "https://awscli.amazonaws.com/v2/documentation/api/latest/reference/iam/create-group.html", diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_rds_cluster_creation.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_rds_cluster_creation.json index c6e23acab0fb..94a695a97a27 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_rds_cluster_creation.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/persistence_rds_cluster_creation.json @@ -14,6 +14,7 @@ "language": "kuery", "license": "Elastic License", "name": "AWS RDS Cluster Creation", + "note": "The AWS Filebeat module must be enabled to use this rule.", "query": "event.action:(CreateDBCluster or CreateGlobalCluster) and event.dataset:aws.cloudtrail and event.provider:rds.amazonaws.com and event.outcome:success", "references": [ "https://awscli.amazonaws.com/v2/documentation/api/latest/reference/rds/create-db-cluster.html", diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_root_login_without_mfa.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_root_login_without_mfa.json index 6db9e04edc0c..74c5376100b2 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_root_login_without_mfa.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_root_login_without_mfa.json @@ -14,6 +14,7 @@ "language": "kuery", "license": "Elastic License", "name": "AWS Root Login Without MFA", + "note": "The AWS Filebeat module must be enabled to use this rule.", "query": "event.module:aws and event.dataset:aws.cloudtrail and event.provider:signin.amazonaws.com and event.action:ConsoleLogin and aws.cloudtrail.user_identity.type:Root and aws.cloudtrail.console_login.additional_eventdata.mfa_used:false and event.outcome:success", "references": [ "https://docs.aws.amazon.com/IAM/latest/UserGuide/id_root-user.html" diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_updateassumerolepolicy.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_updateassumerolepolicy.json index 623f90716b2b..7ce54b00f211 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_updateassumerolepolicy.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/privilege_escalation_updateassumerolepolicy.json @@ -14,6 +14,7 @@ "language": "kuery", "license": "Elastic License", "name": "AWS IAM Assume Role Policy Update", + "note": "The AWS Filebeat module must be enabled to use this rule.", "query": "event.module:aws and event.dataset:aws.cloudtrail and event.provider:iam.amazonaws.com and event.action:UpdateAssumeRolePolicy and event.outcome:success", "references": [ "https://labs.bishopfox.com/tech-blog/5-privesc-attack-vectors-in-aws" From 9a22b95b97baed7d0fb0ab3cbc3e67a463cd9b55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Marcondes?= <55978943+cauemarcondes@users.noreply.github.com> Date: Thu, 23 Jul 2020 12:46:09 +0100 Subject: [PATCH 088/142] [APM] Custom link: Removing async check for callAPMApi (#73004) * removing async check for callAPMApi * removing async check for callAPMApi --- .../CustomizeUI/CustomLink/index.test.tsx | 32 ++++++++----------- 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.test.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.test.tsx index d633d466b661..56c420878cdb 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.test.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.test.tsx @@ -35,9 +35,8 @@ const data = [ ]; describe('CustomLink', () => { - let callApmApiSpy: jest.SpyInstance; beforeAll(() => { - callApmApiSpy = jest.spyOn(apmApi, 'callApmApi').mockReturnValue({}); + jest.spyOn(apmApi, 'callApmApi').mockReturnValue({}); }); afterAll(() => { jest.resetAllMocks(); @@ -103,7 +102,7 @@ describe('CustomLink', () => { ]); }); - it('checks if create custom link button is available and working', async () => { + it('checks if create custom link button is available and working', () => { const { queryByText, getByText } = render( @@ -115,7 +114,6 @@ describe('CustomLink', () => { act(() => { fireEvent.click(getByText('Create custom link')); }); - await wait(() => expect(callApmApiSpy).toHaveBeenCalled()); expect(queryByText('Create link')).toBeInTheDocument(); }); }); @@ -133,7 +131,7 @@ describe('CustomLink', () => { }); }); - const openFlyout = async () => { + const openFlyout = () => { const component = render( @@ -145,15 +143,12 @@ describe('CustomLink', () => { act(() => { fireEvent.click(component.getByText('Create custom link')); }); - await wait(() => - expect(component.queryByText('Create link')).toBeInTheDocument() - ); - await wait(() => expect(callApmApiSpy).toHaveBeenCalled()); + expect(component.queryByText('Create link')).toBeInTheDocument(); return component; }; it('creates a custom link', async () => { - const component = await openFlyout(); + const component = openFlyout(); const labelInput = component.getByTestId('label'); act(() => { fireEvent.change(labelInput, { @@ -167,7 +162,7 @@ describe('CustomLink', () => { }); }); await act(async () => { - await wait(() => fireEvent.submit(component.getByText('Save'))); + fireEvent.submit(component.getByText('Save')); }); expect(saveCustomLinkSpy).toHaveBeenCalledTimes(1); }); @@ -186,11 +181,12 @@ describe('CustomLink', () => { act(() => { fireEvent.click(editButtons[0]); }); - expect(component.queryByText('Create link')).toBeInTheDocument(); + await wait(() => + expect(component.queryByText('Create link')).toBeInTheDocument() + ); await act(async () => { - await wait(() => fireEvent.click(component.getByText('Delete'))); + fireEvent.click(component.getByText('Delete')); }); - expect(callApmApiSpy).toHaveBeenCalled(); expect(refetch).toHaveBeenCalled(); }); @@ -200,8 +196,8 @@ describe('CustomLink', () => { fireEvent.click(component.getByText('Add another filter')); } }; - it('checks if add filter button is disabled after all elements have been added', async () => { - const component = await openFlyout(); + it('checks if add filter button is disabled after all elements have been added', () => { + const component = openFlyout(); expect(component.getAllByText('service.name').length).toEqual(1); addFilterField(component, 1); expect(component.getAllByText('service.name').length).toEqual(2); @@ -211,8 +207,8 @@ describe('CustomLink', () => { addFilterField(component, 2); expect(component.getAllByText('service.name').length).toEqual(4); }); - it('removes items already selected', async () => { - const component = await openFlyout(); + it('removes items already selected', () => { + const component = openFlyout(); const addFieldAndCheck = ( fieldName: string, From 06f142d586653db33e1b1b52aa71d95a91b341c3 Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Thu, 23 Jul 2020 08:06:16 -0400 Subject: [PATCH 089/142] [Ingest Manager] Fix config rollout move to limit concurrent config change instead of config per second (#72931) --- .../ingest_manager/common/types/index.ts | 3 +- x-pack/plugins/ingest_manager/server/index.ts | 3 +- .../agents/checkin/rxjs_utils.test.ts | 45 ++++++++++++++++++ .../services/agents/checkin/rxjs_utils.ts | 47 +++++++------------ .../server/services/agents/checkin/state.ts | 17 +++++-- .../agents/checkin/state_new_actions.ts | 10 ++-- 6 files changed, 80 insertions(+), 45 deletions(-) create mode 100644 x-pack/plugins/ingest_manager/server/services/agents/checkin/rxjs_utils.test.ts diff --git a/x-pack/plugins/ingest_manager/common/types/index.ts b/x-pack/plugins/ingest_manager/common/types/index.ts index d7edc04a3579..7acef263f973 100644 --- a/x-pack/plugins/ingest_manager/common/types/index.ts +++ b/x-pack/plugins/ingest_manager/common/types/index.ts @@ -22,8 +22,7 @@ export interface IngestManagerConfigType { host?: string; ca_sha256?: string; }; - agentConfigRollupRateLimitIntervalMs: number; - agentConfigRollupRateLimitRequestPerInterval: number; + agentConfigRolloutConcurrency: number; }; } diff --git a/x-pack/plugins/ingest_manager/server/index.ts b/x-pack/plugins/ingest_manager/server/index.ts index 6c72218abc53..40e0153a2658 100644 --- a/x-pack/plugins/ingest_manager/server/index.ts +++ b/x-pack/plugins/ingest_manager/server/index.ts @@ -35,8 +35,7 @@ export const config = { host: schema.maybe(schema.string()), ca_sha256: schema.maybe(schema.string()), }), - agentConfigRollupRateLimitIntervalMs: schema.number({ defaultValue: 5000 }), - agentConfigRollupRateLimitRequestPerInterval: schema.number({ defaultValue: 50 }), + agentConfigRolloutConcurrency: schema.number({ defaultValue: 10 }), }), }), }; diff --git a/x-pack/plugins/ingest_manager/server/services/agents/checkin/rxjs_utils.test.ts b/x-pack/plugins/ingest_manager/server/services/agents/checkin/rxjs_utils.test.ts new file mode 100644 index 000000000000..70207dcf325c --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/services/agents/checkin/rxjs_utils.test.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as Rx from 'rxjs'; +import { share } from 'rxjs/operators'; +import { createSubscriberConcurrencyLimiter } from './rxjs_utils'; + +function createSpyObserver(o: Rx.Observable): [Rx.Subscription, jest.Mock] { + const spy = jest.fn(); + const observer = o.subscribe(spy); + return [observer, spy]; +} + +describe('createSubscriberConcurrencyLimiter', () => { + it('should not publish to more than n concurrent subscriber', async () => { + const subject = new Rx.Subject(); + const sharedObservable = subject.pipe(share()); + + const limiter = createSubscriberConcurrencyLimiter(2); + + const [observer1, spy1] = createSpyObserver(sharedObservable.pipe(limiter())); + const [observer2, spy2] = createSpyObserver(sharedObservable.pipe(limiter())); + const [observer3, spy3] = createSpyObserver(sharedObservable.pipe(limiter())); + const [observer4, spy4] = createSpyObserver(sharedObservable.pipe(limiter())); + subject.next('test1'); + + expect(spy1).toBeCalled(); + expect(spy2).toBeCalled(); + expect(spy3).not.toBeCalled(); + expect(spy4).not.toBeCalled(); + + observer1.unsubscribe(); + expect(spy3).toBeCalled(); + expect(spy4).not.toBeCalled(); + + observer2.unsubscribe(); + expect(spy4).toBeCalled(); + + observer3.unsubscribe(); + observer4.unsubscribe(); + }); +}); diff --git a/x-pack/plugins/ingest_manager/server/services/agents/checkin/rxjs_utils.ts b/x-pack/plugins/ingest_manager/server/services/agents/checkin/rxjs_utils.ts index a806169019a1..dc0ed35207e4 100644 --- a/x-pack/plugins/ingest_manager/server/services/agents/checkin/rxjs_utils.ts +++ b/x-pack/plugins/ingest_manager/server/services/agents/checkin/rxjs_utils.ts @@ -43,34 +43,23 @@ export const toPromiseAbortable = ( } }); -export function createLimiter(ratelimitIntervalMs: number, ratelimitRequestPerInterval: number) { - function createCurrentInterval() { - return { - startedAt: Rx.asyncScheduler.now(), - numRequests: 0, - }; - } - - let currentInterval: { startedAt: number; numRequests: number } = createCurrentInterval(); +export function createSubscriberConcurrencyLimiter(maxConcurrency: number) { let observers: Array<[Rx.Subscriber, any]> = []; - let timerSubscription: Rx.Subscription | undefined; + let activeObservers: Array> = []; - function createTimeout() { - if (timerSubscription) { + function processNext() { + if (activeObservers.length >= maxConcurrency) { return; } - timerSubscription = Rx.asyncScheduler.schedule(() => { - timerSubscription = undefined; - currentInterval = createCurrentInterval(); - for (const [waitingObserver, value] of observers) { - if (currentInterval.numRequests >= ratelimitRequestPerInterval) { - createTimeout(); - continue; - } - currentInterval.numRequests++; - waitingObserver.next(value); - } - }, ratelimitIntervalMs); + const observerValuePair = observers.shift(); + + if (!observerValuePair) { + return; + } + + const [observer, value] = observerValuePair; + activeObservers.push(observer); + observer.next(value); } return function limit(): Rx.MonoTypeOperatorFunction { @@ -78,14 +67,8 @@ export function createLimiter(ratelimitIntervalMs: number, ratelimitRequestPerIn new Rx.Observable((observer) => { const subscription = observable.subscribe({ next(value) { - if (currentInterval.numRequests < ratelimitRequestPerInterval) { - currentInterval.numRequests++; - observer.next(value); - return; - } - observers = [...observers, [observer, value]]; - createTimeout(); + processNext(); }, error(err) { observer.error(err); @@ -96,8 +79,10 @@ export function createLimiter(ratelimitIntervalMs: number, ratelimitRequestPerIn }); return () => { + activeObservers = activeObservers.filter((o) => o !== observer); observers = observers.filter((o) => o[0] !== observer); subscription.unsubscribe(); + processNext(); }; }); }; diff --git a/x-pack/plugins/ingest_manager/server/services/agents/checkin/state.ts b/x-pack/plugins/ingest_manager/server/services/agents/checkin/state.ts index 69d61171b21f..63f22b82611c 100644 --- a/x-pack/plugins/ingest_manager/server/services/agents/checkin/state.ts +++ b/x-pack/plugins/ingest_manager/server/services/agents/checkin/state.ts @@ -13,9 +13,11 @@ import { AGENT_UPDATE_LAST_CHECKIN_INTERVAL_MS } from '../../../constants'; function agentCheckinStateFactory() { const agentConnected = agentCheckinStateConnectedAgentsFactory(); - const newActions = agentCheckinStateNewActionsFactory(); + let newActions: ReturnType; let interval: NodeJS.Timeout; + function start() { + newActions = agentCheckinStateNewActionsFactory(); interval = setInterval(async () => { try { await agentConnected.updateLastCheckinAt(); @@ -31,15 +33,20 @@ function agentCheckinStateFactory() { } } return { - subscribeToNewActions: ( + subscribeToNewActions: async ( soClient: SavedObjectsClientContract, agent: Agent, options?: { signal: AbortSignal } - ) => - agentConnected.wrapPromise( + ) => { + if (!newActions) { + throw new Error('Agent checkin state not initialized'); + } + + return agentConnected.wrapPromise( agent.id, newActions.subscribeToNewActions(soClient, agent, options) - ), + ); + }, start, stop, }; diff --git a/x-pack/plugins/ingest_manager/server/services/agents/checkin/state_new_actions.ts b/x-pack/plugins/ingest_manager/server/services/agents/checkin/state_new_actions.ts index 5ceb774a1946..53270afe453c 100644 --- a/x-pack/plugins/ingest_manager/server/services/agents/checkin/state_new_actions.ts +++ b/x-pack/plugins/ingest_manager/server/services/agents/checkin/state_new_actions.ts @@ -28,7 +28,7 @@ import * as APIKeysService from '../../api_keys'; import { AGENT_SAVED_OBJECT_TYPE, AGENT_UPDATE_ACTIONS_INTERVAL_MS } from '../../../constants'; import { createAgentAction, getNewActionsSince } from '../actions'; import { appContextService } from '../../app_context'; -import { toPromiseAbortable, AbortError, createLimiter } from './rxjs_utils'; +import { toPromiseAbortable, AbortError, createSubscriberConcurrencyLimiter } from './rxjs_utils'; function getInternalUserSOClient() { const fakeRequest = ({ @@ -134,9 +134,8 @@ export function agentCheckinStateNewActionsFactory() { const agentConfigs$ = new Map>(); const newActions$ = createNewActionsSharedObservable(); // Rx operators - const rateLimiter = createLimiter( - appContextService.getConfig()?.fleet.agentConfigRollupRateLimitIntervalMs || 5000, - appContextService.getConfig()?.fleet.agentConfigRollupRateLimitRequestPerInterval || 50 + const concurrencyLimiter = createSubscriberConcurrencyLimiter( + appContextService.getConfig()?.fleet.agentConfigRolloutConcurrency ?? 10 ); async function subscribeToNewActions( @@ -155,10 +154,11 @@ export function agentCheckinStateNewActionsFactory() { if (!agentConfig$) { throw new Error(`Invalid state no observable for config ${configId}`); } + const stream$ = agentConfig$.pipe( timeout(appContextService.getConfig()?.fleet.pollingRequestTimeout || 0), filter((config) => shouldCreateAgentConfigAction(agent, config)), - rateLimiter(), + concurrencyLimiter(), mergeMap((config) => createAgentActionFromConfig(soClient, agent, config)), merge(newActions$), mergeMap(async (data) => { From 49782f93480b8d016ebf36bda18a6d855be5aff2 Mon Sep 17 00:00:00 2001 From: Pierre Gayvallet Date: Thu, 23 Jul 2020 14:48:13 +0200 Subject: [PATCH 090/142] delete legacy apm_oss plugin (#73016) --- src/legacy/core_plugins/apm_oss/index.d.ts | 22 ------- src/legacy/core_plugins/apm_oss/index.js | 60 -------------------- src/legacy/core_plugins/apm_oss/package.json | 4 -- src/legacy/server/kbn_server.d.ts | 2 - 4 files changed, 88 deletions(-) delete mode 100644 src/legacy/core_plugins/apm_oss/index.d.ts delete mode 100644 src/legacy/core_plugins/apm_oss/index.js delete mode 100644 src/legacy/core_plugins/apm_oss/package.json diff --git a/src/legacy/core_plugins/apm_oss/index.d.ts b/src/legacy/core_plugins/apm_oss/index.d.ts deleted file mode 100644 index 86fe4e0350dc..000000000000 --- a/src/legacy/core_plugins/apm_oss/index.d.ts +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -export interface ApmOssPlugin { - indexPatterns: string[]; -} diff --git a/src/legacy/core_plugins/apm_oss/index.js b/src/legacy/core_plugins/apm_oss/index.js deleted file mode 100644 index b7ab6797c0de..000000000000 --- a/src/legacy/core_plugins/apm_oss/index.js +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import _ from 'lodash'; - -export default function apmOss(kibana) { - return new kibana.Plugin({ - id: 'apm_oss', - - config(Joi) { - return Joi.object({ - // enable plugin - enabled: Joi.boolean().default(true), - - // Kibana Index pattern - indexPattern: Joi.string().default('apm-*'), - - // ES Indices - sourcemapIndices: Joi.string().default('apm-*'), - errorIndices: Joi.string().default('apm-*'), - transactionIndices: Joi.string().default('apm-*'), - spanIndices: Joi.string().default('apm-*'), - metricsIndices: Joi.string().default('apm-*'), - onboardingIndices: Joi.string().default('apm-*'), - }).default(); - }, - - init(server) { - server.expose( - 'indexPatterns', - _.uniq( - [ - 'sourcemapIndices', - 'errorIndices', - 'transactionIndices', - 'spanIndices', - 'metricsIndices', - 'onboardingIndices', - ].map((type) => server.config().get(`apm_oss.${type}`)) - ) - ); - }, - }); -} diff --git a/src/legacy/core_plugins/apm_oss/package.json b/src/legacy/core_plugins/apm_oss/package.json deleted file mode 100644 index 4ca161f293e7..000000000000 --- a/src/legacy/core_plugins/apm_oss/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "apm_oss", - "version": "kibana" -} diff --git a/src/legacy/server/kbn_server.d.ts b/src/legacy/server/kbn_server.d.ts index 40996500bfbe..9bb091383ab1 100644 --- a/src/legacy/server/kbn_server.d.ts +++ b/src/legacy/server/kbn_server.d.ts @@ -43,7 +43,6 @@ import { import { LegacyConfig, ILegacyService, ILegacyInternals } from '../../core/server/legacy'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { UiPlugins } from '../../core/server/plugins'; -import { ApmOssPlugin } from '../core_plugins/apm_oss'; import { CallClusterWithRequest, ElasticsearchPlugin } from '../core_plugins/elasticsearch'; import { UsageCollectionSetup } from '../../plugins/usage_collection/server'; import { UiSettingsServiceFactoryOptions } from '../../legacy/ui/ui_settings/ui_settings_service_factory'; @@ -62,7 +61,6 @@ declare module 'hapi' { elasticsearch: ElasticsearchPlugin; kibana: any; spaces: any; - apm_oss: ApmOssPlugin; // add new plugin types here } From 304445f007899681572a888cb45d35ef7e102d7c Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Thu, 23 Jul 2020 06:27:06 -0700 Subject: [PATCH 091/142] =?UTF-8?q?fix:=20=F0=9F=90=9B=20don't=20show=20ac?= =?UTF-8?q?tions=20if=20Discover=20app=20is=20disabled=20(#73017)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 🐛 don't show actions if Discover app is disabled * style: collapse ifs --- .../explore_data/abstract_explore_data_action.ts | 6 +++++- .../explore_data/explore_data_chart_action.test.ts | 13 +++++++++++++ .../explore_data_context_menu_action.test.ts | 13 +++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/discover_enhanced/public/actions/explore_data/abstract_explore_data_action.ts b/x-pack/plugins/discover_enhanced/public/actions/explore_data/abstract_explore_data_action.ts index 3aec0ce238c3..434d38c76d42 100644 --- a/x-pack/plugins/discover_enhanced/public/actions/explore_data/abstract_explore_data_action.ts +++ b/x-pack/plugins/discover_enhanced/public/actions/explore_data/abstract_explore_data_action.ts @@ -49,12 +49,16 @@ export abstract class AbstractExploreDataAction { if (!embeddable) return false; + const { core, plugins } = this.params.start(); + const { capabilities } = core.application; + + if (capabilities.discover && !capabilities.discover.show) return false; + if (!plugins.discover.urlGenerator) return false; const isDashboardOnlyMode = !!this.params .start() .plugins.kibanaLegacy?.dashboardConfig.getHideWriteControls(); if (isDashboardOnlyMode) return false; - if (!this.params.start().plugins.discover.urlGenerator) return false; if (!shared.hasExactlyOneIndexPattern(embeddable)) return false; if (embeddable.getInput().viewMode !== ViewMode.VIEW) return false; return true; diff --git a/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_chart_action.test.ts b/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_chart_action.test.ts index 6c3ed7a2fe77..14cd48ae1f50 100644 --- a/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_chart_action.test.ts +++ b/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_chart_action.test.ts @@ -196,6 +196,19 @@ describe('"Explore underlying data" panel action', () => { expect(isCompatible).toBe(false); }); + + test('returns false if Discover app is disabled', async () => { + const { action, context, core } = setup(); + + core.application.capabilities = { ...core.application.capabilities }; + (core.application.capabilities as any).discover = { + show: false, + }; + + const isCompatible = await action.isCompatible(context); + + expect(isCompatible).toBe(false); + }); }); describe('getHref()', () => { diff --git a/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_context_menu_action.test.ts b/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_context_menu_action.test.ts index 1422cc871cde..68253655af89 100644 --- a/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_context_menu_action.test.ts +++ b/x-pack/plugins/discover_enhanced/public/actions/explore_data/explore_data_context_menu_action.test.ts @@ -179,6 +179,19 @@ describe('"Explore underlying data" panel action', () => { expect(isCompatible).toBe(false); }); + + test('returns false if Discover app is disabled', async () => { + const { action, context, core } = setup(); + + core.application.capabilities = { ...core.application.capabilities }; + (core.application.capabilities as any).discover = { + show: false, + }; + + const isCompatible = await action.isCompatible(context); + + expect(isCompatible).toBe(false); + }); }); describe('getHref()', () => { From 7280b69e9942866489da7f2f03376f7508bc74c3 Mon Sep 17 00:00:00 2001 From: Madison Caldwell Date: Thu, 23 Jul 2020 09:54:08 -0400 Subject: [PATCH 092/142] [Security Solution][Exceptions] Preserve rule exceptions when updating rule (#72977) * Send exceptions_list with rule edit * Handle exceptions list checkbox * whoops * Don't lose data when associating with endpoint list * syntax * Filter out the endpoint lists when disassociating * Add tests * Refactor per PR suggestions Co-authored-by: Elastic Machine --- .../rules/all/__mocks__/mock.ts | 7 +++ .../rules/create/helpers.test.ts | 57 +++++++++++++++++++ .../detection_engine/rules/create/helpers.ts | 22 +++++-- .../detection_engine/rules/edit/index.tsx | 3 +- 4 files changed, 84 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/__mocks__/mock.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/__mocks__/mock.ts index 10d969ae7e6e..14cf476e6656 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/__mocks__/mock.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/__mocks__/mock.ts @@ -6,6 +6,7 @@ import { esFilters } from '../../../../../../../../../../src/plugins/data/public'; import { Rule, RuleError } from '../../../../../containers/detection_engine/rules'; +import { List } from '../../../../../../../common/detection_engine/schemas/types'; import { AboutStepRule, ActionsStepRule, DefineStepRule, ScheduleStepRule } from '../../types'; import { FieldValueQueryBar } from '../../../../../components/rules/query_bar'; @@ -240,3 +241,9 @@ export const mockRules: Rule[] = [ mockRule('abe6c564-050d-45a5-aaf0-386c37dd1f61'), mockRule('63f06f34-c181-4b2d-af35-f2ace572a1ee'), ]; + +export const mockExceptionsList: List = { + namespace_type: 'single', + id: '75cd4380-cc5e-11ea-9101-5b34f44aeb44', + type: 'detection', +}; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.test.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.test.ts index 745518b90df0..6458d2faa246 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.test.ts @@ -4,7 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ +import { List } from '../../../../../../common/detection_engine/schemas/types'; +import { ENDPOINT_LIST_ID } from '../../../../../shared_imports'; import { NewRule } from '../../../../containers/detection_engine/rules'; + import { DefineStepRuleJson, ScheduleStepRuleJson, @@ -26,12 +29,19 @@ import { } from './helpers'; import { mockDefineStepRule, + mockExceptionsList, mockQueryBar, mockScheduleStepRule, mockAboutStepRule, mockActionsStepRule, } from '../all/__mocks__/mock'; +const ENDPOINT_LIST = { + id: ENDPOINT_LIST_ID, + namespace_type: 'agnostic', + type: 'endpoint', +} as List; + describe('helpers', () => { describe('getTimeTypeValue', () => { test('returns timeObj with value 0 if no time value found', () => { @@ -373,6 +383,53 @@ describe('helpers', () => { expect(result).toEqual(expected); }); + test('returns formatted object with endpoint exceptions_list', () => { + const result: AboutStepRuleJson = formatAboutStepData( + { + ...mockData, + isAssociatedToEndpointList: true, + }, + [] + ); + expect(result.exceptions_list).toEqual([ + { id: ENDPOINT_LIST_ID, namespace_type: 'agnostic', type: 'endpoint' }, + ]); + }); + + test('returns formatted object with detections exceptions_list', () => { + const result: AboutStepRuleJson = formatAboutStepData(mockData, [mockExceptionsList]); + expect(result.exceptions_list).toEqual([mockExceptionsList]); + }); + + test('returns formatted object with both exceptions_lists', () => { + const result: AboutStepRuleJson = formatAboutStepData( + { + ...mockData, + isAssociatedToEndpointList: true, + }, + [mockExceptionsList] + ); + expect(result.exceptions_list).toEqual([ENDPOINT_LIST, mockExceptionsList]); + }); + + test('returns formatted object with pre-existing exceptions lists', () => { + const exceptionsLists: List[] = [ENDPOINT_LIST, mockExceptionsList]; + const result: AboutStepRuleJson = formatAboutStepData( + { + ...mockData, + isAssociatedToEndpointList: true, + }, + exceptionsLists + ); + expect(result.exceptions_list).toEqual(exceptionsLists); + }); + + test('returns formatted object with pre-existing endpoint exceptions list disabled', () => { + const exceptionsLists: List[] = [ENDPOINT_LIST, mockExceptionsList]; + const result: AboutStepRuleJson = formatAboutStepData(mockData, exceptionsLists); + expect(result.exceptions_list).toEqual([mockExceptionsList]); + }); + test('returns formatted object with empty falsePositive and references filtered out', () => { const mockStepData = { ...mockData, diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.ts index 38f7836f678f..a972afbd8c0c 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.ts @@ -12,8 +12,9 @@ import { NOTIFICATION_THROTTLE_NO_ACTIONS } from '../../../../../../common/const import { transformAlertToRuleAction } from '../../../../../../common/detection_engine/transform_actions'; import { RuleType } from '../../../../../../common/detection_engine/types'; import { isMlRule } from '../../../../../../common/machine_learning/helpers'; +import { List } from '../../../../../../common/detection_engine/schemas/types'; import { ENDPOINT_LIST_ID } from '../../../../../shared_imports'; -import { NewRule } from '../../../../containers/detection_engine/rules'; +import { NewRule, Rule } from '../../../../containers/detection_engine/rules'; import { AboutStepRule, @@ -146,7 +147,10 @@ export const formatScheduleStepData = (scheduleData: ScheduleStepRule): Schedule }; }; -export const formatAboutStepData = (aboutStepData: AboutStepRule): AboutStepRuleJson => { +export const formatAboutStepData = ( + aboutStepData: AboutStepRule, + exceptionsList?: List[] +): AboutStepRuleJson => { const { author, falsePositives, @@ -162,6 +166,10 @@ export const formatAboutStepData = (aboutStepData: AboutStepRule): AboutStepRule timestampOverride, ...rest } = aboutStepData; + + const detectionExceptionLists = + exceptionsList != null ? exceptionsList.filter((list) => list.type !== 'endpoint') : []; + const resp = { author: author.filter((item) => !isEmpty(item)), ...(isBuildingBlock ? { building_block_type: 'default' } : {}), @@ -169,8 +177,13 @@ export const formatAboutStepData = (aboutStepData: AboutStepRule): AboutStepRule ? { exceptions_list: [ { id: ENDPOINT_LIST_ID, namespace_type: 'agnostic', type: 'endpoint' }, + ...detectionExceptionLists, ] as AboutStepRuleJson['exceptions_list'], } + : exceptionsList != null + ? { + exceptions_list: [...detectionExceptionLists], + } : {}), false_positives: falsePositives.filter((item) => !isEmpty(item)), references: references.filter((item) => !isEmpty(item)), @@ -218,11 +231,12 @@ export const formatRule = ( defineStepData: DefineStepRule, aboutStepData: AboutStepRule, scheduleData: ScheduleStepRule, - actionsData: ActionsStepRule + actionsData: ActionsStepRule, + rule?: Rule | null ): NewRule => deepmerge.all([ formatDefineStepData(defineStepData), - formatAboutStepData(aboutStepData), + formatAboutStepData(aboutStepData, rule?.exceptions_list), formatScheduleStepData(scheduleData), formatActionsStepData(actionsData), ]) as NewRule; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.tsx index 0900cdb8f478..3cc874b85ecf 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.tsx @@ -273,7 +273,8 @@ const EditRulePageComponent: FC = () => { : myScheduleRuleForm.data) as ScheduleStepRule, (activeFormId === RuleStep.ruleActions ? activeForm.data - : myActionsRuleForm.data) as ActionsStepRule + : myActionsRuleForm.data) as ActionsStepRule, + rule ), ...(ruleId ? { id: ruleId } : {}), }); From 1ee3cdb03db5a94636b90d5ffefb4ef868a7bb81 Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Thu, 23 Jul 2020 17:00:16 +0300 Subject: [PATCH 093/142] [Functional Tests] Unskip tsvb timeseries test (#73011) * [Functional Tests] Unskip tsvb timeseries test * Add retry to dropdown selection when element is not found to headless mode --- test/functional/apps/visualize/_tsvb_time_series.ts | 2 +- test/functional/page_objects/visual_builder_page.ts | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/test/functional/apps/visualize/_tsvb_time_series.ts b/test/functional/apps/visualize/_tsvb_time_series.ts index e0d512c1f486..c048755fc5fb 100644 --- a/test/functional/apps/visualize/_tsvb_time_series.ts +++ b/test/functional/apps/visualize/_tsvb_time_series.ts @@ -107,7 +107,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { expect(actualCount).to.be(expectedLegendValue); }); - it.skip('should show the correct count in the legend with "Human readable" duration formatter', async () => { + it('should show the correct count in the legend with "Human readable" duration formatter', async () => { await visualBuilder.clickSeriesOption(); await visualBuilder.changeDataFormatter('Duration'); await visualBuilder.setDurationFormatterSettings({ to: 'Human readable' }); diff --git a/test/functional/page_objects/visual_builder_page.ts b/test/functional/page_objects/visual_builder_page.ts index 4a4beca95954..0db8cac0f075 100644 --- a/test/functional/page_objects/visual_builder_page.ts +++ b/test/functional/page_objects/visual_builder_page.ts @@ -279,8 +279,10 @@ export function VisualBuilderPageProvider({ getService, getPageObjects }: FtrPro decimalPlaces?: string; }) { if (from) { - const fromCombobox = await find.byCssSelector('[id$="from-row"] .euiComboBox'); - await comboBox.setElement(fromCombobox, from, { clickWithMouse: true }); + await retry.try(async () => { + const fromCombobox = await find.byCssSelector('[id$="from-row"] .euiComboBox'); + await comboBox.setElement(fromCombobox, from, { clickWithMouse: true }); + }); } if (to) { const toCombobox = await find.byCssSelector('[id$="to-row"] .euiComboBox'); From 2cf37a53266c8407e6993f7085e8f204f1ad4780 Mon Sep 17 00:00:00 2001 From: Matthew Kime Date: Thu, 23 Jul 2020 09:34:56 -0500 Subject: [PATCH 094/142] Don't skip index pattern creation test (#73032) --- .../functional/apps/management/_create_index_pattern_wizard.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/functional/apps/management/_create_index_pattern_wizard.js b/test/functional/apps/management/_create_index_pattern_wizard.js index 160b052e70d3..976052737140 100644 --- a/test/functional/apps/management/_create_index_pattern_wizard.js +++ b/test/functional/apps/management/_create_index_pattern_wizard.js @@ -25,8 +25,7 @@ export default function ({ getService, getPageObjects }) { const es = getService('legacyEs'); const PageObjects = getPageObjects(['settings', 'common']); - // Flaky: https://github.com/elastic/kibana/issues/71501 - describe.skip('"Create Index Pattern" wizard', function () { + describe('"Create Index Pattern" wizard', function () { before(async function () { // delete .kibana index and then wait for Kibana to re-create it await kibanaServer.uiSettings.replace({}); From 15ccdc36cae57f34aa070818776fc453fdbdcb68 Mon Sep 17 00:00:00 2001 From: Tyler Smalley Date: Thu, 23 Jul 2020 07:50:30 -0700 Subject: [PATCH 095/142] [test] Skips flaky uptime test Signed-off-by: Tyler Smalley --- x-pack/test/functional/apps/uptime/settings.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/test/functional/apps/uptime/settings.ts b/x-pack/test/functional/apps/uptime/settings.ts index 1286a9940c02..a258cccffbd8 100644 --- a/x-pack/test/functional/apps/uptime/settings.ts +++ b/x-pack/test/functional/apps/uptime/settings.ts @@ -16,8 +16,8 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const es = getService('es'); - // Flaky https://github.com/elastic/kibana/issues/60866 - describe('uptime settings page', () => { + // Flaky https://github.com/elastic/kibana/issues/72994 + describe.skip('uptime settings page', () => { beforeEach('navigate to clean app root', async () => { // make 10 checks await makeChecks(es, 'myMonitor', 1, 1, 1); From 2d9eaf013bae31adc72578065982833ea6934f0e Mon Sep 17 00:00:00 2001 From: Daniil Suleiman <31325372+sulemanof@users.noreply.github.com> Date: Thu, 23 Jul 2020 18:00:52 +0300 Subject: [PATCH 096/142] Fix view saved search through a visualization (#73040) --- .../components/sidebar/sidebar_title.tsx | 9 ++++++-- .../apps/visualize/_linked_saved_searches.ts | 22 +++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/plugins/vis_default_editor/public/components/sidebar/sidebar_title.tsx b/src/plugins/vis_default_editor/public/components/sidebar/sidebar_title.tsx index 6713c2ce2391..11ceb5885dd3 100644 --- a/src/plugins/vis_default_editor/public/components/sidebar/sidebar_title.tsx +++ b/src/plugins/vis_default_editor/public/components/sidebar/sidebar_title.tsx @@ -65,7 +65,7 @@ export function LinkedSearch({ savedSearch, eventEmitter }: LinkedSearchProps) { }, [eventEmitter]); const onClickViewInDiscover = useCallback(() => { application.navigateToApp('discover', { - path: `#/${savedSearch.id}`, + path: `#/view/${savedSearch.id}`, }); }, [application, savedSearch.id]); @@ -128,7 +128,12 @@ export function LinkedSearch({ savedSearch, eventEmitter }: LinkedSearchProps) {

- + { const savedSearchName = 'vis_saved_search'; + let discoverSavedSearchUrlPath: string; before(async () => { await PageObjects.common.navigateToApp('discover'); await filterBar.addFilter('extension.raw', 'is', 'jpg'); await PageObjects.header.waitUntilLoadingHasFinished(); await PageObjects.discover.saveSearch(savedSearchName); + discoverSavedSearchUrlPath = (await browser.getCurrentUrl()).split('?')[0]; }); it('should create a visualization from a saved search', async () => { @@ -54,6 +58,24 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); }); + it('should have a valid link to the saved search from the visualization', async () => { + await testSubjects.click('showUnlinkSavedSearchPopover'); + await testSubjects.click('viewSavedSearch'); + await PageObjects.header.waitUntilLoadingHasFinished(); + + await retry.waitFor('wait discover load its breadcrumbs', async () => { + const discoverBreadcrumb = await PageObjects.discover.getCurrentQueryName(); + return discoverBreadcrumb === savedSearchName; + }); + + const discoverURLPath = (await browser.getCurrentUrl()).split('?')[0]; + expect(discoverURLPath).to.equal(discoverSavedSearchUrlPath); + + // go back to visualize + await browser.goBack(); + await PageObjects.header.waitUntilLoadingHasFinished(); + }); + it('should respect the time filter when linked to a saved search', async () => { await PageObjects.timePicker.setAbsoluteRange( 'Sep 19, 2015 @ 06:31:44.000', From 18df677da7efa30dec36e8a63b175dc5cd71e6be Mon Sep 17 00:00:00 2001 From: James Gowdy Date: Thu, 23 Jul 2020 16:11:15 +0100 Subject: [PATCH 097/142] [ML] Fixing file import, module creation and results viewing permission checks (#72825) * [ML] Fixing file import and module creation permission checks * correcting searches on results index * fixing test * removing unnecessary index * updating apidoc * fixing test Co-authored-by: Elastic Machine --- .../plugins/ml/common/types/capabilities.ts | 13 +- .../components/bottom_bar/bottom_bar.tsx | 2 +- .../components/import_view/import_view.js | 59 +++++----- .../components/custom_url_editor/utils.js | 11 +- .../new_job/common/results_loader/searches.ts | 111 +++++++++--------- .../application/services/forecast_service.js | 84 +++++++------ .../services/ml_api_service/results.ts | 18 +++ .../results_service/result_service_rx.ts | 12 +- .../results_service/results_service.js | 41 +++---- .../ml/server/lib/check_annotations/index.ts | 8 +- .../annotation_service/annotation.test.ts | 22 ++-- .../models/annotation_service/annotation.ts | 8 +- .../analytics_audit_messages.ts | 4 +- .../job_audit_messages/job_audit_messages.js | 6 +- .../ml/server/models/job_service/jobs.ts | 4 +- .../new_job/categorization/top_categories.ts | 8 +- .../get_partition_fields_values.ts | 3 +- .../models/results_service/results_service.ts | 12 +- x-pack/plugins/ml/server/routes/apidoc.json | 1 + .../ml/server/routes/data_frame_analytics.ts | 2 +- .../ml/server/routes/results_service.ts | 33 ++++++ .../shared_services/providers/system.ts | 4 +- .../services/ml/data_visualizer_file_based.ts | 2 +- 23 files changed, 255 insertions(+), 213 deletions(-) diff --git a/x-pack/plugins/ml/common/types/capabilities.ts b/x-pack/plugins/ml/common/types/capabilities.ts index f2177b0a3572..504cd28b8fa1 100644 --- a/x-pack/plugins/ml/common/types/capabilities.ts +++ b/x-pack/plugins/ml/common/types/capabilities.ts @@ -72,6 +72,7 @@ export function getPluginPrivileges() { const adminMlCapabilitiesKeys = Object.keys(adminMlCapabilities); const allMlCapabilitiesKeys = [...adminMlCapabilitiesKeys, ...userMlCapabilitiesKeys]; // TODO: include ML in base privileges for the `8.0` release: https://github.com/elastic/kibana/issues/71422 + const savedObjects = ['index-pattern', 'dashboard', 'search', 'visualization']; const privilege = { app: [PLUGIN_ID, 'kibana'], excludeFromBasePrivileges: true, @@ -79,10 +80,6 @@ export function getPluginPrivileges() { insightsAndAlerting: ['jobsListLink'], }, catalogue: [PLUGIN_ID], - savedObject: { - all: [], - read: ['index-pattern', 'dashboard', 'search', 'visualization'], - }, }; return { @@ -90,11 +87,19 @@ export function getPluginPrivileges() { ...privilege, api: allMlCapabilitiesKeys.map((k) => `ml:${k}`), ui: allMlCapabilitiesKeys, + savedObject: { + all: savedObjects, + read: savedObjects, + }, }, user: { ...privilege, api: userMlCapabilitiesKeys.map((k) => `ml:${k}`), ui: userMlCapabilitiesKeys, + savedObject: { + all: [], + read: savedObjects, + }, }, }; } diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/bottom_bar/bottom_bar.tsx b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/bottom_bar/bottom_bar.tsx index e28386093abe..8b6c16a71651 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/bottom_bar/bottom_bar.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/bottom_bar/bottom_bar.tsx @@ -39,7 +39,7 @@ export const BottomBar: FC = ({ mode, onChangeMode, onCancel, di disableImport ? ( ) : null } diff --git a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_view/import_view.js b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_view/import_view.js index 64d2e26f827f..36b77a5a25e0 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_view/import_view.js +++ b/x-pack/plugins/ml/public/application/datavisualizer/file_based/components/import_view/import_view.js @@ -18,6 +18,7 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { debounce } from 'lodash'; import { importerFactory } from './importer'; import { ResultsLinks } from '../results_links'; import { FilebeatConfigFlyout } from '../filebeat_config_flyout'; @@ -66,6 +67,7 @@ const DEFAULT_STATE = { indexPatternNameError: '', timeFieldName: undefined, isFilebeatFlyoutVisible: false, + checkingValidIndex: false, }; export class ImportView extends Component { @@ -76,14 +78,12 @@ export class ImportView extends Component { } componentDidMount() { - this.loadIndexNames(); this.loadIndexPatternNames(); } clickReset = () => { const state = getDefaultState(this.state, this.props.results); this.setState(state, () => { - this.loadIndexNames(); this.loadIndexPatternNames(); }); }; @@ -326,21 +326,33 @@ export class ImportView extends Component { }; onIndexChange = (e) => { - const name = e.target.value; - const { indexNames, indexPattern, indexPatternNames } = this.state; - + const index = e.target.value; this.setState({ - index: name, - indexNameError: isIndexNameValid(name, indexNames), - // if index pattern has been altered, check that it still matches the inputted index - ...(indexPattern === '' - ? {} - : { - indexPatternNameError: isIndexPatternNameValid(indexPattern, indexPatternNames, name), - }), + index, + checkingValidIndex: true, }); + this.debounceIndexCheck(index); }; + debounceIndexCheck = debounce(async (index) => { + if (index === '') { + this.setState({ checkingValidIndex: false }); + return; + } + + const { exists } = await ml.checkIndexExists({ index }); + const indexNameError = exists ? ( + + ) : ( + isIndexNameValid(index) + ); + + this.setState({ checkingValidIndex: false, indexNameError }); + }, 500); + onIndexPatternChange = (e) => { const name = e.target.value; const { indexPatternNames, index } = this.state; @@ -396,12 +408,6 @@ export class ImportView extends Component { this.props.showBottomBar(); }; - async loadIndexNames() { - const indices = await ml.getIndices(); - const indexNames = indices.map((i) => i.name); - this.setState({ indexNames }); - } - async loadIndexPatternNames() { await loadIndexPatterns(this.props.indexPatterns); const indexPatternNames = getIndexPatternNames(); @@ -437,6 +443,7 @@ export class ImportView extends Component { indexPatternNameError, timeFieldName, isFilebeatFlyoutVisible, + checkingValidIndex, } = this.state; const createPipeline = pipelineString !== ''; @@ -459,7 +466,8 @@ export class ImportView extends Component { index === '' || indexNameError !== '' || (createIndexPattern === true && indexPatternNameError !== '') || - initialized === true; + initialized === true || + checkingValidIndex === true; return ( @@ -655,16 +663,7 @@ function getDefaultState(state, results) { }; } -function isIndexNameValid(name, indexNames) { - if (indexNames.find((i) => i === name)) { - return ( - - ); - } - +function isIndexNameValid(name) { const reg = new RegExp('[\\\\/*?"<>|\\s,#]+'); if ( name !== name.toLowerCase() || // name should be lowercase diff --git a/x-pack/plugins/ml/public/application/jobs/components/custom_url_editor/utils.js b/x-pack/plugins/ml/public/application/jobs/components/custom_url_editor/utils.js index 0b33efa3f9ff..87c2219f4d44 100644 --- a/x-pack/plugins/ml/public/application/jobs/components/custom_url_editor/utils.js +++ b/x-pack/plugins/ml/public/application/jobs/components/custom_url_editor/utils.js @@ -11,7 +11,6 @@ import url from 'url'; import { DASHBOARD_APP_URL_GENERATOR } from '../../../../../../../../src/plugins/dashboard/public'; -import { ML_RESULTS_INDEX_PATTERN } from '../../../../../common/constants/index_patterns'; import { getPartitioningFieldNames } from '../../../../../common/util/job_utils'; import { parseInterval } from '../../../../../common/util/parse_interval'; import { replaceTokensInUrlValue, isValidLabel } from '../../../util/custom_url_utils'; @@ -295,11 +294,11 @@ export function getTestUrl(job, customUrl) { }; return new Promise((resolve, reject) => { - ml.esSearch({ - index: ML_RESULTS_INDEX_PATTERN, - rest_total_hits_as_int: true, - body, - }) + ml.results + .anomalySearch({ + rest_total_hits_as_int: true, + body, + }) .then((resp) => { if (resp.hits.total > 0) { const record = resp.hits.hits[0]._source; diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/common/results_loader/searches.ts b/x-pack/plugins/ml/public/application/jobs/new_job/common/results_loader/searches.ts index 724a6146854a..51c396518c85 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/common/results_loader/searches.ts +++ b/x-pack/plugins/ml/public/application/jobs/new_job/common/results_loader/searches.ts @@ -6,7 +6,6 @@ import { get } from 'lodash'; -import { ML_RESULTS_INDEX_PATTERN } from '../../../../../../common/constants/index_patterns'; import { escapeForElasticsearchQuery } from '../../../../util/string_utils'; import { ml } from '../../../../services/ml_api_service'; @@ -53,69 +52,70 @@ export function getScoresByRecord( jobIdFilterStr += `"${String(firstSplitField.value).replace(/\\/g, '\\\\')}"`; } - ml.esSearch({ - index: ML_RESULTS_INDEX_PATTERN, - size: 0, - body: { - query: { - bool: { - filter: [ - { - query_string: { - query: 'result_type:record', + ml.results + .anomalySearch({ + size: 0, + body: { + query: { + bool: { + filter: [ + { + query_string: { + query: 'result_type:record', + }, }, - }, - { - bool: { - must: [ - { - range: { - timestamp: { - gte: earliestMs, - lte: latestMs, - format: 'epoch_millis', + { + bool: { + must: [ + { + range: { + timestamp: { + gte: earliestMs, + lte: latestMs, + format: 'epoch_millis', + }, }, }, - }, - { - query_string: { - query: jobIdFilterStr, + { + query_string: { + query: jobIdFilterStr, + }, }, - }, - ], + ], + }, }, - }, - ], - }, - }, - aggs: { - detector_index: { - terms: { - field: 'detector_index', - order: { - recordScore: 'desc', - }, + ], }, - aggs: { - recordScore: { - max: { - field: 'record_score', + }, + aggs: { + detector_index: { + terms: { + field: 'detector_index', + order: { + recordScore: 'desc', }, }, - byTime: { - date_histogram: { - field: 'timestamp', - interval, - min_doc_count: 1, - extended_bounds: { - min: earliestMs, - max: latestMs, + aggs: { + recordScore: { + max: { + field: 'record_score', }, }, - aggs: { - recordScore: { - max: { - field: 'record_score', + byTime: { + date_histogram: { + field: 'timestamp', + interval, + min_doc_count: 1, + extended_bounds: { + min: earliestMs, + max: latestMs, + }, + }, + aggs: { + recordScore: { + max: { + field: 'record_score', + }, }, }, }, @@ -123,8 +123,7 @@ export function getScoresByRecord( }, }, }, - }, - }) + }) .then((resp: any) => { const detectorsByIndex = get(resp, ['aggregations', 'detector_index', 'buckets'], []); detectorsByIndex.forEach((dtr: any) => { diff --git a/x-pack/plugins/ml/public/application/services/forecast_service.js b/x-pack/plugins/ml/public/application/services/forecast_service.js index c3d593c3347d..ed5a29ff74a6 100644 --- a/x-pack/plugins/ml/public/application/services/forecast_service.js +++ b/x-pack/plugins/ml/public/application/services/forecast_service.js @@ -9,7 +9,6 @@ import _ from 'lodash'; import { map } from 'rxjs/operators'; -import { ML_RESULTS_INDEX_PATTERN } from '../../../common/constants/index_patterns'; import { ml } from './ml_api_service'; // Gets a basic summary of the most recently run forecasts for the specified @@ -48,19 +47,19 @@ function getForecastsSummary(job, query, earliestMs, maxResults) { filterCriteria.push(query); } - ml.esSearch({ - index: ML_RESULTS_INDEX_PATTERN, - size: maxResults, - rest_total_hits_as_int: true, - body: { - query: { - bool: { - filter: filterCriteria, + ml.results + .anomalySearch({ + size: maxResults, + rest_total_hits_as_int: true, + body: { + query: { + bool: { + filter: filterCriteria, + }, }, + sort: [{ forecast_create_timestamp: { order: 'desc' } }], }, - sort: [{ forecast_create_timestamp: { order: 'desc' } }], - }, - }) + }) .then((resp) => { if (resp.hits.total !== 0) { obj.forecasts = resp.hits.hits.map((hit) => hit._source); @@ -106,29 +105,29 @@ function getForecastDateRange(job, forecastId) { // TODO - add in criteria for detector index and entity fields (by, over, partition) // once forecasting with these parameters is supported. - ml.esSearch({ - index: ML_RESULTS_INDEX_PATTERN, - size: 0, - body: { - query: { - bool: { - filter: filterCriteria, - }, - }, - aggs: { - earliest: { - min: { - field: 'timestamp', + ml.results + .anomalySearch({ + size: 0, + body: { + query: { + bool: { + filter: filterCriteria, }, }, - latest: { - max: { - field: 'timestamp', + aggs: { + earliest: { + min: { + field: 'timestamp', + }, + }, + latest: { + max: { + field: 'timestamp', + }, }, }, }, - }, - }) + }) .then((resp) => { obj.earliest = _.get(resp, 'aggregations.earliest.value', null); obj.latest = _.get(resp, 'aggregations.latest.value', null); @@ -243,9 +242,8 @@ function getForecastData( min: aggType.min, }; - return ml - .esSearch$({ - index: ML_RESULTS_INDEX_PATTERN, + return ml.results + .anomalySearch$({ size: 0, body: { query: { @@ -343,18 +341,18 @@ function getForecastRequestStats(job, forecastId) { }, ]; - ml.esSearch({ - index: ML_RESULTS_INDEX_PATTERN, - size: 1, - rest_total_hits_as_int: true, - body: { - query: { - bool: { - filter: filterCriteria, + ml.results + .anomalySearch({ + size: 1, + rest_total_hits_as_int: true, + body: { + query: { + bool: { + filter: filterCriteria, + }, }, }, - }, - }) + }) .then((resp) => { if (resp.hits.total !== 0) { obj.stats = _.first(resp.hits.hits)._source; diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/results.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/results.ts index 521fd306847e..08c3853ace6f 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/results.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/results.ts @@ -96,4 +96,22 @@ export const resultsApiProvider = (httpService: HttpService) => ({ body, }); }, + + anomalySearch(obj: any) { + const body = JSON.stringify(obj); + return httpService.http({ + path: `${basePath()}/results/anomaly_search`, + method: 'POST', + body, + }); + }, + + anomalySearch$(obj: any) { + const body = JSON.stringify(obj); + return httpService.http$({ + path: `${basePath()}/results/anomaly_search`, + method: 'POST', + body, + }); + }, }); diff --git a/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts b/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts index 1bcbd8dbcdd6..d7f016b41937 100644 --- a/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts +++ b/x-pack/plugins/ml/public/application/services/results_service/result_service_rx.ts @@ -262,8 +262,8 @@ export function resultsServiceRxProvider(mlApiServices: MlApiServices) { }, ]; - return mlApiServices - .esSearch$({ + return mlApiServices.results + .anomalySearch$({ index: ML_RESULTS_INDEX_PATTERN, size: 0, body: { @@ -399,8 +399,8 @@ export function resultsServiceRxProvider(mlApiServices: MlApiServices) { }); }); - return mlApiServices - .esSearch$({ + return mlApiServices.results + .anomalySearch$({ index: ML_RESULTS_INDEX_PATTERN, rest_total_hits_as_int: true, size: maxResults !== undefined ? maxResults : 100, @@ -484,8 +484,8 @@ export function resultsServiceRxProvider(mlApiServices: MlApiServices) { }); } - return mlApiServices - .esSearch$({ + return mlApiServices.results + .anomalySearch$({ index: ML_RESULTS_INDEX_PATTERN, size: 0, body: { diff --git a/x-pack/plugins/ml/public/application/services/results_service/results_service.js b/x-pack/plugins/ml/public/application/services/results_service/results_service.js index 55ddb1de3529..50e2d0a5a2a0 100644 --- a/x-pack/plugins/ml/public/application/services/results_service/results_service.js +++ b/x-pack/plugins/ml/public/application/services/results_service/results_service.js @@ -8,7 +8,6 @@ import _ from 'lodash'; import { ML_MEDIAN_PERCENTS } from '../../../../common/util/job_utils'; import { escapeForElasticsearchQuery } from '../../util/string_utils'; -import { ML_RESULTS_INDEX_PATTERN } from '../../../../common/constants/index_patterns'; import { ANOMALY_SWIM_LANE_HARD_LIMIT, SWIM_LANE_DEFAULT_PAGE_SIZE, @@ -66,9 +65,8 @@ export function resultsServiceProvider(mlApiServices) { }); } - mlApiServices - .esSearch({ - index: ML_RESULTS_INDEX_PATTERN, + mlApiServices.results + .anomalySearch({ size: 0, body: { query: { @@ -238,9 +236,8 @@ export function resultsServiceProvider(mlApiServices) { }); } - mlApiServices - .esSearch({ - index: ML_RESULTS_INDEX_PATTERN, + mlApiServices.results + .anomalySearch({ size: 0, body: { query: { @@ -378,9 +375,8 @@ export function resultsServiceProvider(mlApiServices) { }); } - mlApiServices - .esSearch({ - index: ML_RESULTS_INDEX_PATTERN, + mlApiServices.results + .anomalySearch({ size: 0, body: { query: { @@ -560,9 +556,8 @@ export function resultsServiceProvider(mlApiServices) { }); } - mlApiServices - .esSearch({ - index: ML_RESULTS_INDEX_PATTERN, + mlApiServices.results + .anomalySearch({ size: 0, body: { query: { @@ -721,9 +716,8 @@ export function resultsServiceProvider(mlApiServices) { }); } - mlApiServices - .esSearch({ - index: ML_RESULTS_INDEX_PATTERN, + mlApiServices.results + .anomalySearch({ size: maxResults !== undefined ? maxResults : 100, rest_total_hits_as_int: true, body: { @@ -854,9 +848,8 @@ export function resultsServiceProvider(mlApiServices) { }); } - mlApiServices - .esSearch({ - index: ML_RESULTS_INDEX_PATTERN, + mlApiServices.results + .anomalySearch({ size: maxResults !== undefined ? maxResults : 100, rest_total_hits_as_int: true, body: { @@ -980,9 +973,8 @@ export function resultsServiceProvider(mlApiServices) { } } - mlApiServices - .esSearch({ - index: ML_RESULTS_INDEX_PATTERN, + mlApiServices.results + .anomalySearch({ size: maxResults !== undefined ? maxResults : 100, rest_total_hits_as_int: true, body: { @@ -1307,9 +1299,8 @@ export function resultsServiceProvider(mlApiServices) { }); }); - mlApiServices - .esSearch({ - index: ML_RESULTS_INDEX_PATTERN, + mlApiServices.results + .anomalySearch({ size: 0, body: { query: { diff --git a/x-pack/plugins/ml/server/lib/check_annotations/index.ts b/x-pack/plugins/ml/server/lib/check_annotations/index.ts index fb37917c512c..de19f0ead679 100644 --- a/x-pack/plugins/ml/server/lib/check_annotations/index.ts +++ b/x-pack/plugins/ml/server/lib/check_annotations/index.ts @@ -18,17 +18,17 @@ import { // - ML_ANNOTATIONS_INDEX_ALIAS_READ alias is present // - ML_ANNOTATIONS_INDEX_ALIAS_WRITE alias is present export async function isAnnotationsFeatureAvailable({ - callAsCurrentUser, + callAsInternalUser, }: ILegacyScopedClusterClient) { try { const indexParams = { index: ML_ANNOTATIONS_INDEX_PATTERN }; - const annotationsIndexExists = await callAsCurrentUser('indices.exists', indexParams); + const annotationsIndexExists = await callAsInternalUser('indices.exists', indexParams); if (!annotationsIndexExists) { return false; } - const annotationsReadAliasExists = await callAsCurrentUser('indices.existsAlias', { + const annotationsReadAliasExists = await callAsInternalUser('indices.existsAlias', { index: ML_ANNOTATIONS_INDEX_ALIAS_READ, name: ML_ANNOTATIONS_INDEX_ALIAS_READ, }); @@ -37,7 +37,7 @@ export async function isAnnotationsFeatureAvailable({ return false; } - const annotationsWriteAliasExists = await callAsCurrentUser('indices.existsAlias', { + const annotationsWriteAliasExists = await callAsInternalUser('indices.existsAlias', { index: ML_ANNOTATIONS_INDEX_ALIAS_WRITE, name: ML_ANNOTATIONS_INDEX_ALIAS_WRITE, }); diff --git a/x-pack/plugins/ml/server/models/annotation_service/annotation.test.ts b/x-pack/plugins/ml/server/models/annotation_service/annotation.test.ts index 3bf9bd0232a5..5be443266ffe 100644 --- a/x-pack/plugins/ml/server/models/annotation_service/annotation.test.ts +++ b/x-pack/plugins/ml/server/models/annotation_service/annotation.test.ts @@ -52,8 +52,8 @@ describe('annotation_service', () => { const response = await deleteAnnotation(annotationMockId); - expect(mockFunct.callAsCurrentUser.mock.calls[0][0]).toBe('delete'); - expect(mockFunct.callAsCurrentUser.mock.calls[0][1]).toEqual(deleteParamsMock); + expect(mockFunct.callAsInternalUser.mock.calls[0][0]).toBe('delete'); + expect(mockFunct.callAsInternalUser.mock.calls[0][1]).toEqual(deleteParamsMock); expect(response).toBe(acknowledgedResponseMock); done(); }); @@ -73,8 +73,8 @@ describe('annotation_service', () => { const response: GetResponse = await getAnnotations(indexAnnotationArgsMock); - expect(mockFunct.callAsCurrentUser.mock.calls[0][0]).toBe('search'); - expect(mockFunct.callAsCurrentUser.mock.calls[0][1]).toEqual(getAnnotationsRequestMock); + expect(mockFunct.callAsInternalUser.mock.calls[0][0]).toBe('search'); + expect(mockFunct.callAsInternalUser.mock.calls[0][1]).toEqual(getAnnotationsRequestMock); expect(Object.keys(response.annotations)).toHaveLength(1); expect(response.annotations[jobIdMock]).toHaveLength(2); expect(isAnnotations(response.annotations[jobIdMock])).toBeTruthy(); @@ -89,7 +89,7 @@ describe('annotation_service', () => { }; const mlClusterClientSpyError: any = { - callAsCurrentUser: jest.fn(() => { + callAsInternalUser: jest.fn(() => { return Promise.resolve(mockEsError); }), }; @@ -124,10 +124,10 @@ describe('annotation_service', () => { const response = await indexAnnotation(annotationMock, usernameMock); - expect(mockFunct.callAsCurrentUser.mock.calls[0][0]).toBe('index'); + expect(mockFunct.callAsInternalUser.mock.calls[0][0]).toBe('index'); // test if the annotation has been correctly augmented - const indexParamsCheck = mockFunct.callAsCurrentUser.mock.calls[0][1]; + const indexParamsCheck = mockFunct.callAsInternalUser.mock.calls[0][1]; const annotation = indexParamsCheck.body; expect(annotation.create_username).toBe(usernameMock); expect(annotation.modified_username).toBe(usernameMock); @@ -154,10 +154,10 @@ describe('annotation_service', () => { const response = await indexAnnotation(annotationMock, usernameMock); - expect(mockFunct.callAsCurrentUser.mock.calls[0][0]).toBe('index'); + expect(mockFunct.callAsInternalUser.mock.calls[0][0]).toBe('index'); // test if the annotation has been correctly augmented - const indexParamsCheck = mockFunct.callAsCurrentUser.mock.calls[0][1]; + const indexParamsCheck = mockFunct.callAsInternalUser.mock.calls[0][1]; const annotation = indexParamsCheck.body; expect(annotation.create_username).toBe(usernameMock); expect(annotation.modified_username).toBe(usernameMock); @@ -196,9 +196,9 @@ describe('annotation_service', () => { await indexAnnotation(annotation, modifiedUsernameMock); - expect(mockFunct.callAsCurrentUser.mock.calls[1][0]).toBe('index'); + expect(mockFunct.callAsInternalUser.mock.calls[1][0]).toBe('index'); // test if the annotation has been correctly updated - const indexParamsCheck = mockFunct.callAsCurrentUser.mock.calls[1][1]; + const indexParamsCheck = mockFunct.callAsInternalUser.mock.calls[1][1]; const modifiedAnnotation = indexParamsCheck.body; expect(modifiedAnnotation.annotation).toBe(modifiedAnnotationText); expect(modifiedAnnotation.create_username).toBe(originalUsernameMock); diff --git a/x-pack/plugins/ml/server/models/annotation_service/annotation.ts b/x-pack/plugins/ml/server/models/annotation_service/annotation.ts index f7353034b745..8094689abf3e 100644 --- a/x-pack/plugins/ml/server/models/annotation_service/annotation.ts +++ b/x-pack/plugins/ml/server/models/annotation_service/annotation.ts @@ -76,7 +76,7 @@ export interface DeleteParams { id: string; } -export function annotationProvider({ callAsCurrentUser }: ILegacyScopedClusterClient) { +export function annotationProvider({ callAsInternalUser }: ILegacyScopedClusterClient) { async function indexAnnotation(annotation: Annotation, username: string) { if (isAnnotation(annotation) === false) { // No need to translate, this will not be exposed in the UI. @@ -103,7 +103,7 @@ export function annotationProvider({ callAsCurrentUser }: ILegacyScopedClusterCl delete params.body.key; } - return await callAsCurrentUser('index', params); + return await callAsInternalUser('index', params); } async function getAnnotations({ @@ -286,7 +286,7 @@ export function annotationProvider({ callAsCurrentUser }: ILegacyScopedClusterCl }; try { - const resp = await callAsCurrentUser('search', params); + const resp = await callAsInternalUser('search', params); if (resp.error !== undefined && resp.message !== undefined) { // No need to translate, this will not be exposed in the UI. @@ -335,7 +335,7 @@ export function annotationProvider({ callAsCurrentUser }: ILegacyScopedClusterCl refresh: 'wait_for', }; - return await callAsCurrentUser('delete', param); + return await callAsInternalUser('delete', param); } return { diff --git a/x-pack/plugins/ml/server/models/data_frame_analytics/analytics_audit_messages.ts b/x-pack/plugins/ml/server/models/data_frame_analytics/analytics_audit_messages.ts index c8471b546220..1cb0656e88a0 100644 --- a/x-pack/plugins/ml/server/models/data_frame_analytics/analytics_audit_messages.ts +++ b/x-pack/plugins/ml/server/models/data_frame_analytics/analytics_audit_messages.ts @@ -23,7 +23,7 @@ interface BoolQuery { bool: { [key: string]: any }; } -export function analyticsAuditMessagesProvider({ callAsCurrentUser }: ILegacyScopedClusterClient) { +export function analyticsAuditMessagesProvider({ callAsInternalUser }: ILegacyScopedClusterClient) { // search for audit messages, // analyticsId is optional. without it, all analytics will be listed. async function getAnalyticsAuditMessages(analyticsId: string) { @@ -69,7 +69,7 @@ export function analyticsAuditMessagesProvider({ callAsCurrentUser }: ILegacySco } try { - const resp = await callAsCurrentUser('search', { + const resp = await callAsInternalUser('search', { index: ML_NOTIFICATION_INDEX_PATTERN, ignore_unavailable: true, rest_total_hits_as_int: true, diff --git a/x-pack/plugins/ml/server/models/job_audit_messages/job_audit_messages.js b/x-pack/plugins/ml/server/models/job_audit_messages/job_audit_messages.js index dcbabd879b47..86d80c394137 100644 --- a/x-pack/plugins/ml/server/models/job_audit_messages/job_audit_messages.js +++ b/x-pack/plugins/ml/server/models/job_audit_messages/job_audit_messages.js @@ -34,7 +34,7 @@ const anomalyDetectorTypeFilter = { }, }; -export function jobAuditMessagesProvider({ callAsCurrentUser, callAsInternalUser }) { +export function jobAuditMessagesProvider({ callAsInternalUser }) { // search for audit messages, // jobId is optional. without it, all jobs will be listed. // from is optional and should be a string formatted in ES time units. e.g. 12h, 1d, 7d @@ -100,7 +100,7 @@ export function jobAuditMessagesProvider({ callAsCurrentUser, callAsInternalUser } try { - const resp = await callAsCurrentUser('search', { + const resp = await callAsInternalUser('search', { index: ML_NOTIFICATION_INDEX_PATTERN, ignore_unavailable: true, rest_total_hits_as_int: true, @@ -155,7 +155,7 @@ export function jobAuditMessagesProvider({ callAsCurrentUser, callAsInternalUser levelsPerJobAggSize = jobIds.length; } - const resp = await callAsCurrentUser('search', { + const resp = await callAsInternalUser('search', { index: ML_NOTIFICATION_INDEX_PATTERN, ignore_unavailable: true, rest_total_hits_as_int: true, diff --git a/x-pack/plugins/ml/server/models/job_service/jobs.ts b/x-pack/plugins/ml/server/models/job_service/jobs.ts index e9ed2d0941d9..0aa1cfdae13c 100644 --- a/x-pack/plugins/ml/server/models/job_service/jobs.ts +++ b/x-pack/plugins/ml/server/models/job_service/jobs.ts @@ -48,7 +48,7 @@ interface Results { } export function jobsProvider(mlClusterClient: ILegacyScopedClusterClient) { - const { callAsCurrentUser, callAsInternalUser } = mlClusterClient; + const { callAsInternalUser } = mlClusterClient; const { forceDeleteDatafeed, getDatafeedIdsByJobId } = datafeedsProvider(mlClusterClient); const { getAuditMessagesSummary } = jobAuditMessagesProvider(mlClusterClient); @@ -400,7 +400,7 @@ export function jobsProvider(mlClusterClient: ILegacyScopedClusterClient) { const detailed = true; const jobIds = []; try { - const tasksList = await callAsCurrentUser('tasks.list', { actions, detailed }); + const tasksList = await callAsInternalUser('tasks.list', { actions, detailed }); Object.keys(tasksList.nodes).forEach((nodeId) => { const tasks = tasksList.nodes[nodeId].tasks; Object.keys(tasks).forEach((taskId) => { diff --git a/x-pack/plugins/ml/server/models/job_service/new_job/categorization/top_categories.ts b/x-pack/plugins/ml/server/models/job_service/new_job/categorization/top_categories.ts index 4f97238a4a0b..5ade86806f38 100644 --- a/x-pack/plugins/ml/server/models/job_service/new_job/categorization/top_categories.ts +++ b/x-pack/plugins/ml/server/models/job_service/new_job/categorization/top_categories.ts @@ -9,9 +9,9 @@ import { ILegacyScopedClusterClient } from 'kibana/server'; import { ML_RESULTS_INDEX_PATTERN } from '../../../../../common/constants/index_patterns'; import { CategoryId, Category } from '../../../../../common/types/categories'; -export function topCategoriesProvider({ callAsCurrentUser }: ILegacyScopedClusterClient) { +export function topCategoriesProvider({ callAsInternalUser }: ILegacyScopedClusterClient) { async function getTotalCategories(jobId: string): Promise<{ total: number }> { - const totalResp = await callAsCurrentUser('search', { + const totalResp = await callAsInternalUser('search', { index: ML_RESULTS_INDEX_PATTERN, size: 0, body: { @@ -37,7 +37,7 @@ export function topCategoriesProvider({ callAsCurrentUser }: ILegacyScopedCluste } async function getTopCategoryCounts(jobId: string, numberOfCategories: number) { - const top: SearchResponse = await callAsCurrentUser('search', { + const top: SearchResponse = await callAsInternalUser('search', { index: ML_RESULTS_INDEX_PATTERN, size: 0, body: { @@ -99,7 +99,7 @@ export function topCategoriesProvider({ callAsCurrentUser }: ILegacyScopedCluste field: 'category_id', }, }; - const result: SearchResponse = await callAsCurrentUser('search', { + const result: SearchResponse = await callAsInternalUser('search', { index: ML_RESULTS_INDEX_PATTERN, size, body: { diff --git a/x-pack/plugins/ml/server/models/results_service/get_partition_fields_values.ts b/x-pack/plugins/ml/server/models/results_service/get_partition_fields_values.ts index 663ee846571e..9c0efe259844 100644 --- a/x-pack/plugins/ml/server/models/results_service/get_partition_fields_values.ts +++ b/x-pack/plugins/ml/server/models/results_service/get_partition_fields_values.ts @@ -75,7 +75,6 @@ function getFieldObject(fieldType: PartitionFieldsType, aggs: any) { } export const getPartitionFieldsValuesFactory = ({ - callAsCurrentUser, callAsInternalUser, }: ILegacyScopedClusterClient) => /** @@ -102,7 +101,7 @@ export const getPartitionFieldsValuesFactory = ({ const isModelPlotEnabled = job?.model_plot_config?.enabled; - const resp = await callAsCurrentUser('search', { + const resp = await callAsInternalUser('search', { index: ML_RESULTS_INDEX_PATTERN, size: 0, body: { diff --git a/x-pack/plugins/ml/server/models/results_service/results_service.ts b/x-pack/plugins/ml/server/models/results_service/results_service.ts index 8e904143263d..04997e517bba 100644 --- a/x-pack/plugins/ml/server/models/results_service/results_service.ts +++ b/x-pack/plugins/ml/server/models/results_service/results_service.ts @@ -31,7 +31,7 @@ interface Influencer { } export function resultsServiceProvider(mlClusterClient: ILegacyScopedClusterClient) { - const { callAsCurrentUser } = mlClusterClient; + const { callAsInternalUser } = mlClusterClient; // Obtains data for the anomalies table, aggregating anomalies by day or hour as requested. // Return an Object with properties 'anomalies' and 'interval' (interval used to aggregate anomalies, // one of day, hour or second. Note 'auto' can be provided as the aggregationInterval in the request, @@ -134,7 +134,7 @@ export function resultsServiceProvider(mlClusterClient: ILegacyScopedClusterClie }); } - const resp: SearchResponse = await callAsCurrentUser('search', { + const resp: SearchResponse = await callAsInternalUser('search', { index: ML_RESULTS_INDEX_PATTERN, rest_total_hits_as_int: true, size: maxRecords, @@ -288,7 +288,7 @@ export function resultsServiceProvider(mlClusterClient: ILegacyScopedClusterClie }, }; - const resp = await callAsCurrentUser('search', query); + const resp = await callAsInternalUser('search', query); const maxScore = _.get(resp, ['aggregations', 'max_score', 'value'], null); return { maxScore }; @@ -326,7 +326,7 @@ export function resultsServiceProvider(mlClusterClient: ILegacyScopedClusterClie // Size of job terms agg, consistent with maximum number of jobs supported by Java endpoints. const maxJobs = 10000; - const resp = await callAsCurrentUser('search', { + const resp = await callAsInternalUser('search', { index: ML_RESULTS_INDEX_PATTERN, size: 0, body: { @@ -370,7 +370,7 @@ export function resultsServiceProvider(mlClusterClient: ILegacyScopedClusterClie // from the given index and job ID. // Returned response consists of a list of examples against category ID. async function getCategoryExamples(jobId: string, categoryIds: any, maxExamples: number) { - const resp = await callAsCurrentUser('search', { + const resp = await callAsInternalUser('search', { index: ML_RESULTS_INDEX_PATTERN, rest_total_hits_as_int: true, size: ANOMALIES_TABLE_DEFAULT_QUERY_SIZE, // Matches size of records in anomaly summary table. @@ -405,7 +405,7 @@ export function resultsServiceProvider(mlClusterClient: ILegacyScopedClusterClie // Returned response contains four properties - categoryId, regex, examples // and terms (space delimited String of the common tokens matched in values of the category). async function getCategoryDefinition(jobId: string, categoryId: string) { - const resp = await callAsCurrentUser('search', { + const resp = await callAsInternalUser('search', { index: ML_RESULTS_INDEX_PATTERN, rest_total_hits_as_int: true, size: 1, diff --git a/x-pack/plugins/ml/server/routes/apidoc.json b/x-pack/plugins/ml/server/routes/apidoc.json index 98f7a78537c5..f360da5df539 100644 --- a/x-pack/plugins/ml/server/routes/apidoc.json +++ b/x-pack/plugins/ml/server/routes/apidoc.json @@ -48,6 +48,7 @@ "GetMaxAnomalyScore", "GetCategoryExamples", "GetPartitionFieldsValues", + "AnomalySearch", "Modules", "DataRecognizer", diff --git a/x-pack/plugins/ml/server/routes/data_frame_analytics.ts b/x-pack/plugins/ml/server/routes/data_frame_analytics.ts index 3e6c6f5f6a2f..94feb21a6b5f 100644 --- a/x-pack/plugins/ml/server/routes/data_frame_analytics.ts +++ b/x-pack/plugins/ml/server/routes/data_frame_analytics.ts @@ -513,7 +513,7 @@ export function dataFrameAnalyticsRoutes({ router, mlLicense }: RouteInitializat mlLicense.fullLicenseAPIGuard(async (context, request, response) => { try { const { analyticsId } = request.params; - const results = await context.ml!.mlClient.callAsCurrentUser( + const results = await context.ml!.mlClient.callAsInternalUser( 'ml.updateDataFrameAnalytics', { body: request.body, diff --git a/x-pack/plugins/ml/server/routes/results_service.ts b/x-pack/plugins/ml/server/routes/results_service.ts index c7fcebd2a29a..c9370362816f 100644 --- a/x-pack/plugins/ml/server/routes/results_service.ts +++ b/x-pack/plugins/ml/server/routes/results_service.ts @@ -5,6 +5,7 @@ */ import { RequestHandlerContext } from 'kibana/server'; +import { schema } from '@kbn/config-schema'; import { wrapError } from '../client/error_wrapper'; import { RouteInitialization } from '../types'; import { @@ -15,6 +16,7 @@ import { partitionFieldValuesSchema, } from './schemas/results_service_schema'; import { resultsServiceProvider } from '../models/results_service'; +import { ML_RESULTS_INDEX_PATTERN } from '../../common/constants/index_patterns'; function getAnomaliesTableData(context: RequestHandlerContext, payload: any) { const rs = resultsServiceProvider(context.ml!.mlClient); @@ -232,4 +234,35 @@ export function resultsServiceRoutes({ router, mlLicense }: RouteInitialization) } }) ); + + /** + * @apiGroup ResultsService + * + * @api {post} /api/ml/results/anomaly_search Performs a search on the anomaly results index + * @apiName AnomalySearch + */ + router.post( + { + path: '/api/ml/results/anomaly_search', + validate: { + body: schema.maybe(schema.any()), + }, + options: { + tags: ['access:ml:canGetJobs'], + }, + }, + mlLicense.fullLicenseAPIGuard(async (context, request, response) => { + const body = { + ...request.body, + index: ML_RESULTS_INDEX_PATTERN, + }; + try { + return response.ok({ + body: await context.ml!.mlClient.callAsInternalUser('search', body), + }); + } catch (error) { + return response.customError(wrapError(error)); + } + }) + ); } diff --git a/x-pack/plugins/ml/server/shared_services/providers/system.ts b/x-pack/plugins/ml/server/shared_services/providers/system.ts index ec2662014546..d292abc438a2 100644 --- a/x-pack/plugins/ml/server/shared_services/providers/system.ts +++ b/x-pack/plugins/ml/server/shared_services/providers/system.ts @@ -37,7 +37,7 @@ export function getMlSystemProvider( return { mlSystemProvider(mlClusterClient: ILegacyScopedClusterClient, request: KibanaRequest) { // const hasMlCapabilities = getHasMlCapabilities(request); - const { callAsCurrentUser, callAsInternalUser } = mlClusterClient; + const { callAsInternalUser } = mlClusterClient; return { async mlCapabilities() { isMinimumLicense(); @@ -77,7 +77,7 @@ export function getMlSystemProvider( // integration and currently alerting does not supply a request object. // await hasMlCapabilities(['canAccessML']); - return callAsCurrentUser('search', { + return callAsInternalUser('search', { ...searchParams, index: ML_RESULTS_INDEX_PATTERN, }); diff --git a/x-pack/test/functional/services/ml/data_visualizer_file_based.ts b/x-pack/test/functional/services/ml/data_visualizer_file_based.ts index eea0a83879ea..8c5e40dd5dbd 100644 --- a/x-pack/test/functional/services/ml/data_visualizer_file_based.ts +++ b/x-pack/test/functional/services/ml/data_visualizer_file_based.ts @@ -101,7 +101,7 @@ export function MachineLearningDataVisualizerFileBasedProvider( }, async startImportAndWaitForProcessing() { - await testSubjects.click('mlFileDataVisImportButton'); + await testSubjects.clickWhenNotDisabled('mlFileDataVisImportButton'); await retry.tryForTime(60 * 1000, async () => { await testSubjects.existOrFail('mlFileImportSuccessCallout'); }); From badbfa0eb5f6aee037b37ae72b9d9e77c392e210 Mon Sep 17 00:00:00 2001 From: Yuliia Naumenko Date: Thu, 23 Jul 2020 08:39:51 -0700 Subject: [PATCH 098/142] Added more {{context}} fields for Index Threshold alert type (including requested 'threshold' field). Extended action variables UX with tooltip containing variable description. (#71141) * Added more {{context}} fields for Index Threshold alert type (including requested 'threshold' field). Extended action variables UX with tooltip containing variable description. * Fixed type checks and failing tests * fixed type check * Splited params variables * Fixed tests and type checks * Fixed styles * Fixed type check * fixed styles * fixed missing type * Fixed due to comments * fixed variables description * fixed type check * Fixed due to comments * fixed typecheck * Merge remote-tracking branch upstream/master into alerting-additional-context-fields * fixed type checks and tests * fixed tests --- .../index_threshold/action_context.test.ts | 30 ++++++------ .../index_threshold/alert_type.test.ts | 46 +++++++++++++++++++ .../alert_types/index_threshold/alert_type.ts | 30 ++++++++++++ .../alerts/server/alert_type_registry.test.ts | 2 + .../alerts/server/alert_type_registry.ts | 1 + .../create_execution_handler.test.ts | 5 ++ .../task_runner/create_execution_handler.ts | 5 +- .../alerts/server/task_runner/task_runner.ts | 7 ++- .../transform_action_params.test.ts | 17 +++++++ .../task_runner/transform_action_params.ts | 5 +- x-pack/plugins/alerts/server/types.ts | 3 ++ .../rules/step_rule_actions/index.tsx | 3 +- .../pages/detection_engine/rules/helpers.tsx | 28 ++++++----- x-pack/plugins/triggers_actions_ui/README.md | 4 +- .../components/add_message_variables.scss | 1 + .../components/add_message_variables.tsx | 24 +++++++--- .../servicenow/servicenow_params.tsx | 2 +- .../json_editor_with_message_variables.tsx | 3 +- .../text_area_with_message_variables.tsx | 3 +- .../text_field_with_message_variables.tsx | 3 +- .../application/lib/action_variables.test.ts | 9 +++- .../application/lib/action_variables.ts | 3 +- .../public/application/lib/alert_api.test.ts | 1 + .../action_form.test.tsx | 5 +- .../action_connector_form/action_form.tsx | 3 +- .../components/alert_details.test.tsx | 36 +++++++-------- .../sections/alert_form/alert_add.test.tsx | 1 + .../sections/alert_form/alert_form.tsx | 4 +- .../triggers_actions_ui/public/index.ts | 1 + .../triggers_actions_ui/public/types.ts | 3 +- .../plugins/alerts/server/alert_types.ts | 1 + .../tests/alerting/list_alert_types.ts | 2 + .../tests/alerting/list_alert_types.ts | 4 ++ .../apps/triggers_actions_ui/alerts.ts | 2 +- 34 files changed, 228 insertions(+), 69 deletions(-) diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/action_context.test.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/action_context.test.ts index a72a7343c590..3f5addb77cb3 100644 --- a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/action_context.test.ts +++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/action_context.test.ts @@ -9,11 +9,6 @@ import { ParamsSchema } from './alert_type_params'; describe('ActionContext', () => { it('generates expected properties if aggField is null', async () => { - const base: BaseActionContext = { - date: '2020-01-01T00:00:00.000Z', - group: '[group]', - value: 42, - }; const params = ParamsSchema.validate({ index: '[index]', timeField: '[timeField]', @@ -26,6 +21,11 @@ describe('ActionContext', () => { thresholdComparator: '>', threshold: [4], }); + const base: BaseActionContext = { + date: '2020-01-01T00:00:00.000Z', + group: '[group]', + value: 42, + }; const context = addMessages({ name: '[alert-name]' }, base, params); expect(context.title).toMatchInlineSnapshot( `"alert [alert-name] group [group] exceeded threshold"` @@ -36,11 +36,6 @@ describe('ActionContext', () => { }); it('generates expected properties if aggField is not null', async () => { - const base: BaseActionContext = { - date: '2020-01-01T00:00:00.000Z', - group: '[group]', - value: 42, - }; const params = ParamsSchema.validate({ index: '[index]', timeField: '[timeField]', @@ -54,6 +49,11 @@ describe('ActionContext', () => { thresholdComparator: '>', threshold: [4.2], }); + const base: BaseActionContext = { + date: '2020-01-01T00:00:00.000Z', + group: '[group]', + value: 42, + }; const context = addMessages({ name: '[alert-name]' }, base, params); expect(context.title).toMatchInlineSnapshot( `"alert [alert-name] group [group] exceeded threshold"` @@ -64,11 +64,6 @@ describe('ActionContext', () => { }); it('generates expected properties if comparator is between', async () => { - const base: BaseActionContext = { - date: '2020-01-01T00:00:00.000Z', - group: '[group]', - value: 4, - }; const params = ParamsSchema.validate({ index: '[index]', timeField: '[timeField]', @@ -81,6 +76,11 @@ describe('ActionContext', () => { thresholdComparator: 'between', threshold: [4, 5], }); + const base: BaseActionContext = { + date: '2020-01-01T00:00:00.000Z', + group: '[group]', + value: 4, + }; const context = addMessages({ name: '[alert-name]' }, base, params); expect(context.title).toMatchInlineSnapshot( `"alert [alert-name] group [group] exceeded threshold"` diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type.test.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type.test.ts index d3583fd4cdb0..e33a3e775ca9 100644 --- a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type.test.ts +++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type.test.ts @@ -47,6 +47,52 @@ describe('alertType', () => { "name": "value", }, ], + "params": Array [ + Object { + "description": "An array of values to use as the threshold; 'between' and 'notBetween' require two values, the others require one.", + "name": "threshold", + }, + Object { + "description": "A comparison function to use to determine if the threshold as been met.", + "name": "thresholdComparator", + }, + Object { + "description": "index", + "name": "index", + }, + Object { + "description": "timeField", + "name": "timeField", + }, + Object { + "description": "aggType", + "name": "aggType", + }, + Object { + "description": "aggField", + "name": "aggField", + }, + Object { + "description": "groupBy", + "name": "groupBy", + }, + Object { + "description": "termField", + "name": "termField", + }, + Object { + "description": "termSize", + "name": "termSize", + }, + Object { + "description": "timeWindowSize", + "name": "timeWindowSize", + }, + Object { + "description": "timeWindowUnit", + "name": "timeWindowUnit", + }, + ], } `); }); diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type.ts index 153334cb6404..c0522c08a7b9 100644 --- a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type.ts +++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type.ts @@ -14,6 +14,7 @@ import { BUILT_IN_ALERTS_FEATURE_ID } from '../../../common'; export const ID = '.index-threshold'; +import { CoreQueryParamsSchemaProperties } from './lib/core_query_types'; const ActionGroupId = 'threshold met'; const ComparatorFns = getComparatorFns(); export const ComparatorFnNames = new Set(ComparatorFns.keys()); @@ -67,6 +68,30 @@ export function getAlertType(service: Service): AlertType { } ); + const actionVariableContextThresholdLabel = i18n.translate( + 'xpack.alertingBuiltins.indexThreshold.actionVariableContextThresholdLabel', + { + defaultMessage: + "An array of values to use as the threshold; 'between' and 'notBetween' require two values, the others require one.", + } + ); + + const actionVariableContextThresholdComparatorLabel = i18n.translate( + 'xpack.alertingBuiltins.indexThreshold.actionVariableContextThresholdComparatorLabel', + { + defaultMessage: 'A comparison function to use to determine if the threshold as been met.', + } + ); + + const alertParamsVariables = Object.keys(CoreQueryParamsSchemaProperties).map( + (propKey: string) => { + return { + name: propKey, + description: propKey, + }; + } + ); + return { id: ID, name: alertTypeName, @@ -83,6 +108,11 @@ export function getAlertType(service: Service): AlertType { { name: 'date', description: actionVariableContextDateLabel }, { name: 'value', description: actionVariableContextValueLabel }, ], + params: [ + { name: 'threshold', description: actionVariableContextThresholdLabel }, + { name: 'thresholdComparator', description: actionVariableContextThresholdComparatorLabel }, + ...alertParamsVariables, + ], }, executor, producer: BUILT_IN_ALERTS_FEATURE_ID, diff --git a/x-pack/plugins/alerts/server/alert_type_registry.test.ts b/x-pack/plugins/alerts/server/alert_type_registry.test.ts index c74039071371..229847bda183 100644 --- a/x-pack/plugins/alerts/server/alert_type_registry.test.ts +++ b/x-pack/plugins/alerts/server/alert_type_registry.test.ts @@ -208,6 +208,7 @@ describe('get()', () => { ], "actionVariables": Object { "context": Array [], + "params": Array [], "state": Array [], }, "defaultActionGroupId": "default", @@ -261,6 +262,7 @@ describe('list()', () => { ], "actionVariables": Object { "context": Array [], + "params": Array [], "state": Array [], }, "defaultActionGroupId": "testActionGroup", diff --git a/x-pack/plugins/alerts/server/alert_type_registry.ts b/x-pack/plugins/alerts/server/alert_type_registry.ts index c466d0e96382..19d3bf13bd66 100644 --- a/x-pack/plugins/alerts/server/alert_type_registry.ts +++ b/x-pack/plugins/alerts/server/alert_type_registry.ts @@ -119,5 +119,6 @@ function normalizedActionVariables(actionVariables: AlertType['actionVariables'] return { context: actionVariables?.context ?? [], state: actionVariables?.state ?? [], + params: actionVariables?.params ?? [], }; } diff --git a/x-pack/plugins/alerts/server/task_runner/create_execution_handler.test.ts b/x-pack/plugins/alerts/server/task_runner/create_execution_handler.test.ts index 3ea40fe4c308..677040d8174e 100644 --- a/x-pack/plugins/alerts/server/task_runner/create_execution_handler.test.ts +++ b/x-pack/plugins/alerts/server/task_runner/create_execution_handler.test.ts @@ -50,6 +50,11 @@ const createExecutionHandlerParams = { }, ], request: {} as KibanaRequest, + alertParams: { + foo: true, + contextVal: 'My other {{context.value}} goes here', + stateVal: 'My other {{state.value}} goes here', + }, }; beforeEach(() => { diff --git a/x-pack/plugins/alerts/server/task_runner/create_execution_handler.ts b/x-pack/plugins/alerts/server/task_runner/create_execution_handler.ts index e1e1568d2f13..c21d81779e5e 100644 --- a/x-pack/plugins/alerts/server/task_runner/create_execution_handler.ts +++ b/x-pack/plugins/alerts/server/task_runner/create_execution_handler.ts @@ -5,7 +5,7 @@ */ import { map } from 'lodash'; -import { AlertAction, State, Context, AlertType } from '../types'; +import { AlertAction, State, Context, AlertType, AlertParams } from '../types'; import { Logger, KibanaRequest } from '../../../../../src/core/server'; import { transformActionParams } from './transform_action_params'; import { PluginStartContract as ActionsPluginStartContract } from '../../../actions/server'; @@ -24,6 +24,7 @@ interface CreateExecutionHandlerOptions { logger: Logger; eventLogger: IEventLogger; request: KibanaRequest; + alertParams: AlertParams; } interface ExecutionHandlerOptions { @@ -45,6 +46,7 @@ export function createExecutionHandler({ alertType, eventLogger, request, + alertParams, }: CreateExecutionHandlerOptions) { const alertTypeActionGroups = new Set(map(alertType.actionGroups, 'id')); return async ({ actionGroup, context, state, alertInstanceId }: ExecutionHandlerOptions) => { @@ -66,6 +68,7 @@ export function createExecutionHandler({ context, actionParams: action.params, state, + alertParams, }), }; }); diff --git a/x-pack/plugins/alerts/server/task_runner/task_runner.ts b/x-pack/plugins/alerts/server/task_runner/task_runner.ts index e4d04a005c98..04fea58f250a 100644 --- a/x-pack/plugins/alerts/server/task_runner/task_runner.ts +++ b/x-pack/plugins/alerts/server/task_runner/task_runner.ts @@ -110,7 +110,8 @@ export class TaskRunner { tags: string[] | undefined, spaceId: string, apiKey: string | null, - actions: Alert['actions'] + actions: Alert['actions'], + alertParams: RawAlert['params'] ) { return createExecutionHandler({ alertId, @@ -124,6 +125,7 @@ export class TaskRunner { alertType: this.alertType, eventLogger: this.context.eventLogger, request: this.getFakeKibanaRequest(spaceId, apiKey), + alertParams, }); } @@ -261,7 +263,8 @@ export class TaskRunner { alert.tags, spaceId, apiKey, - alert.actions + alert.actions, + alert.params ); return this.executeAlertInstances(services, alert, validatedParams, executionHandler, spaceId); } diff --git a/x-pack/plugins/alerts/server/task_runner/transform_action_params.test.ts b/x-pack/plugins/alerts/server/task_runner/transform_action_params.test.ts index d5c310caf3fd..ddbef8e32e70 100644 --- a/x-pack/plugins/alerts/server/task_runner/transform_action_params.test.ts +++ b/x-pack/plugins/alerts/server/task_runner/transform_action_params.test.ts @@ -13,6 +13,7 @@ test('skips non string parameters', () => { empty1: null, empty2: undefined, date: '2019-02-12T21:01:22.479Z', + message: 'Value "{{params.foo}}" exists', }; const result = transformActionParams({ actionParams, @@ -23,6 +24,9 @@ test('skips non string parameters', () => { tags: ['tag-A', 'tag-B'], spaceId: 'spaceId-A', alertInstanceId: '2', + alertParams: { + foo: 'test', + }, }); expect(result).toMatchInlineSnapshot(` Object { @@ -30,6 +34,7 @@ test('skips non string parameters', () => { "date": "2019-02-12T21:01:22.479Z", "empty1": null, "empty2": undefined, + "message": "Value \\"test\\" exists", "number": 1, } `); @@ -49,6 +54,7 @@ test('missing parameters get emptied out', () => { tags: ['tag-A', 'tag-B'], spaceId: 'spaceId-A', alertInstanceId: '2', + alertParams: {}, }); expect(result).toMatchInlineSnapshot(` Object { @@ -71,6 +77,7 @@ test('context parameters are passed to templates', () => { tags: ['tag-A', 'tag-B'], spaceId: 'spaceId-A', alertInstanceId: '2', + alertParams: {}, }); expect(result).toMatchInlineSnapshot(` Object { @@ -92,6 +99,7 @@ test('state parameters are passed to templates', () => { tags: ['tag-A', 'tag-B'], spaceId: 'spaceId-A', alertInstanceId: '2', + alertParams: {}, }); expect(result).toMatchInlineSnapshot(` Object { @@ -113,6 +121,7 @@ test('alertId is passed to templates', () => { tags: ['tag-A', 'tag-B'], spaceId: 'spaceId-A', alertInstanceId: '2', + alertParams: {}, }); expect(result).toMatchInlineSnapshot(` Object { @@ -134,6 +143,7 @@ test('alertName is passed to templates', () => { tags: ['tag-A', 'tag-B'], spaceId: 'spaceId-A', alertInstanceId: '2', + alertParams: {}, }); expect(result).toMatchInlineSnapshot(` Object { @@ -155,6 +165,7 @@ test('tags is passed to templates', () => { tags: ['tag-A', 'tag-B'], spaceId: 'spaceId-A', alertInstanceId: '2', + alertParams: {}, }); expect(result).toMatchInlineSnapshot(` Object { @@ -175,6 +186,7 @@ test('undefined tags is passed to templates', () => { alertName: 'alert-name', spaceId: 'spaceId-A', alertInstanceId: '2', + alertParams: {}, }); expect(result).toMatchInlineSnapshot(` Object { @@ -196,6 +208,7 @@ test('empty tags is passed to templates', () => { tags: [], spaceId: 'spaceId-A', alertInstanceId: '2', + alertParams: {}, }); expect(result).toMatchInlineSnapshot(` Object { @@ -217,6 +230,7 @@ test('spaceId is passed to templates', () => { tags: ['tag-A', 'tag-B'], spaceId: 'spaceId-A', alertInstanceId: '2', + alertParams: {}, }); expect(result).toMatchInlineSnapshot(` Object { @@ -238,6 +252,7 @@ test('alertInstanceId is passed to templates', () => { tags: ['tag-A', 'tag-B'], spaceId: 'spaceId-A', alertInstanceId: '2', + alertParams: {}, }); expect(result).toMatchInlineSnapshot(` Object { @@ -261,6 +276,7 @@ test('works recursively', () => { tags: ['tag-A', 'tag-B'], spaceId: 'spaceId-A', alertInstanceId: '2', + alertParams: {}, }); expect(result).toMatchInlineSnapshot(` Object { @@ -286,6 +302,7 @@ test('works recursively with arrays', () => { tags: ['tag-A', 'tag-B'], spaceId: 'spaceId-A', alertInstanceId: '2', + alertParams: {}, }); expect(result).toMatchInlineSnapshot(` Object { diff --git a/x-pack/plugins/alerts/server/task_runner/transform_action_params.ts b/x-pack/plugins/alerts/server/task_runner/transform_action_params.ts index fa4a0e40ddee..30f062eee370 100644 --- a/x-pack/plugins/alerts/server/task_runner/transform_action_params.ts +++ b/x-pack/plugins/alerts/server/task_runner/transform_action_params.ts @@ -6,7 +6,7 @@ import Mustache from 'mustache'; import { isString, cloneDeepWith } from 'lodash'; -import { AlertActionParams, State, Context } from '../types'; +import { AlertActionParams, State, Context, AlertParams } from '../types'; interface TransformActionParamsOptions { alertId: string; @@ -17,6 +17,7 @@ interface TransformActionParamsOptions { actionParams: AlertActionParams; state: State; context: Context; + alertParams: AlertParams; } export function transformActionParams({ @@ -28,6 +29,7 @@ export function transformActionParams({ context, actionParams, state, + alertParams, }: TransformActionParamsOptions): AlertActionParams { const result = cloneDeepWith(actionParams, (value: unknown) => { if (!isString(value)) return; @@ -43,6 +45,7 @@ export function transformActionParams({ alertInstanceId, context, state, + params: alertParams, }; return Mustache.render(value, variables); }); diff --git a/x-pack/plugins/alerts/server/types.ts b/x-pack/plugins/alerts/server/types.ts index 66eec370f2c2..154a9564518e 100644 --- a/x-pack/plugins/alerts/server/types.ts +++ b/x-pack/plugins/alerts/server/types.ts @@ -23,6 +23,8 @@ import { export type State = Record; // eslint-disable-next-line @typescript-eslint/no-explicit-any export type Context = Record; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type AlertParams = Record; export type WithoutQueryAndParams = Pick>; export type GetServicesFunction = (request: KibanaRequest) => Services; export type GetBasePathFunction = (spaceId?: string) => string; @@ -82,6 +84,7 @@ export interface AlertType { actionVariables?: { context?: ActionVariable[]; state?: ActionVariable[]; + params?: ActionVariable[]; }; } diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx index 2b842515d0b7..5b4f7677dbc3 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx @@ -15,6 +15,7 @@ import { import { findIndex } from 'lodash/fp'; import React, { FC, memo, useCallback, useEffect, useMemo, useState } from 'react'; +import { ActionVariable } from '../../../../../../triggers_actions_ui/public'; import { RuleStep, RuleStepProps, @@ -36,7 +37,7 @@ import { APP_ID } from '../../../../../common/constants'; interface StepRuleActionsProps extends RuleStepProps { defaultValues?: ActionsStepRule | null; - actionMessageParams: string[]; + actionMessageParams: ActionVariable[]; } const stepActionsDefaultValue = { diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.tsx index 11b779e71b9b..8f8967f2ff6d 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.tsx @@ -9,6 +9,7 @@ import moment from 'moment'; import memoizeOne from 'memoize-one'; import { useLocation } from 'react-router-dom'; +import { ActionVariable } from '../../../../../../triggers_actions_ui/public'; import { RuleAlertAction, RuleType } from '../../../../../common/detection_engine/types'; import { isMlRule } from '../../../../../common/machine_learning/helpers'; import { transformRuleToAlertAction } from '../../../../../common/detection_engine/transform_actions'; @@ -326,18 +327,23 @@ export const getActionMessageRuleParams = (ruleType: RuleType): string[] => { return ruleParamsKeys; }; -export const getActionMessageParams = memoizeOne((ruleType: RuleType | undefined): string[] => { - if (!ruleType) { - return []; +export const getActionMessageParams = memoizeOne( + (ruleType: RuleType | undefined): ActionVariable[] => { + if (!ruleType) { + return []; + } + const actionMessageRuleParams = getActionMessageRuleParams(ruleType); + + return [ + { name: 'state.signals_count', description: 'state.signals_count' }, + { name: '{context.results_link}', description: 'context.results_link' }, + ...actionMessageRuleParams.map((param) => { + const extendedParam = `context.rule.${param}`; + return { name: extendedParam, description: extendedParam }; + }), + ]; } - const actionMessageRuleParams = getActionMessageRuleParams(ruleType); - - return [ - 'state.signals_count', - '{context.results_link}', - ...actionMessageRuleParams.map((param) => `context.rule.${param}`), - ]; -}); +); // typed as null not undefined as the initial state for this value is null. export const userHasNoPermissions = (canUserCRUD: boolean | null): boolean => diff --git a/x-pack/plugins/triggers_actions_ui/README.md b/x-pack/plugins/triggers_actions_ui/README.md index 0dd2d100401f..b8e765c9ea63 100644 --- a/x-pack/plugins/triggers_actions_ui/README.md +++ b/x-pack/plugins/triggers_actions_ui/README.md @@ -1294,7 +1294,7 @@ Then this dependencies will be used to embed Actions form or register your own a return ( { initialAlert.actions[index].id = id; @@ -1329,7 +1329,7 @@ interface ActionAccordionFormProps { 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' >; actionTypes?: ActionType[]; - messageVariables?: string[]; + messageVariables?: ActionVariable[]; defaultActionMessage?: string; consumer: string; } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/add_message_variables.scss b/x-pack/plugins/triggers_actions_ui/public/application/components/add_message_variables.scss index 996f21c4b6b0..521d0f399b19 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/add_message_variables.scss +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/add_message_variables.scss @@ -1,4 +1,5 @@ .messageVariablesPanel { @include euiYScrollWithShadows; max-height: $euiSize * 20; + max-width: $euiSize * 20; } \ No newline at end of file diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/add_message_variables.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/add_message_variables.tsx index 655f64995d14..0742ed8a778e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/add_message_variables.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/add_message_variables.tsx @@ -5,11 +5,18 @@ */ import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiPopover, EuiButtonIcon, EuiContextMenuPanel, EuiContextMenuItem } from '@elastic/eui'; +import { + EuiPopover, + EuiButtonIcon, + EuiContextMenuPanel, + EuiContextMenuItem, + EuiText, +} from '@elastic/eui'; import './add_message_variables.scss'; +import { ActionVariable } from '../../types'; interface Props { - messageVariables: string[] | undefined; + messageVariables?: ActionVariable[]; paramsProperty: string; onSelectEventHandler: (variable: string) => void; } @@ -22,17 +29,22 @@ export const AddMessageVariables: React.FunctionComponent = ({ const [isVariablesPopoverOpen, setIsVariablesPopoverOpen] = useState(false); const getMessageVariables = () => - messageVariables?.map((variable: string, i: number) => ( + messageVariables?.map((variable: ActionVariable, i: number) => ( { - onSelectEventHandler(variable); + onSelectEventHandler(variable.name); setIsVariablesPopoverOpen(false); }} > - {`{{${variable}}}`} + <> + {`{{${variable.name}}}`} + +

{variable.description}
+
+ )); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_params.tsx index 1e0f4d1fdc57..2a29018d83ff 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_params.tsx @@ -61,7 +61,7 @@ const ServiceNowParamsFields: React.FunctionComponent variable === 'alertId')) { + if (!savedObjectId && messageVariables?.find((variable) => variable.name === 'alertId')) { editSubActionProperty('savedObjectId', '{{alertId}}'); } if (!urgency) { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/json_editor_with_message_variables.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/json_editor_with_message_variables.tsx index 473c0fe9609c..0b8184fc441f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/json_editor_with_message_variables.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/json_editor_with_message_variables.tsx @@ -9,9 +9,10 @@ import './add_message_variables.scss'; import { useXJsonMode } from '../../../../../../src/plugins/es_ui_shared/static/ace_x_json/hooks'; import { AddMessageVariables } from './add_message_variables'; +import { ActionVariable } from '../../types'; interface Props { - messageVariables: string[] | undefined; + messageVariables?: ActionVariable[]; paramsProperty: string; inputTargetValue: string; label: string; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/text_area_with_message_variables.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/text_area_with_message_variables.tsx index 0b8a9349ad5f..e60785f70bff 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/text_area_with_message_variables.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/text_area_with_message_variables.tsx @@ -7,9 +7,10 @@ import React, { useState } from 'react'; import { EuiTextArea, EuiFormRow } from '@elastic/eui'; import './add_message_variables.scss'; import { AddMessageVariables } from './add_message_variables'; +import { ActionVariable } from '../../types'; interface Props { - messageVariables: string[] | undefined; + messageVariables?: ActionVariable[]; paramsProperty: string; index: number; inputTargetValue?: string; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/text_field_with_message_variables.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/text_field_with_message_variables.tsx index e280fd3f34e9..fc05b237ccf5 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/text_field_with_message_variables.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/text_field_with_message_variables.tsx @@ -7,9 +7,10 @@ import React, { useState } from 'react'; import { EuiFieldText } from '@elastic/eui'; import './add_message_variables.scss'; import { AddMessageVariables } from './add_message_variables'; +import { ActionVariable } from '../../types'; interface Props { - messageVariables: string[] | undefined; + messageVariables?: ActionVariable[]; paramsProperty: string; index: number; inputTargetValue?: string; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/action_variables.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_variables.test.ts index ddd03df8bee6..c5009fad3294 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/action_variables.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_variables.test.ts @@ -12,7 +12,7 @@ beforeEach(() => jest.resetAllMocks()); describe('actionVariablesFromAlertType', () => { test('should return correct variables when no state or context provided', async () => { - const alertType = getAlertType({ context: [], state: [] }); + const alertType = getAlertType({ context: [], state: [], params: [] }); expect(actionVariablesFromAlertType(alertType)).toMatchInlineSnapshot(` Array [ Object { @@ -46,6 +46,7 @@ describe('actionVariablesFromAlertType', () => { { name: 'bar', description: 'bar-description' }, ], state: [], + params: [], }); expect(actionVariablesFromAlertType(alertType)).toMatchInlineSnapshot(` Array [ @@ -88,6 +89,7 @@ describe('actionVariablesFromAlertType', () => { { name: 'foo', description: 'foo-description' }, { name: 'bar', description: 'bar-description' }, ], + params: [], }); expect(actionVariablesFromAlertType(alertType)).toMatchInlineSnapshot(` Array [ @@ -133,6 +135,7 @@ describe('actionVariablesFromAlertType', () => { { name: 'fooS', description: 'fooS-description' }, { name: 'barS', description: 'barS-description' }, ], + params: [{ name: 'fooP', description: 'fooP-description' }], }); expect(actionVariablesFromAlertType(alertType)).toMatchInlineSnapshot(` Array [ @@ -164,6 +167,10 @@ describe('actionVariablesFromAlertType', () => { "description": "barC-description", "name": "context.barC", }, + Object { + "description": "fooP-description", + "name": "params.fooP", + }, Object { "description": "fooS-description", "name": "state.fooS", diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/action_variables.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_variables.ts index 714dc5210e39..8bbe34847016 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/action_variables.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_variables.ts @@ -11,9 +11,10 @@ import { AlertType, ActionVariable } from '../../types'; export function actionVariablesFromAlertType(alertType: AlertType): ActionVariable[] { const alwaysProvidedVars = getAlwaysProvidedActionVariables(); const contextVars = prefixKeys(alertType.actionVariables.context, 'context.'); + const paramsVars = prefixKeys(alertType.actionVariables.params, 'params.'); const stateVars = prefixKeys(alertType.actionVariables.state, 'state.'); - return alwaysProvidedVars.concat(contextVars, stateVars); + return alwaysProvidedVars.concat(contextVars, paramsVars, stateVars); } function prefixKeys(actionVariables: ActionVariable[], prefix: string): ActionVariable[] { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.test.ts index 23caf2cfb31a..fc5d301cb7cd 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.test.ts @@ -42,6 +42,7 @@ describe('loadAlertTypes', () => { actionVariables: { context: [{ name: 'var1', description: 'val1' }], state: [{ name: 'var2', description: 'val2' }], + params: [{ name: 'var3', description: 'val3' }], }, producer: ALERTS_FEATURE_ID, actionGroups: [{ id: 'default', name: 'Default' }], diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx index c21cce4cc4b6..7ee1e0d3f3fa 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx @@ -217,7 +217,10 @@ describe('action_form', () => { wrapper = mountWithIntl( { initialAlert.actions[index].id = id; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx index af10f583dd41..2d4507ca9307 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx @@ -38,6 +38,7 @@ import { ActionTypeIndex, ActionConnector, ActionType, + ActionVariable, } from '../../../types'; import { SectionLoading } from '../../components/section_loading'; import { ConnectorAddModal } from './connector_add_modal'; @@ -61,7 +62,7 @@ interface ActionAccordionFormProps { >; docLinks: DocLinksStart; actionTypes?: ActionType[]; - messageVariables?: string[]; + messageVariables?: ActionVariable[]; defaultActionMessage?: string; setHasActionsDisabled?: (value: boolean) => void; capabilities: ApplicationStart['capabilities']; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx index ccaa180de0ed..a620a0db4540 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx @@ -93,7 +93,7 @@ describe('alert_details', () => { id: '.noop', name: 'No Op', actionGroups: [{ id: 'default', name: 'Default' }], - actionVariables: { context: [], state: [] }, + actionVariables: { context: [], state: [], params: [] }, defaultActionGroupId: 'default', producer: ALERTS_FEATURE_ID, authorizedConsumers, @@ -132,7 +132,7 @@ describe('alert_details', () => { id: '.noop', name: 'No Op', actionGroups: [{ id: 'default', name: 'Default' }], - actionVariables: { context: [], state: [] }, + actionVariables: { context: [], state: [], params: [] }, defaultActionGroupId: 'default', producer: ALERTS_FEATURE_ID, authorizedConsumers, @@ -162,7 +162,7 @@ describe('alert_details', () => { id: '.noop', name: 'No Op', actionGroups: [{ id: 'default', name: 'Default' }], - actionVariables: { context: [], state: [] }, + actionVariables: { context: [], state: [], params: [] }, defaultActionGroupId: 'default', producer: ALERTS_FEATURE_ID, authorizedConsumers, @@ -216,7 +216,7 @@ describe('alert_details', () => { id: '.noop', name: 'No Op', actionGroups: [{ id: 'default', name: 'Default' }], - actionVariables: { context: [], state: [] }, + actionVariables: { context: [], state: [], params: [] }, defaultActionGroupId: 'default', producer: ALERTS_FEATURE_ID, authorizedConsumers, @@ -275,7 +275,7 @@ describe('alert_details', () => { id: '.noop', name: 'No Op', actionGroups: [{ id: 'default', name: 'Default' }], - actionVariables: { context: [], state: [] }, + actionVariables: { context: [], state: [], params: [] }, defaultActionGroupId: 'default', producer: ALERTS_FEATURE_ID, authorizedConsumers, @@ -295,7 +295,7 @@ describe('alert_details', () => { id: '.noop', name: 'No Op', actionGroups: [{ id: 'default', name: 'Default' }], - actionVariables: { context: [], state: [] }, + actionVariables: { context: [], state: [], params: [] }, defaultActionGroupId: 'default', producer: ALERTS_FEATURE_ID, authorizedConsumers, @@ -324,7 +324,7 @@ describe('disable button', () => { id: '.noop', name: 'No Op', actionGroups: [{ id: 'default', name: 'Default' }], - actionVariables: { context: [], state: [] }, + actionVariables: { context: [], state: [], params: [] }, defaultActionGroupId: 'default', producer: ALERTS_FEATURE_ID, authorizedConsumers, @@ -352,7 +352,7 @@ describe('disable button', () => { id: '.noop', name: 'No Op', actionGroups: [{ id: 'default', name: 'Default' }], - actionVariables: { context: [], state: [] }, + actionVariables: { context: [], state: [], params: [] }, defaultActionGroupId: 'default', producer: ALERTS_FEATURE_ID, authorizedConsumers, @@ -380,7 +380,7 @@ describe('disable button', () => { id: '.noop', name: 'No Op', actionGroups: [{ id: 'default', name: 'Default' }], - actionVariables: { context: [], state: [] }, + actionVariables: { context: [], state: [], params: [] }, defaultActionGroupId: 'default', producer: ALERTS_FEATURE_ID, authorizedConsumers, @@ -417,7 +417,7 @@ describe('disable button', () => { id: '.noop', name: 'No Op', actionGroups: [{ id: 'default', name: 'Default' }], - actionVariables: { context: [], state: [] }, + actionVariables: { context: [], state: [], params: [] }, defaultActionGroupId: 'default', producer: ALERTS_FEATURE_ID, authorizedConsumers, @@ -457,7 +457,7 @@ describe('mute button', () => { id: '.noop', name: 'No Op', actionGroups: [{ id: 'default', name: 'Default' }], - actionVariables: { context: [], state: [] }, + actionVariables: { context: [], state: [], params: [] }, defaultActionGroupId: 'default', producer: ALERTS_FEATURE_ID, authorizedConsumers, @@ -486,7 +486,7 @@ describe('mute button', () => { id: '.noop', name: 'No Op', actionGroups: [{ id: 'default', name: 'Default' }], - actionVariables: { context: [], state: [] }, + actionVariables: { context: [], state: [], params: [] }, defaultActionGroupId: 'default', producer: ALERTS_FEATURE_ID, authorizedConsumers, @@ -515,7 +515,7 @@ describe('mute button', () => { id: '.noop', name: 'No Op', actionGroups: [{ id: 'default', name: 'Default' }], - actionVariables: { context: [], state: [] }, + actionVariables: { context: [], state: [], params: [] }, defaultActionGroupId: 'default', producer: ALERTS_FEATURE_ID, authorizedConsumers, @@ -553,7 +553,7 @@ describe('mute button', () => { id: '.noop', name: 'No Op', actionGroups: [{ id: 'default', name: 'Default' }], - actionVariables: { context: [], state: [] }, + actionVariables: { context: [], state: [], params: [] }, defaultActionGroupId: 'default', producer: ALERTS_FEATURE_ID, authorizedConsumers, @@ -591,7 +591,7 @@ describe('mute button', () => { id: '.noop', name: 'No Op', actionGroups: [{ id: 'default', name: 'Default' }], - actionVariables: { context: [], state: [] }, + actionVariables: { context: [], state: [], params: [] }, defaultActionGroupId: 'default', producer: ALERTS_FEATURE_ID, authorizedConsumers, @@ -641,7 +641,7 @@ describe('edit button', () => { id: '.noop', name: 'No Op', actionGroups: [{ id: 'default', name: 'Default' }], - actionVariables: { context: [], state: [] }, + actionVariables: { context: [], state: [], params: [] }, defaultActionGroupId: 'default', producer: 'alerting', authorizedConsumers, @@ -683,7 +683,7 @@ describe('edit button', () => { id: '.noop', name: 'No Op', actionGroups: [{ id: 'default', name: 'Default' }], - actionVariables: { context: [], state: [] }, + actionVariables: { context: [], state: [], params: [] }, defaultActionGroupId: 'default', producer: 'alerting', authorizedConsumers, @@ -718,7 +718,7 @@ describe('edit button', () => { id: '.noop', name: 'No Op', actionGroups: [{ id: 'default', name: 'Default' }], - actionVariables: { context: [], state: [] }, + actionVariables: { context: [], state: [], params: [] }, defaultActionGroupId: 'default', producer: 'alerting', authorizedConsumers, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx index 10efabd70ade..3803fcebbb92 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_add.test.tsx @@ -68,6 +68,7 @@ describe('alert_add', () => { actionVariables: { context: [], state: [], + params: [], }, }, ]; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx index 47ec2c436ca5..9d54baf359af 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx @@ -269,8 +269,8 @@ export const AlertForm = ({ setHasActionsDisabled={setHasActionsDisabled} messageVariables={ alertTypesIndex && alertTypesIndex.has(alert.alertTypeId) - ? actionVariablesFromAlertType(alertTypesIndex.get(alert.alertTypeId)!).map( - (av) => av.name + ? actionVariablesFromAlertType(alertTypesIndex.get(alert.alertTypeId)!).sort((a, b) => + a.name.toUpperCase().localeCompare(b.name.toUpperCase()) ) : undefined } diff --git a/x-pack/plugins/triggers_actions_ui/public/index.ts b/x-pack/plugins/triggers_actions_ui/public/index.ts index 55653f49001b..1048e15eb118 100644 --- a/x-pack/plugins/triggers_actions_ui/public/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/index.ts @@ -19,6 +19,7 @@ export { ActionType, ActionTypeRegistryContract, AlertTypeParamsExpressionProps, + ActionVariable, } from './types'; export { ConnectorAddFlyout, diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index dd2b070956db..a42a9f56a751 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -41,7 +41,7 @@ export interface ActionParamsProps { index: number; editAction: (property: string, value: any, index: number) => void; errors: IErrorObject; - messageVariables?: string[]; + messageVariables?: ActionVariable[]; defaultMessage?: string; docLinks: DocLinksStart; } @@ -94,6 +94,7 @@ export interface ActionVariable { export interface ActionVariables { context: ActionVariable[]; state: ActionVariable[]; + params: ActionVariable[]; } export interface AlertType { diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/alert_types.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/alert_types.ts index 26010e5a2c2e..ebf639067518 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/alert_types.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/alert_types.ts @@ -26,6 +26,7 @@ export function defineAlertTypes( defaultActionGroupId: 'default', actionVariables: { state: [{ name: 'instanceStateValue', description: 'the instance state value' }], + params: [{ name: 'instanceParamsValue', description: 'the instance params value' }], context: [{ name: 'instanceContextValue', description: 'the instance context value' }], }, async executor(alertExecutorOptions: AlertExecutorOptions) { diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/list_alert_types.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/list_alert_types.ts index c3e5af0d1f77..ad60ed6941ca 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/list_alert_types.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/list_alert_types.ts @@ -22,6 +22,7 @@ export default function listAlertTypes({ getService }: FtrProviderContext) { actionVariables: { state: [], context: [], + params: [], }, producer: 'alertsFixture', }; @@ -34,6 +35,7 @@ export default function listAlertTypes({ getService }: FtrProviderContext) { actionVariables: { state: [], context: [], + params: [], }, producer: 'alertsRestrictedFixture', }; diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/list_alert_types.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/list_alert_types.ts index dd09a14b4cb8..6fb573c7344b 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/list_alert_types.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/list_alert_types.ts @@ -29,6 +29,7 @@ export default function listAlertTypes({ getService }: FtrProviderContext) { name: 'Test: Noop', actionVariables: { state: [], + params: [], context: [], }, producer: 'alertsFixture', @@ -48,6 +49,7 @@ export default function listAlertTypes({ getService }: FtrProviderContext) { expect(fixtureAlertType.actionVariables).to.eql({ state: [{ name: 'instanceStateValue', description: 'the instance state value' }], + params: [{ name: 'instanceParamsValue', description: 'the instance params value' }], context: [{ name: 'instanceContextValue', description: 'the instance context value' }], }); }); @@ -64,6 +66,7 @@ export default function listAlertTypes({ getService }: FtrProviderContext) { expect(fixtureAlertType.actionVariables).to.eql({ state: [], + params: [], context: [{ name: 'aContextVariable', description: 'this is a context variable' }], }); }); @@ -81,6 +84,7 @@ export default function listAlertTypes({ getService }: FtrProviderContext) { expect(fixtureAlertType.actionVariables).to.eql({ state: [{ name: 'aStateVariable', description: 'this is a state variable' }], context: [], + params: [], }); }); }); diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts.ts index 09c415685450..fa714e8374ec 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts.ts @@ -86,7 +86,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await testSubjects.click('variableMenuButton-1'); expect(await messageTextArea.getAttribute('value')).to.eql( - 'test message {{alertId}} some additional text {{alertName}}' + 'test message {{alertId}} some additional text {{alertInstanceId}}' ); await testSubjects.click('saveAlertButton'); From 6b9a598f731142657df1c729d07949293ee63fb0 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Thu, 23 Jul 2020 18:43:54 +0300 Subject: [PATCH 099/142] [Security Solution][Case] Fix long tag display (#72819) --- .../public/cases/components/all_cases/columns.tsx | 10 +++++++--- .../cases/components/case_view/index.test.tsx | 8 +++++++- .../cases/components/tag_list/index.test.tsx | 6 +++--- .../public/cases/components/tag_list/index.tsx | 15 ++++++++------- .../cases/components/user_action_tree/helpers.tsx | 12 ++++++------ 5 files changed, 31 insertions(+), 20 deletions(-) diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/columns.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/columns.tsx index 162966a2df28..5c6c72477bf1 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/columns.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/columns.tsx @@ -6,6 +6,7 @@ import React, { useCallback } from 'react'; import { EuiAvatar, + EuiBadgeGroup, EuiBadge, EuiLink, EuiTableActionsColumnType, @@ -19,7 +20,6 @@ import { getEmptyTagValue } from '../../../common/components/empty_value'; import { Case } from '../../containers/types'; import { FormattedRelativePreferenceDate } from '../../../common/components/formatted_date'; import { CaseDetailsLink } from '../../../common/components/links'; -import { TruncatableText } from '../../../common/components/truncatable_text'; import * as i18n from './translations'; export type CasesColumns = @@ -35,6 +35,10 @@ const Spacer = styled.span` margin-left: ${({ theme }) => theme.eui.paddingSizes.s}; `; +const TagWrapper = styled(EuiBadgeGroup)` + width: 100%; +`; + const renderStringField = (field: string, dataTestSubj: string) => field != null ? {field} : getEmptyTagValue(); @@ -96,7 +100,7 @@ export const getCasesColumns = ( render: (tags: Case['tags']) => { if (tags != null && tags.length > 0) { return ( - + {tags.map((tag: string, i: number) => ( ))} - + ); } return getEmptyTagValue(); diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx index 278b972ada97..e1d7d98ba8c5 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/case_view/index.test.tsx @@ -119,10 +119,16 @@ describe('CaseView ', () => { ); expect( wrapper - .find(`[data-test-subj="case-view-tag-list"] [data-test-subj="case-tag"]`) + .find(`[data-test-subj="case-view-tag-list"] [data-test-subj="case-tag-coke"]`) .first() .text() ).toEqual(data.tags[0]); + expect( + wrapper + .find(`[data-test-subj="case-view-tag-list"] [data-test-subj="case-tag-pepsi"]`) + .first() + .text() + ).toEqual(data.tags[1]); expect(wrapper.find(`[data-test-subj="case-view-username"]`).first().text()).toEqual( data.createdBy.username ); diff --git a/x-pack/plugins/security_solution/public/cases/components/tag_list/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/tag_list/index.test.tsx index 939ddfde8b9d..7c3fcde68703 100644 --- a/x-pack/plugins/security_solution/public/cases/components/tag_list/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/tag_list/index.test.tsx @@ -102,14 +102,14 @@ describe('TagList ', () => { ); - expect(wrapper.find(`[data-test-subj="case-tag"]`).last().exists()).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="case-tag-pepsi"]`).last().exists()).toBeTruthy(); wrapper.find(`[data-test-subj="tag-list-edit-button"]`).last().simulate('click'); await act(async () => { - expect(wrapper.find(`[data-test-subj="case-tag"]`).last().exists()).toBeFalsy(); + expect(wrapper.find(`[data-test-subj="case-tag-pepsi"]`).last().exists()).toBeFalsy(); wrapper.find(`[data-test-subj="edit-tags-cancel"]`).last().simulate('click'); await waitFor(() => { wrapper.update(); - expect(wrapper.find(`[data-test-subj="case-tag"]`).last().exists()).toBeTruthy(); + expect(wrapper.find(`[data-test-subj="case-tag-pepsi"]`).last().exists()).toBeTruthy(); }); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/tag_list/index.tsx b/x-pack/plugins/security_solution/public/cases/components/tag_list/index.tsx index 7bb10c743a41..b5af1934f379 100644 --- a/x-pack/plugins/security_solution/public/cases/components/tag_list/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/tag_list/index.tsx @@ -10,6 +10,7 @@ import { EuiHorizontalRule, EuiFlexGroup, EuiFlexItem, + EuiBadgeGroup, EuiBadge, EuiButton, EuiButtonEmpty, @@ -98,15 +99,15 @@ export const TagList = React.memo( {tags.length === 0 && !isEditTags &&

{i18n.NO_TAGS}

} - {tags.length > 0 && - !isEditTags && - tags.map((tag, key) => ( - - + + {tags.length > 0 && + !isEditTags && + tags.map((tag, key) => ( + {tag} - - ))} + ))} + {isEditTags && ( diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.tsx index a6286693423c..1401ac2c4652 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/helpers.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiFlexGroup, EuiFlexItem, EuiBadge, EuiLink } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiBadgeGroup, EuiBadge, EuiLink } from '@elastic/eui'; import React from 'react'; import { CaseFullExternalService, Connector } from '../../../../../case/common/api'; @@ -50,14 +50,14 @@ const getTagsLabelTitle = (action: CaseUserActions) => ( {action.action === 'add' && i18n.ADDED_FIELD} {action.action === 'delete' && i18n.REMOVED_FIELD} {i18n.TAGS.toLowerCase()} - {action.newValue != null && - action.newValue.split(',').map((tag) => ( - + + {action.newValue != null && + action.newValue.split(',').map((tag) => ( {tag} - - ))} + ))} + ); From 367bece39622a04c9b51409751fbb9dd31dcddf1 Mon Sep 17 00:00:00 2001 From: Spencer Date: Thu, 23 Jul 2020 08:49:24 -0700 Subject: [PATCH 100/142] [kbn/test/failed_test_reporter] handle cypress junit better (#72968) Co-authored-by: spalger --- .../__fixtures__/cypress_report.xml | 50 +++++++++++++ .../__fixtures__/index.ts | 1 + .../add_messages_to_report.test.ts | 71 ++++++++++++++++++- .../add_messages_to_report.ts | 10 ++- .../run_failed_tests_reporter_cli.ts | 2 + .../src/failed_tests_reporter/test_report.ts | 2 +- 6 files changed, 131 insertions(+), 5 deletions(-) create mode 100644 packages/kbn-test/src/failed_tests_reporter/__fixtures__/cypress_report.xml diff --git a/packages/kbn-test/src/failed_tests_reporter/__fixtures__/cypress_report.xml b/packages/kbn-test/src/failed_tests_reporter/__fixtures__/cypress_report.xml new file mode 100644 index 000000000000..ed0e154552ca --- /dev/null +++ b/packages/kbn-test/src/failed_tests_reporter/__fixtures__/cypress_report.xml @@ -0,0 +1,50 @@ + + + + + + + + + ...` + +You can fix this problem by: + - Passing `{force: true}` which disables all error checking + - Passing `{waitForAnimations: false}` which disables waiting on animations + - Passing `{animationDistanceThreshold: 20}` which decreases the sensitivity + +https://on.cypress.io/element-is-animating + +Because this error occurred during a `after each` hook we are skipping the remaining tests in the current suite: `timeline flyout button` + at cypressErr (http://elastic:changeme@localhost:61141/__cypress/runner/cypress_runner.js:146621:16) + at cypressErrByPath (http://elastic:changeme@localhost:61141/__cypress/runner/cypress_runner.js:146630:10) + at Object.throwErrByPath (http://elastic:changeme@localhost:61141/__cypress/runner/cypress_runner.js:146593:11) + at Object.ensureElementIsNotAnimating (http://elastic:changeme@localhost:61141/__cypress/runner/cypress_runner.js:137560:24) + at ensureNotAnimating (http://elastic:changeme@localhost:61141/__cypress/runner/cypress_runner.js:127434:13) + at runAllChecks (http://elastic:changeme@localhost:61141/__cypress/runner/cypress_runner.js:127522:9) + at retryActionability (http://elastic:changeme@localhost:61141/__cypress/runner/cypress_runner.js:127542:16) + at tryCatcher (http://elastic:changeme@localhost:61141/__cypress/runner/cypress_runner.js:9065:23) + at Function.Promise.attempt.Promise.try (http://elastic:changeme@localhost:61141/__cypress/runner/cypress_runner.js:6339:29) + at tryFn (http://elastic:changeme@localhost:61141/__cypress/runner/cypress_runner.js:140680:21) + at whenStable (http://elastic:changeme@localhost:61141/__cypress/runner/cypress_runner.js:140715:12) + at http://elastic:changeme@localhost:61141/__cypress/runner/cypress_runner.js:140259:16 + at tryCatcher (http://elastic:changeme@localhost:61141/__cypress/runner/cypress_runner.js:9065:23) + at Promise._settlePromiseFromHandler (http://elastic:changeme@localhost:61141/__cypress/runner/cypress_runner.js:7000:31) + at Promise._settlePromise (http://elastic:changeme@localhost:61141/__cypress/runner/cypress_runner.js:7057:18) + at Promise._settlePromise0 (http://elastic:changeme@localhost:61141/__cypress/runner/cypress_runner.js:7102:10)]]> + + + diff --git a/packages/kbn-test/src/failed_tests_reporter/__fixtures__/index.ts b/packages/kbn-test/src/failed_tests_reporter/__fixtures__/index.ts index 02b6b5f06421..16ebe10ad542 100644 --- a/packages/kbn-test/src/failed_tests_reporter/__fixtures__/index.ts +++ b/packages/kbn-test/src/failed_tests_reporter/__fixtures__/index.ts @@ -23,3 +23,4 @@ export const FTR_REPORT = Fs.readFileSync(require.resolve('./ftr_report.xml'), ' export const JEST_REPORT = Fs.readFileSync(require.resolve('./jest_report.xml'), 'utf8'); export const KARMA_REPORT = Fs.readFileSync(require.resolve('./karma_report.xml'), 'utf8'); export const MOCHA_REPORT = Fs.readFileSync(require.resolve('./mocha_report.xml'), 'utf8'); +export const CYPRESS_REPORT = Fs.readFileSync(require.resolve('./cypress_report.xml'), 'utf8'); diff --git a/packages/kbn-test/src/failed_tests_reporter/add_messages_to_report.test.ts b/packages/kbn-test/src/failed_tests_reporter/add_messages_to_report.test.ts index f8f279151e07..53a74f6cc6af 100644 --- a/packages/kbn-test/src/failed_tests_reporter/add_messages_to_report.test.ts +++ b/packages/kbn-test/src/failed_tests_reporter/add_messages_to_report.test.ts @@ -39,7 +39,13 @@ jest.mock('fs', () => { }; }); -import { FTR_REPORT, JEST_REPORT, MOCHA_REPORT, KARMA_REPORT } from './__fixtures__'; +import { + FTR_REPORT, + JEST_REPORT, + MOCHA_REPORT, + KARMA_REPORT, + CYPRESS_REPORT, +} from './__fixtures__'; import { parseTestReport } from './test_report'; import { addMessagesToReport } from './add_messages_to_report'; @@ -270,6 +276,69 @@ it('rewrites mocha reports with minimal changes', async () => { `); }); +it('rewrites cypress reports with minimal changes', async () => { + const xml = await addMessagesToReport({ + messages: [ + { + classname: '"after each" hook for "toggles open the timeline"', + name: 'timeline flyout button "after each" hook for "toggles open the timeline"', + message: 'Some extra content\n', + }, + ], + report: await parseTestReport(CYPRESS_REPORT), + log, + reportPath: Path.resolve(__dirname, './__fixtures__/cypress_report.xml'), + }); + + expect(createPatch('cypress.xml', CYPRESS_REPORT, xml, { context: 0 })).toMatchInlineSnapshot(` + Index: cypress.xml + =================================================================== + --- cypress.xml [object Object] + +++ cypress.xml + @@ -1,25 +1,16 @@ + -‹?xml version="1.0" encoding="UTF-8"?› + +‹?xml version="1.0" encoding="utf-8"?› + ‹testsuites name="Mocha Tests" time="16.198" tests="2" failures="1"› + - ‹testsuite name="Root Suite" timestamp="2020-07-22T15:06:26" tests="0" file="cypress/integration/timeline_flyout_button.spec.ts" failures="0" time="0"› + - ‹/testsuite› + + ‹testsuite name="Root Suite" timestamp="2020-07-22T15:06:26" tests="0" file="cypress/integration/timeline_flyout_button.spec.ts" failures="0" time="0"/› + ‹testsuite name="timeline flyout button" timestamp="2020-07-22T15:06:26" tests="2" failures="1" time="16.198"› + - ‹testcase name="timeline flyout button toggles open the timeline" time="8.099" classname="toggles open the timeline"› + - ‹/testcase› + + ‹testcase name="timeline flyout button toggles open the timeline" time="8.099" classname="toggles open the timeline"/› + ‹testcase name="timeline flyout button "after each" hook for "toggles open the timeline"" time="8.099" classname=""after each" hook for "toggles open the timeline""› + - ‹failure message="Timed out retrying: \`cy.click()\` could not be issued because this element is currently animating: + + ‹failure message="Timed out retrying: \`cy.click()\` could not be issued because this element is currently animating: \`<button class="euiButtonEmpty euiButtonEmpty--text" type="button" data-test-subj="timeline-new"›...</button›\` You can fix this problem by: - Passing \`{force: true}\` which disables all error checking - Passing \`{waitForAnimations: false}\` which disables waiting on animations - Passing \`{animationDistanceThreshold: 20}\` which decreases the sensitivity https://on.cypress.io/element-is-animating Because this error occurred during a \`after each\` hook we are skipping the remaining tests in the current suite: \`timeline flyout button\`" type="CypressError"›‹![CDATA[Failed Tests Reporter: + + - Some extra content + + -\`<button class="euiButtonEmpty euiButtonEmpty--text" type="button" data-test-subj="timeline-new">...</button>\` + + -You can fix this problem by: + - - Passing \`{force: true}\` which disables all error checking + - - Passing \`{waitForAnimations: false}\` which disables waiting on animations + - - Passing \`{animationDistanceThreshold: 20}\` which decreases the sensitivity + +CypressError: Timed out retrying: \`cy.click()\` could not be issued because this element is currently animating: + + -https://on.cypress.io/element-is-animating + - + -Because this error occurred during a \`after each\` hook we are skipping the remaining tests in the current suite: \`timeline flyout button\`" type="CypressError"›‹![CDATA[CypressError: Timed out retrying: \`cy.click()\` could not be issued because this element is currently animating: + - + \`‹button class="euiButtonEmpty euiButtonEmpty--text" type="button" data-test-subj="timeline-new"›...‹/button›\` + + You can fix this problem by: + - Passing \`{force: true}\` which disables all error checking + @@ -46,5 +37,5 @@ + at Promise._settlePromise (http://elastic:changeme@localhost:61141/__cypress/runner/cypress_runner.js:7057:18) + at Promise._settlePromise0 (http://elastic:changeme@localhost:61141/__cypress/runner/cypress_runner.js:7102:10)]]›‹/failure› + ‹/testcase› + ‹/testsuite› + -‹/testsuites› + +‹/testsuites› + \\ No newline at end of file + + `); +}); + it('rewrites karma reports with minimal changes', async () => { const xml = await addMessagesToReport({ report: await parseTestReport(KARMA_REPORT), diff --git a/packages/kbn-test/src/failed_tests_reporter/add_messages_to_report.ts b/packages/kbn-test/src/failed_tests_reporter/add_messages_to_report.ts index 6bc7556db8a4..27bf8a9c7549 100644 --- a/packages/kbn-test/src/failed_tests_reporter/add_messages_to_report.ts +++ b/packages/kbn-test/src/failed_tests_reporter/add_messages_to_report.ts @@ -59,10 +59,14 @@ export async function addMessagesToReport(options: { log.info(`${classname} - ${name}:${messageList}`); const output = `Failed Tests Reporter:${messageList}\n\n`; - if (!testCase['system-out']) { - testCase['system-out'] = [output]; + if (typeof testCase.failure[0] === 'object' && testCase.failure[0].$.message) { + // failure with "messages" ignore the system-out on jenkins + // so we instead extend the failure message + testCase.failure[0]._ = output + testCase.failure[0]._; + } else if (!testCase['system-out']) { + testCase['system-out'] = [{ _: output }]; } else if (typeof testCase['system-out'][0] === 'string') { - testCase['system-out'][0] = output + String(testCase['system-out'][0]); + testCase['system-out'][0] = { _: output + testCase['system-out'][0] }; } else { testCase['system-out'][0]._ = output + testCase['system-out'][0]._; } diff --git a/packages/kbn-test/src/failed_tests_reporter/run_failed_tests_reporter_cli.ts b/packages/kbn-test/src/failed_tests_reporter/run_failed_tests_reporter_cli.ts index 8a951ac96919..3dfb1ea44d9e 100644 --- a/packages/kbn-test/src/failed_tests_reporter/run_failed_tests_reporter_cli.ts +++ b/packages/kbn-test/src/failed_tests_reporter/run_failed_tests_reporter_cli.ts @@ -72,6 +72,7 @@ export function runFailedTestsReporterCli() { } const patterns = flags._.length ? flags._ : DEFAULT_PATTERNS; + log.info('Searching for reports at', patterns); const reportPaths = await globby(patterns, { absolute: true, }); @@ -80,6 +81,7 @@ export function runFailedTestsReporterCli() { throw createFailError(`Unable to find any junit reports with patterns [${patterns}]`); } + log.info('found', reportPaths.length, 'junit reports', reportPaths); const newlyCreatedIssues: Array<{ failure: TestFailure; newIssue: GithubIssueMini; diff --git a/packages/kbn-test/src/failed_tests_reporter/test_report.ts b/packages/kbn-test/src/failed_tests_reporter/test_report.ts index 43d84163462d..9907ca8b89ca 100644 --- a/packages/kbn-test/src/failed_tests_reporter/test_report.ts +++ b/packages/kbn-test/src/failed_tests_reporter/test_report.ts @@ -70,7 +70,7 @@ export interface TestCase { } export interface FailedTestCase extends TestCase { - failure: Array; + failure: Array; } /** From 0103cd342439bcde987c7761c1c538394001f30b Mon Sep 17 00:00:00 2001 From: Devon Thomson Date: Thu, 23 Jul 2020 12:21:31 -0400 Subject: [PATCH 101/142] [Fix] Lose OriginatingApp Connection on Save As (#72725) Reset originatingApp in lens and visualize when return to dashboard on saving is false --- .../application/components/visualize_editor.tsx | 1 + .../application/components/visualize_top_nav.tsx | 4 ++++ .../application/utils/get_top_nav_config.tsx | 5 +++++ .../apps/dashboard/edit_embeddable_redirects.js | 12 ++++++++++++ test/functional/page_objects/visualize_page.ts | 10 ++++++++++ x-pack/plugins/lens/public/app_plugin/app.tsx | 15 +++++++++++---- .../apps/dashboard_mode/dashboard_empty_screen.js | 10 ++++++++++ x-pack/test/functional/page_objects/lens_page.ts | 10 ++++++++++ 8 files changed, 63 insertions(+), 4 deletions(-) diff --git a/src/plugins/visualize/public/application/components/visualize_editor.tsx b/src/plugins/visualize/public/application/components/visualize_editor.tsx index c571a5fb078b..516dcacfe581 100644 --- a/src/plugins/visualize/public/application/components/visualize_editor.tsx +++ b/src/plugins/visualize/public/application/components/visualize_editor.tsx @@ -89,6 +89,7 @@ export const VisualizeEditor = () => { isEmbeddableRendered={isEmbeddableRendered} hasUnappliedChanges={hasUnappliedChanges} originatingApp={originatingApp} + setOriginatingApp={setOriginatingApp} savedVisInstance={savedVisInstance} stateContainer={appState} visualizationIdFromUrl={visualizationIdFromUrl} diff --git a/src/plugins/visualize/public/application/components/visualize_top_nav.tsx b/src/plugins/visualize/public/application/components/visualize_top_nav.tsx index 2e7dba46487a..f00c26f83e1e 100644 --- a/src/plugins/visualize/public/application/components/visualize_top_nav.tsx +++ b/src/plugins/visualize/public/application/components/visualize_top_nav.tsx @@ -40,6 +40,7 @@ interface VisualizeTopNavProps { setHasUnsavedChanges: (value: boolean) => void; hasUnappliedChanges: boolean; originatingApp?: string; + setOriginatingApp?: (originatingApp: string | undefined) => void; savedVisInstance: SavedVisInstance; stateContainer: VisualizeAppStateContainer; visualizationIdFromUrl?: string; @@ -53,6 +54,7 @@ const TopNav = ({ setHasUnsavedChanges, hasUnappliedChanges, originatingApp, + setOriginatingApp, savedVisInstance, stateContainer, visualizationIdFromUrl, @@ -86,6 +88,7 @@ const TopNav = ({ hasUnappliedChanges, openInspector, originatingApp, + setOriginatingApp, savedVisInstance, stateContainer, visualizationIdFromUrl, @@ -100,6 +103,7 @@ const TopNav = ({ hasUnappliedChanges, openInspector, originatingApp, + setOriginatingApp, savedVisInstance, stateContainer, visualizationIdFromUrl, diff --git a/src/plugins/visualize/public/application/utils/get_top_nav_config.tsx b/src/plugins/visualize/public/application/utils/get_top_nav_config.tsx index 96f64c6478fa..392168a53008 100644 --- a/src/plugins/visualize/public/application/utils/get_top_nav_config.tsx +++ b/src/plugins/visualize/public/application/utils/get_top_nav_config.tsx @@ -39,6 +39,7 @@ interface TopNavConfigParams { setHasUnsavedChanges: (value: boolean) => void; openInspector: () => void; originatingApp?: string; + setOriginatingApp?: (originatingApp: string | undefined) => void; hasUnappliedChanges: boolean; savedVisInstance: SavedVisInstance; stateContainer: VisualizeAppStateContainer; @@ -51,6 +52,7 @@ export const getTopNavConfig = ( setHasUnsavedChanges, openInspector, originatingApp, + setOriginatingApp, hasUnappliedChanges, savedVisInstance: { embeddableHandler, savedVis, vis }, stateContainer, @@ -112,6 +114,9 @@ export const getTopNavConfig = ( application.navigateToApp(originatingApp); } } else { + if (setOriginatingApp && originatingApp && savedVis.copyOnSave) { + setOriginatingApp(undefined); + } chrome.docTitle.change(savedVis.lastSavedTitle); chrome.setBreadcrumbs(getEditBreadcrumbs(savedVis.lastSavedTitle)); diff --git a/test/functional/apps/dashboard/edit_embeddable_redirects.js b/test/functional/apps/dashboard/edit_embeddable_redirects.js index a366e34b121d..6d3d43890a96 100644 --- a/test/functional/apps/dashboard/edit_embeddable_redirects.js +++ b/test/functional/apps/dashboard/edit_embeddable_redirects.js @@ -75,5 +75,17 @@ export default function ({ getService, getPageObjects }) { const titles = await PageObjects.dashboard.getPanelTitles(); expect(titles.indexOf(newTitle)).to.not.be(-1); }); + + it('loses originatingApp connection after save as when redirectToOrigin is false', async () => { + const newTitle = 'wowee, my title just got cooler again'; + await PageObjects.header.waitUntilLoadingHasFinished(); + await dashboardPanelActions.openContextMenu(); + await dashboardPanelActions.clickEdit(); + await PageObjects.visualize.saveVisualizationExpectSuccess(newTitle, { + saveAsNew: true, + redirectToOrigin: false, + }); + await PageObjects.visualize.notLinkedToOriginatingApp(); + }); }); } diff --git a/test/functional/page_objects/visualize_page.ts b/test/functional/page_objects/visualize_page.ts index a08598fc42d6..92692767b096 100644 --- a/test/functional/page_objects/visualize_page.ts +++ b/test/functional/page_objects/visualize_page.ts @@ -352,6 +352,16 @@ export function VisualizePageProvider({ getService, getPageObjects }: FtrProvide await testSubjects.existOrFail('visualizesaveAndReturnButton'); await testSubjects.click('visualizesaveAndReturnButton'); } + + public async linkedToOriginatingApp() { + await header.waitUntilLoadingHasFinished(); + await testSubjects.existOrFail('visualizesaveAndReturnButton'); + } + + public async notLinkedToOriginatingApp() { + await header.waitUntilLoadingHasFinished(); + await testSubjects.missingOrFail('visualizesaveAndReturnButton'); + } } return new VisualizePage(); diff --git a/x-pack/plugins/lens/public/app_plugin/app.tsx b/x-pack/plugins/lens/public/app_plugin/app.tsx index 9b8b9a8531cf..082a3afcd513 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.tsx @@ -44,6 +44,7 @@ interface State { isLoading: boolean; isSaveModalVisible: boolean; indexPatternsForTopNav: IndexPatternInstance[]; + originatingApp?: string; persistedDoc?: Document; lastKnownDoc?: Document; @@ -97,6 +98,7 @@ export function App({ fromDate: currentRange.from, toDate: currentRange.to, }, + originatingApp, filters: [], indicateNoData: false, }; @@ -321,9 +323,14 @@ export function App({ .then(({ id }) => { // Prevents unnecessary network request and disables save button const newDoc = { ...doc, id }; + const currentOriginatingApp = state.originatingApp; setState((s) => ({ ...s, isSaveModalVisible: false, + originatingApp: + saveProps.newCopyOnSave && !saveProps.returnToOrigin + ? undefined + : currentOriginatingApp, persistedDoc: newDoc, lastKnownDoc: newDoc, })); @@ -368,7 +375,7 @@ export function App({
{ if (isSaveable && lastKnownDoc) { setState((s) => ({ ...s, isSaveModalVisible: true })); @@ -523,7 +530,7 @@ export function App({
{lastKnownDoc && state.isSaveModalVisible && ( runSave(props)} onClose={() => setState((s) => ({ ...s, isSaveModalVisible: false }))} documentInfo={{ diff --git a/x-pack/test/functional/apps/dashboard_mode/dashboard_empty_screen.js b/x-pack/test/functional/apps/dashboard_mode/dashboard_empty_screen.js index c8a8f9653c11..62e07a08d176 100644 --- a/x-pack/test/functional/apps/dashboard_mode/dashboard_empty_screen.js +++ b/x-pack/test/functional/apps/dashboard_mode/dashboard_empty_screen.js @@ -98,5 +98,15 @@ export default function ({ getPageObjects, getService }) { const titles = await PageObjects.dashboard.getPanelTitles(); expect(titles.indexOf(newTitle)).to.not.be(-1); }); + + it('loses originatingApp connection after save as when redirectToOrigin is false', async () => { + const newTitle = 'wowee, my title just got cooler again'; + await PageObjects.dashboard.waitForRenderComplete(); + await dashboardPanelActions.openContextMenu(); + await dashboardPanelActions.clickEdit(); + await PageObjects.lens.save(newTitle, true, false); + await PageObjects.lens.notLinkedToOriginatingApp(); + await PageObjects.common.navigateToApp('dashboard'); + }); }); } diff --git a/x-pack/test/functional/page_objects/lens_page.ts b/x-pack/test/functional/page_objects/lens_page.ts index d101c9754d56..79548db0e263 100644 --- a/x-pack/test/functional/page_objects/lens_page.ts +++ b/x-pack/test/functional/page_objects/lens_page.ts @@ -195,5 +195,15 @@ export function LensPageProvider({ getService, getPageObjects }: FtrProviderCont async createLayer() { await testSubjects.click('lnsLayerAddButton'); }, + + async linkedToOriginatingApp() { + await PageObjects.header.waitUntilLoadingHasFinished(); + await testSubjects.existOrFail('lnsApp_saveAndReturnButton'); + }, + + async notLinkedToOriginatingApp() { + await PageObjects.header.waitUntilLoadingHasFinished(); + await testSubjects.missingOrFail('lnsApp_saveAndReturnButton'); + }, }); } From 6d480c7f228e570b36254e8728cce02d25b494d7 Mon Sep 17 00:00:00 2001 From: Quynh Nguyen <43350163+qn895@users.noreply.github.com> Date: Thu, 23 Jul 2020 11:38:29 -0500 Subject: [PATCH 102/142] [ML] Add API integration tests for /filters and /calendars (#72564) Co-authored-by: Elastic Machine --- .../apis/ml/calendars/create_calendars.ts | 84 ++++++++++ .../apis/ml/calendars/delete_calendars.ts | 87 +++++++++++ .../apis/ml/calendars/get_calendars.ts | 145 ++++++++++++++++++ .../apis/ml/calendars/helpers.ts | 31 ++++ .../apis/ml/calendars/index.ts | 16 ++ .../apis/ml/calendars/update_calendars.ts | 104 +++++++++++++ .../apis/ml/filters/create_filters.ts | 127 +++++++++++++++ .../apis/ml/filters/delete_filters.ts | 95 ++++++++++++ .../apis/ml/filters/get_filters.ts | 98 ++++++++++++ .../api_integration/apis/ml/filters/index.ts | 16 ++ .../apis/ml/filters/update_filters.ts | 118 ++++++++++++++ x-pack/test/api_integration/apis/ml/index.ts | 2 + x-pack/test/functional/services/ml/api.ts | 139 +++++++++++++++-- 13 files changed, 1052 insertions(+), 10 deletions(-) create mode 100644 x-pack/test/api_integration/apis/ml/calendars/create_calendars.ts create mode 100644 x-pack/test/api_integration/apis/ml/calendars/delete_calendars.ts create mode 100644 x-pack/test/api_integration/apis/ml/calendars/get_calendars.ts create mode 100644 x-pack/test/api_integration/apis/ml/calendars/helpers.ts create mode 100644 x-pack/test/api_integration/apis/ml/calendars/index.ts create mode 100644 x-pack/test/api_integration/apis/ml/calendars/update_calendars.ts create mode 100644 x-pack/test/api_integration/apis/ml/filters/create_filters.ts create mode 100644 x-pack/test/api_integration/apis/ml/filters/delete_filters.ts create mode 100644 x-pack/test/api_integration/apis/ml/filters/get_filters.ts create mode 100644 x-pack/test/api_integration/apis/ml/filters/index.ts create mode 100644 x-pack/test/api_integration/apis/ml/filters/update_filters.ts diff --git a/x-pack/test/api_integration/apis/ml/calendars/create_calendars.ts b/x-pack/test/api_integration/apis/ml/calendars/create_calendars.ts new file mode 100644 index 000000000000..f163df0109ff --- /dev/null +++ b/x-pack/test/api_integration/apis/ml/calendars/create_calendars.ts @@ -0,0 +1,84 @@ +/* + * 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 expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../../ftr_provider_context'; +import { USER } from '../../../../functional/services/ml/security_common'; +import { COMMON_REQUEST_HEADERS } from '../../../../functional/services/ml/common'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext) => { + const supertest = getService('supertestWithoutAuth'); + const ml = getService('ml'); + + describe('create_calendars', function () { + const calendarId = `test_create_calendar`; + + const requestBody = { + calendarId, + job_ids: ['test_job_1', 'test_job_2'], + description: 'Test calendar', + events: [ + { description: 'event 1', start_time: 1513641600000, end_time: 1513728000000 }, + { description: 'event 2', start_time: 1513814400000, end_time: 1513900800000 }, + { description: 'event 3', start_time: 1514160000000, end_time: 1514246400000 }, + ], + }; + + before(async () => { + await ml.testResources.setKibanaTimeZoneToUTC(); + }); + + afterEach(async () => { + await ml.api.deleteCalendar(calendarId); + }); + + it('should successfully create calendar by id', async () => { + await supertest + .put(`/api/ml/calendars`) + .auth(USER.ML_POWERUSER, ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER)) + .set(COMMON_REQUEST_HEADERS) + .send(requestBody) + .expect(200); + + const results = await ml.api.getCalendar(requestBody.calendarId); + const createdCalendar = results.body.calendars[0]; + + expect(createdCalendar.calendar_id).to.eql(requestBody.calendarId); + expect(createdCalendar.description).to.eql(requestBody.description); + expect(createdCalendar.job_ids).to.eql(requestBody.job_ids); + + await ml.api.waitForEventsToExistInCalendar(calendarId, requestBody.events); + }); + + it('should not create new calendar for user without required permission', async () => { + const { body } = await supertest + .put(`/api/ml/calendars`) + .auth(USER.ML_VIEWER, ml.securityCommon.getPasswordForUser(USER.ML_VIEWER)) + .set(COMMON_REQUEST_HEADERS) + .send(requestBody) + .expect(404); + + expect(body.error).to.eql('Not Found'); + expect(body.message).to.eql('Not Found'); + await ml.api.waitForCalendarNotToExist(calendarId); + }); + + it('should not create new calendar for unauthorized user', async () => { + const { body } = await supertest + .put(`/api/ml/calendars`) + .auth(USER.ML_UNAUTHORIZED, ml.securityCommon.getPasswordForUser(USER.ML_UNAUTHORIZED)) + .set(COMMON_REQUEST_HEADERS) + .send(requestBody) + .expect(404); + + expect(body.error).to.eql('Not Found'); + expect(body.message).to.eql('Not Found'); + await ml.api.waitForCalendarNotToExist(calendarId); + }); + }); +}; diff --git a/x-pack/test/api_integration/apis/ml/calendars/delete_calendars.ts b/x-pack/test/api_integration/apis/ml/calendars/delete_calendars.ts new file mode 100644 index 000000000000..5c5d5a3c432f --- /dev/null +++ b/x-pack/test/api_integration/apis/ml/calendars/delete_calendars.ts @@ -0,0 +1,87 @@ +/* + * 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 expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../../ftr_provider_context'; +import { USER } from '../../../../functional/services/ml/security_common'; +import { COMMON_REQUEST_HEADERS } from '../../../../functional/services/ml/common'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext) => { + const supertest = getService('supertestWithoutAuth'); + const ml = getService('ml'); + + describe('delete_calendars', function () { + const calendarId = `test_delete_cal`; + const testCalendar = { + calendar_id: calendarId, + job_ids: ['test_job_1', 'test_job_2'], + description: `Test calendar`, + }; + const testEvents = [ + { description: 'event 1', start_time: 1513641600000, end_time: 1513728000000 }, + { description: 'event 2', start_time: 1513814400000, end_time: 1513900800000 }, + { description: 'event 3', start_time: 1514160000000, end_time: 1514246400000 }, + ]; + + before(async () => { + await ml.testResources.setKibanaTimeZoneToUTC(); + }); + + beforeEach(async () => { + await ml.api.createCalendar(calendarId, testCalendar); + await ml.api.createCalendarEvents(calendarId, testEvents); + }); + + afterEach(async () => { + await ml.api.deleteCalendar(calendarId); + }); + + it('should delete calendar by id', async () => { + const { body } = await supertest + .delete(`/api/ml/calendars/${calendarId}`) + .auth(USER.ML_POWERUSER, ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER)) + .set(COMMON_REQUEST_HEADERS) + .expect(200); + + expect(body.acknowledged).to.eql(true); + await ml.api.waitForCalendarNotToExist(calendarId); + }); + + it('should not delete calendar for user without required permission', async () => { + const { body } = await supertest + .delete(`/api/ml/calendars/${calendarId}`) + .auth(USER.ML_VIEWER, ml.securityCommon.getPasswordForUser(USER.ML_VIEWER)) + .set(COMMON_REQUEST_HEADERS) + .expect(404); + + expect(body.error).to.eql('Not Found'); + await ml.api.waitForCalendarToExist(calendarId); + }); + + it('should not delete calendar for unauthorized user', async () => { + const { body } = await supertest + .delete(`/api/ml/calendars/${calendarId}`) + .auth(USER.ML_UNAUTHORIZED, ml.securityCommon.getPasswordForUser(USER.ML_UNAUTHORIZED)) + .set(COMMON_REQUEST_HEADERS) + .expect(404); + + expect(body.error).to.eql('Not Found'); + await ml.api.waitForCalendarToExist(calendarId); + }); + + it('should return 404 if invalid calendarId', async () => { + const { body } = await supertest + .delete(`/api/ml/calendars/calendar_id_dne`) + .auth(USER.ML_POWERUSER, ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER)) + .set(COMMON_REQUEST_HEADERS) + .expect(404); + + expect(body.error).to.eql('Not Found'); + }); + }); +}; diff --git a/x-pack/test/api_integration/apis/ml/calendars/get_calendars.ts b/x-pack/test/api_integration/apis/ml/calendars/get_calendars.ts new file mode 100644 index 000000000000..e115986b2f09 --- /dev/null +++ b/x-pack/test/api_integration/apis/ml/calendars/get_calendars.ts @@ -0,0 +1,145 @@ +/* + * 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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../ftr_provider_context'; +import { USER } from '../../../../functional/services/ml/security_common'; +import { COMMON_REQUEST_HEADERS } from '../../../../functional/services/ml/common'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext) => { + const supertest = getService('supertestWithoutAuth'); + const ml = getService('ml'); + + describe('get_calendars', function () { + const testEvents = [ + { description: 'event 1', start_time: 1513641600000, end_time: 1513728000000 }, + { description: 'event 2', start_time: 1513814400000, end_time: 1513900800000 }, + { description: 'event 3', start_time: 1514160000000, end_time: 1514246400000 }, + ]; + + before(async () => { + await ml.testResources.setKibanaTimeZoneToUTC(); + }); + + describe('get multiple calendars', function () { + const testCalendars = [1, 2, 3].map((num) => ({ + calendar_id: `test_get_cal_${num}`, + job_ids: ['test_job_1', 'test_job_2'], + description: `Test calendar ${num}`, + })); + + beforeEach(async () => { + for (const testCalendar of testCalendars) { + await ml.api.createCalendar(testCalendar.calendar_id, testCalendar); + await ml.api.createCalendarEvents(testCalendar.calendar_id, testEvents); + } + }); + + afterEach(async () => { + for (const testCalendar of testCalendars) { + await ml.api.deleteCalendar(testCalendar.calendar_id); + } + }); + + it('should fetch all calendars', async () => { + const { body } = await supertest + .get(`/api/ml/calendars`) + .auth(USER.ML_POWERUSER, ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER)) + .set(COMMON_REQUEST_HEADERS) + .expect(200); + + expect(body).to.have.length(testCalendars.length); + expect(body[0].events).to.have.length(testEvents.length); + ml.api.assertAllEventsExistInCalendar(testEvents, body[0]); + }); + + it('should fetch all calendars for user with view permission', async () => { + const { body } = await supertest + .get(`/api/ml/calendars`) + .auth(USER.ML_VIEWER, ml.securityCommon.getPasswordForUser(USER.ML_VIEWER)) + .set(COMMON_REQUEST_HEADERS) + .expect(200); + + expect(body).to.have.length(testCalendars.length); + expect(body[0].events).to.have.length(testEvents.length); + ml.api.assertAllEventsExistInCalendar(testEvents, body[0]); + }); + + it('should not fetch calendars for unauthorized user', async () => { + const { body } = await supertest + .get(`/api/ml/calendars`) + .auth(USER.ML_UNAUTHORIZED, ml.securityCommon.getPasswordForUser(USER.ML_UNAUTHORIZED)) + .set(COMMON_REQUEST_HEADERS) + .expect(404); + expect(body.error).to.eql('Not Found'); + }); + }); + + describe('get calendar by id', function () { + const calendarId = `test_get_cal`; + const testCalendar = { + calendar_id: calendarId, + job_ids: ['test_job_1', 'test_job_2'], + description: `Test calendar`, + }; + + beforeEach(async () => { + await ml.api.createCalendar(calendarId, testCalendar); + await ml.api.createCalendarEvents(calendarId, testEvents); + }); + + afterEach(async () => { + await ml.api.deleteCalendar(calendarId); + }); + + it('should fetch calendar & associated events by id', async () => { + const { body } = await supertest + .get(`/api/ml/calendars/${calendarId}`) + .auth(USER.ML_POWERUSER, ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER)) + .set(COMMON_REQUEST_HEADERS) + .expect(200); + + expect(body.job_ids).to.eql(testCalendar.job_ids); + expect(body.description).to.eql(testCalendar.description); + expect(body.events).to.have.length(testEvents.length); + ml.api.assertAllEventsExistInCalendar(testEvents, body); + }); + + it('should fetch calendar & associated events by id for user with view permission', async () => { + const { body } = await supertest + .get(`/api/ml/calendars/${calendarId}`) + .auth(USER.ML_VIEWER, ml.securityCommon.getPasswordForUser(USER.ML_VIEWER)) + .set(COMMON_REQUEST_HEADERS) + .expect(200); + + expect(body.job_ids).to.eql(testCalendar.job_ids); + expect(body.description).to.eql(testCalendar.description); + expect(body.events).to.have.length(testEvents.length); + ml.api.assertAllEventsExistInCalendar(testEvents, body); + }); + + it('should not fetch calendars for unauthorized user', async () => { + const { body } = await supertest + .get(`/api/ml/calendars/${calendarId}`) + .auth(USER.ML_UNAUTHORIZED, ml.securityCommon.getPasswordForUser(USER.ML_UNAUTHORIZED)) + .set(COMMON_REQUEST_HEADERS) + .expect(404); + + expect(body.error).to.eql('Not Found'); + }); + }); + + it('should return 404 if invalid calendar id', async () => { + const { body } = await supertest + .get(`/api/ml/calendars/calendar_id_dne`) + .auth(USER.ML_POWERUSER, ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER)) + .set(COMMON_REQUEST_HEADERS) + .expect(404); + expect(body.error).to.eql('Not Found'); + }); + }); +}; diff --git a/x-pack/test/api_integration/apis/ml/calendars/helpers.ts b/x-pack/test/api_integration/apis/ml/calendars/helpers.ts new file mode 100644 index 000000000000..5d143d9b451f --- /dev/null +++ b/x-pack/test/api_integration/apis/ml/calendars/helpers.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Calendar, CalendarEvent } from '../../../../../plugins/ml/server/models/calendar'; + +export const assertAllEventsExistInCalendar = ( + eventsToCheck: CalendarEvent[], + calendar: Calendar +): boolean => { + const updatedCalendarEvents = calendar.events as CalendarEvent[]; + let allEventsAreUpdated = true; + for (const eventToCheck of eventsToCheck) { + // if at least one of the events that we need to check is not in the updated events + // no need to continue + if ( + updatedCalendarEvents.findIndex( + (updatedEvent) => + updatedEvent.description === eventToCheck.description && + updatedEvent.start_time === eventToCheck.start_time && + updatedEvent.end_time === eventToCheck.end_time + ) < 0 + ) { + allEventsAreUpdated = false; + break; + } + } + return allEventsAreUpdated; +}; diff --git a/x-pack/test/api_integration/apis/ml/calendars/index.ts b/x-pack/test/api_integration/apis/ml/calendars/index.ts new file mode 100644 index 000000000000..e7d824205e6c --- /dev/null +++ b/x-pack/test/api_integration/apis/ml/calendars/index.ts @@ -0,0 +1,16 @@ +/* + * 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 { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('calendars', function () { + loadTestFile(require.resolve('./create_calendars')); + loadTestFile(require.resolve('./get_calendars')); + loadTestFile(require.resolve('./delete_calendars')); + loadTestFile(require.resolve('./update_calendars')); + }); +} diff --git a/x-pack/test/api_integration/apis/ml/calendars/update_calendars.ts b/x-pack/test/api_integration/apis/ml/calendars/update_calendars.ts new file mode 100644 index 000000000000..5194370b19e6 --- /dev/null +++ b/x-pack/test/api_integration/apis/ml/calendars/update_calendars.ts @@ -0,0 +1,104 @@ +/* + * 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 expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../../ftr_provider_context'; +import { USER } from '../../../../functional/services/ml/security_common'; +import { COMMON_REQUEST_HEADERS } from '../../../../functional/services/ml/common'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext) => { + const supertest = getService('supertestWithoutAuth'); + const ml = getService('ml'); + + describe('update_calendars', function () { + before(async () => { + await ml.testResources.setKibanaTimeZoneToUTC(); + }); + + const calendarId = `test_update_cal`; + const originalCalendar = { + calendar_id: calendarId, + job_ids: ['test_job_1'], + description: `Test calendar`, + }; + const originalEvents = [ + { description: 'event 1', start_time: 1513641600000, end_time: 1513728000000 }, + ]; + + const updateCalendarRequestBody = { + calendarId, + job_ids: ['test_updated_job_1', 'test_updated_job_2'], + description: 'Updated calendar #1', + events: [ + { description: 'updated event 2', start_time: 1513814400000, end_time: 1513900800000 }, + { description: 'updated event 3', start_time: 1514160000000, end_time: 1514246400000 }, + ], + }; + + beforeEach(async () => { + await ml.api.createCalendar(calendarId, originalCalendar); + await ml.api.createCalendarEvents(calendarId, originalEvents); + }); + + afterEach(async () => { + await ml.api.deleteCalendar(calendarId); + }); + + it('should update calendar by id with new settings', async () => { + await supertest + .put(`/api/ml/calendars/${calendarId}`) + .auth(USER.ML_POWERUSER, ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER)) + .set(COMMON_REQUEST_HEADERS) + .send(updateCalendarRequestBody) + .expect(200); + + await ml.api.waitForCalendarToExist(calendarId); + + const getCalendarResult = await ml.api.getCalendar(calendarId); + const getEventsResult = await ml.api.getCalendarEvents(calendarId); + + const updatedCalendar = getCalendarResult.body.calendars[0]; + const updatedEvents = getEventsResult.body.events; + + expect(updatedCalendar.calendar_id).to.eql(updateCalendarRequestBody.calendarId); + expect(updatedCalendar.job_ids).to.have.length(updateCalendarRequestBody.job_ids.length); + expect(updatedEvents).to.have.length(updateCalendarRequestBody.events.length); + await ml.api.waitForEventsToExistInCalendar( + updatedCalendar.calendar_id, + updateCalendarRequestBody.events + ); + }); + + it('should not allow to update calendar for user without required permission ', async () => { + await supertest + .put(`/api/ml/calendars/${calendarId}`) + .auth(USER.ML_VIEWER, ml.securityCommon.getPasswordForUser(USER.ML_VIEWER)) + .set(COMMON_REQUEST_HEADERS) + .send(updateCalendarRequestBody) + .expect(404); + }); + + it('should not allow to update calendar for unauthorized user', async () => { + await supertest + .put(`/api/ml/calendars/${calendarId}`) + .auth(USER.ML_UNAUTHORIZED, ml.securityCommon.getPasswordForUser(USER.ML_UNAUTHORIZED)) + .set(COMMON_REQUEST_HEADERS) + .send(updateCalendarRequestBody) + .expect(404); + }); + + it('should return error if invalid calendarId ', async () => { + await supertest + .put(`/api/ml/calendars/calendar_id_dne`) + .auth(USER.ML_VIEWER, ml.securityCommon.getPasswordForUser(USER.ML_VIEWER)) + .set(COMMON_REQUEST_HEADERS) + .send(updateCalendarRequestBody) + .expect(404); + }); + }); +}; diff --git a/x-pack/test/api_integration/apis/ml/filters/create_filters.ts b/x-pack/test/api_integration/apis/ml/filters/create_filters.ts new file mode 100644 index 000000000000..c175d3a9a3d9 --- /dev/null +++ b/x-pack/test/api_integration/apis/ml/filters/create_filters.ts @@ -0,0 +1,127 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../../ftr_provider_context'; +import { USER } from '../../../../functional/services/ml/security_common'; +import { COMMON_REQUEST_HEADERS } from '../../../../functional/services/ml/common'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext) => { + const supertest = getService('supertestWithoutAuth'); + const ml = getService('ml'); + + const testDataList = [ + { + testTitle: 'should successfully create new filter', + user: USER.ML_POWERUSER, + requestBody: { filterId: 'safe_ip_addresses', description: '', items: ['104.236.210.185'] }, + expected: { + responseCode: 200, + responseBody: { + filter_id: 'safe_ip_addresses', + description: '', + items: ['104.236.210.185'], + }, + }, + }, + { + testTitle: 'should not create new filter for user without required permission', + user: USER.ML_VIEWER, + requestBody: { + filterId: 'safe_ip_addresses_view_only', + + description: '', + items: ['104.236.210.185'], + }, + expected: { + responseCode: 404, + responseBody: { + error: 'Not Found', + message: 'Not Found', + }, + }, + }, + { + testTitle: 'should not create new filter for unauthorized user', + user: USER.ML_UNAUTHORIZED, + requestBody: { + filterId: 'safe_ip_addresses_unauthorized', + description: '', + items: ['104.236.210.185'], + }, + expected: { + responseCode: 404, + responseBody: { + error: 'Not Found', + message: 'Not Found', + }, + }, + }, + { + testTitle: 'should return 400 bad request if invalid filterId', + user: USER.ML_POWERUSER, + requestBody: { + filterId: '@invalid_filter_id', + description: '', + items: ['104.236.210.185'], + }, + expected: { + responseCode: 400, + responseBody: { + error: 'Bad Request', + message: 'Invalid filter_id', + }, + }, + }, + { + testTitle: 'should return 400 bad request if invalid items', + user: USER.ML_POWERUSER, + requestBody: { filterId: 'valid_filter', description: '' }, + expected: { + responseCode: 400, + responseBody: { + error: 'Bad Request', + message: 'expected value of type [array] but got [undefined]', + }, + }, + }, + ]; + + describe('create_filters', function () { + before(async () => { + await ml.testResources.setKibanaTimeZoneToUTC(); + }); + + after(async () => { + for (const testData of testDataList) { + const { filterId } = testData.requestBody; + await ml.api.deleteFilter(filterId); + } + }); + + for (const testData of testDataList) { + const { testTitle, user, requestBody, expected } = testData; + it(`${testTitle}`, async () => { + const { body } = await supertest + .put(`/api/ml/filters`) + .auth(user, ml.securityCommon.getPasswordForUser(user)) + .set(COMMON_REQUEST_HEADERS) + .send(requestBody) + .expect(expected.responseCode); + if (body.error === undefined) { + // Validate the important parts of the response. + const expectedResponse = testData.expected.responseBody; + expect(body).to.eql(expectedResponse); + } else { + expect(body.error).to.contain(expected.responseBody.error); + expect(body.message).to.contain(expected.responseBody.message); + } + }); + } + }); +}; diff --git a/x-pack/test/api_integration/apis/ml/filters/delete_filters.ts b/x-pack/test/api_integration/apis/ml/filters/delete_filters.ts new file mode 100644 index 000000000000..bb83a7f72069 --- /dev/null +++ b/x-pack/test/api_integration/apis/ml/filters/delete_filters.ts @@ -0,0 +1,95 @@ +/* + * 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 expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../../ftr_provider_context'; +import { USER } from '../../../../functional/services/ml/security_common'; +import { COMMON_REQUEST_HEADERS } from '../../../../functional/services/ml/common'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext) => { + const supertest = getService('supertestWithoutAuth'); + const ml = getService('ml'); + + const items = ['104.236.210.185']; + const validFilters = [ + { + filterId: 'filter_power', + requestBody: { description: 'Test delete filter #1', items }, + }, + { + filterId: 'filter_viewer', + requestBody: { description: 'Test delete filter (viewer)', items }, + }, + { + filterId: 'filter_unauthorized', + requestBody: { description: 'Test delete filter (unauthorized)', items }, + }, + ]; + + describe('delete_filters', function () { + before(async () => { + await ml.testResources.setKibanaTimeZoneToUTC(); + for (const filter of validFilters) { + const { filterId, requestBody } = filter; + await ml.api.createFilter(filterId, requestBody); + } + }); + + after(async () => { + for (const filter of validFilters) { + const { filterId } = filter; + await ml.api.deleteFilter(filterId); + } + }); + + it(`should delete filter by id`, async () => { + const { filterId } = validFilters[0]; + const { body } = await supertest + .delete(`/api/ml/filters/${filterId}`) + .auth(USER.ML_POWERUSER, ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER)) + .set(COMMON_REQUEST_HEADERS) + .expect(200); + + expect(body.acknowledged).to.eql(true); + await ml.api.waitForFilterToNotExist(filterId); + }); + + it(`should not delete filter for user without required permission`, async () => { + const { filterId } = validFilters[1]; + const { body } = await supertest + .delete(`/api/ml/filters/${filterId}`) + .auth(USER.ML_VIEWER, ml.securityCommon.getPasswordForUser(USER.ML_VIEWER)) + .set(COMMON_REQUEST_HEADERS) + .expect(404); + + expect(body.error).to.eql('Not Found'); + await ml.api.waitForFilterToExist(filterId); + }); + + it(`should not delete filter for unauthorized user`, async () => { + const { filterId } = validFilters[2]; + const { body } = await supertest + .delete(`/api/ml/filters/${filterId}`) + .auth(USER.ML_UNAUTHORIZED, ml.securityCommon.getPasswordForUser(USER.ML_UNAUTHORIZED)) + .set(COMMON_REQUEST_HEADERS) + .expect(404); + + expect(body.error).to.eql('Not Found'); + await ml.api.waitForFilterToExist(filterId); + }); + + it(`should not allow user to delete filter if invalid filterId`, async () => { + const { body } = await supertest + .delete(`/api/ml/filters/filter_id_dne`) + .auth(USER.ML_POWERUSER, ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER)) + .set(COMMON_REQUEST_HEADERS) + .expect(404); + expect(body.error).to.eql('Not Found'); + }); + }); +}; diff --git a/x-pack/test/api_integration/apis/ml/filters/get_filters.ts b/x-pack/test/api_integration/apis/ml/filters/get_filters.ts new file mode 100644 index 000000000000..3dd6093a9917 --- /dev/null +++ b/x-pack/test/api_integration/apis/ml/filters/get_filters.ts @@ -0,0 +1,98 @@ +/* + * 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 expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../../ftr_provider_context'; +import { USER } from '../../../../functional/services/ml/security_common'; +import { COMMON_REQUEST_HEADERS } from '../../../../functional/services/ml/common'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext) => { + const supertest = getService('supertestWithoutAuth'); + const ml = getService('ml'); + + const validFilters = [ + { + filterId: 'filter_1', + requestBody: { description: 'Valid filter #1', items: ['104.236.210.185'] }, + }, + { + filterId: 'filter_2', + requestBody: { description: 'Valid filter #2', items: ['104.236.210.185'] }, + }, + ]; + + describe('get_filters', function () { + before(async () => { + await ml.testResources.setKibanaTimeZoneToUTC(); + for (const filter of validFilters) { + const { filterId, requestBody } = filter; + await ml.api.createFilter(filterId, requestBody); + } + }); + + after(async () => { + for (const filter of validFilters) { + const { filterId } = filter; + await ml.api.deleteFilter(filterId); + } + }); + it(`should fetch all filters`, async () => { + const { body } = await supertest + .get(`/api/ml/filters`) + .auth(USER.ML_POWERUSER, ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER)) + .set(COMMON_REQUEST_HEADERS) + .expect(200); + + expect(body).to.have.length(validFilters.length); + }); + + it(`should not allow to retrieve filters for user without required permission`, async () => { + const { body } = await supertest + .get(`/api/ml/filters`) + .auth(USER.ML_VIEWER, ml.securityCommon.getPasswordForUser(USER.ML_VIEWER)) + .set(COMMON_REQUEST_HEADERS) + .expect(404); + expect(body.error).to.eql('Not Found'); + expect(body.message).to.eql('Not Found'); + }); + + it(`should not allow to retrieve filters for unauthorized user`, async () => { + const { body } = await supertest + .get(`/api/ml/filters`) + .auth(USER.ML_UNAUTHORIZED, ml.securityCommon.getPasswordForUser(USER.ML_UNAUTHORIZED)) + .set(COMMON_REQUEST_HEADERS) + .expect(404); + + expect(body.error).to.eql('Not Found'); + expect(body.message).to.eql('Not Found'); + }); + + it(`should fetch single filter by id`, async () => { + const { filterId, requestBody } = validFilters[0]; + const { body } = await supertest + .get(`/api/ml/filters/${filterId}`) + .auth(USER.ML_POWERUSER, ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER)) + .set(COMMON_REQUEST_HEADERS) + .expect(200); + + expect(body.filter_id).to.eql(filterId); + expect(body.description).to.eql(requestBody.description); + expect(body.items).to.eql(requestBody.items); + }); + + it(`should return 400 if filterId does not exist`, async () => { + const { body } = await supertest + .get(`/api/ml/filters/filter_id_dne`) + .auth(USER.ML_POWERUSER, ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER)) + .set(COMMON_REQUEST_HEADERS) + .expect(400); + expect(body.error).to.eql('Bad Request'); + expect(body.message).to.contain('Unable to find filter'); + }); + }); +}; diff --git a/x-pack/test/api_integration/apis/ml/filters/index.ts b/x-pack/test/api_integration/apis/ml/filters/index.ts new file mode 100644 index 000000000000..0c0bc4eab29e --- /dev/null +++ b/x-pack/test/api_integration/apis/ml/filters/index.ts @@ -0,0 +1,16 @@ +/* + * 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 { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('filters', function () { + loadTestFile(require.resolve('./create_filters')); + loadTestFile(require.resolve('./get_filters')); + loadTestFile(require.resolve('./delete_filters')); + loadTestFile(require.resolve('./update_filters')); + }); +} diff --git a/x-pack/test/api_integration/apis/ml/filters/update_filters.ts b/x-pack/test/api_integration/apis/ml/filters/update_filters.ts new file mode 100644 index 000000000000..eb58d545093c --- /dev/null +++ b/x-pack/test/api_integration/apis/ml/filters/update_filters.ts @@ -0,0 +1,118 @@ +/* + * 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 expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../../ftr_provider_context'; +import { USER } from '../../../../functional/services/ml/security_common'; +import { COMMON_REQUEST_HEADERS } from '../../../../functional/services/ml/common'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext) => { + const supertest = getService('supertestWithoutAuth'); + const ml = getService('ml'); + + const items = ['104.236.210.185']; + const validFilters = [ + { + filterId: 'filter_power', + requestBody: { description: 'Test update filter #1', items }, + }, + { + filterId: 'filter_viewer', + requestBody: { description: 'Test update filter (viewer)', items }, + }, + { + filterId: 'filter_unauthorized', + requestBody: { description: 'Test update filter (unauthorized)', items }, + }, + ]; + + describe('update_filters', function () { + const updateFilterRequestBody = { + description: 'Updated filter #1', + removeItems: items, + addItems: ['my_new_items_1', 'my_new_items_2'], + }; + before(async () => { + await ml.testResources.setKibanaTimeZoneToUTC(); + for (const filter of validFilters) { + const { filterId, requestBody } = filter; + await ml.api.createFilter(filterId, requestBody); + } + }); + + after(async () => { + for (const filter of validFilters) { + const { filterId } = filter; + await ml.api.deleteFilter(filterId); + } + }); + + it(`should update filter by id`, async () => { + const { filterId } = validFilters[0]; + const { body } = await supertest + .put(`/api/ml/filters/${filterId}`) + .auth(USER.ML_POWERUSER, ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER)) + .set(COMMON_REQUEST_HEADERS) + .send(updateFilterRequestBody) + .expect(200); + + expect(body.filter_id).to.eql(filterId); + expect(body.description).to.eql(updateFilterRequestBody.description); + expect(body.items).to.eql(updateFilterRequestBody.addItems); + }); + + it(`should not allow to update filter for user without required permission`, async () => { + const { filterId, requestBody: oldFilterRequest } = validFilters[1]; + const { body } = await supertest + .put(`/api/ml/filters/${filterId}`) + .auth(USER.ML_VIEWER, ml.securityCommon.getPasswordForUser(USER.ML_VIEWER)) + .set(COMMON_REQUEST_HEADERS) + .send(updateFilterRequestBody) + .expect(404); + + // response should return not found + expect(body.error).to.eql('Not Found'); + + // and the filter should not be updated + const response = await ml.api.getFilter(filterId); + const updatedFilter = response.body.filters[0]; + expect(updatedFilter.filter_id).to.eql(filterId); + expect(updatedFilter.description).to.eql(oldFilterRequest.description); + expect(updatedFilter.items).to.eql(oldFilterRequest.items); + }); + + it(`should not allow to update filter for unauthorized user`, async () => { + const { filterId, requestBody: oldFilterRequest } = validFilters[2]; + const { body } = await supertest + .put(`/api/ml/filters/${filterId}`) + .auth(USER.ML_UNAUTHORIZED, ml.securityCommon.getPasswordForUser(USER.ML_UNAUTHORIZED)) + .set(COMMON_REQUEST_HEADERS) + .send(updateFilterRequestBody) + .expect(404); + + expect(body.error).to.eql('Not Found'); + + const response = await ml.api.getFilter(filterId); + const updatedFilter = response.body.filters[0]; + expect(updatedFilter.filter_id).to.eql(filterId); + expect(updatedFilter.description).to.eql(oldFilterRequest.description); + expect(updatedFilter.items).to.eql(oldFilterRequest.items); + }); + + it(`should return appropriate error if invalid filterId`, async () => { + const { body } = await supertest + .put(`/api/ml/filters/filter_id_dne`) + .auth(USER.ML_POWERUSER, ml.securityCommon.getPasswordForUser(USER.ML_POWERUSER)) + .set(COMMON_REQUEST_HEADERS) + .send(updateFilterRequestBody) + .expect(400); + + expect(body.message).to.contain('No filter with id'); + }); + }); +}; diff --git a/x-pack/test/api_integration/apis/ml/index.ts b/x-pack/test/api_integration/apis/ml/index.ts index 5c2e7a6c4b2f..b29bc47b5039 100644 --- a/x-pack/test/api_integration/apis/ml/index.ts +++ b/x-pack/test/api_integration/apis/ml/index.ts @@ -58,5 +58,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./jobs')); loadTestFile(require.resolve('./results')); loadTestFile(require.resolve('./data_frame_analytics')); + loadTestFile(require.resolve('./filters')); + loadTestFile(require.resolve('./calendars')); }); } diff --git a/x-pack/test/functional/services/ml/api.ts b/x-pack/test/functional/services/ml/api.ts index a48159cd7515..9dfec3a17dec 100644 --- a/x-pack/test/functional/services/ml/api.ts +++ b/x-pack/test/functional/services/ml/api.ts @@ -5,14 +5,12 @@ */ import expect from '@kbn/expect'; import { ProvidedType } from '@kbn/test/types/ftr'; +import { Calendar, CalendarEvent } from '../../../../plugins/ml/server/models/calendar/index'; import { DataFrameAnalyticsConfig } from '../../../../plugins/ml/public/application/data_frame_analytics/common'; - import { FtrProviderContext } from '../../ftr_provider_context'; - import { DATAFEED_STATE, JOB_STATE } from '../../../../plugins/ml/common/constants/states'; import { DATA_FRAME_TASK_STATE } from '../../../../plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/common'; import { Datafeed, Job } from '../../../../plugins/ml/common/types/anomaly_detection_jobs'; - export type MlApi = ProvidedType; export function MachineLearningAPIProvider({ getService }: FtrProviderContext) { @@ -325,19 +323,102 @@ export function MachineLearningAPIProvider({ getService }: FtrProviderContext) { }); }, - async getCalendar(calendarId: string) { - return await esSupertest.get(`/_ml/calendars/${calendarId}`).expect(200); + async getCalendar(calendarId: string, expectedCode = 200) { + return await esSupertest.get(`/_ml/calendars/${calendarId}`).expect(expectedCode); }, - async createCalendar(calendarId: string, body = { description: '', job_ids: [] }) { + async createCalendar( + calendarId: string, + requestBody: Partial = { description: '', job_ids: [] } + ) { log.debug(`Creating calendar with id '${calendarId}'...`); - await esSupertest.put(`/_ml/calendars/${calendarId}`).send(body).expect(200); + await esSupertest.put(`/_ml/calendars/${calendarId}`).send(requestBody).expect(200); + await this.waitForCalendarToExist(calendarId); + }, + + async deleteCalendar(calendarId: string) { + log.debug(`Deleting calendar with id '${calendarId}'...`); + await esSupertest.delete(`/_ml/calendars/${calendarId}`); + + await this.waitForCalendarNotToExist(calendarId); + }, + + async waitForCalendarToExist(calendarId: string, errorMsg?: string) { + await retry.waitForWithTimeout(`'${calendarId}' to exist`, 5 * 1000, async () => { + if (await this.getCalendar(calendarId, 200)) { + return true; + } else { + throw new Error(errorMsg || `expected calendar '${calendarId}' to exist`); + } + }); + }, - await retry.waitForWithTimeout(`'${calendarId}' to be created`, 30 * 1000, async () => { - if (await this.getCalendar(calendarId)) { + async waitForCalendarNotToExist(calendarId: string, errorMsg?: string) { + await retry.waitForWithTimeout(`'${calendarId}' to not exist`, 5 * 1000, async () => { + if (await this.getCalendar(calendarId, 404)) { return true; } else { - throw new Error(`expected calendar '${calendarId}' to be created`); + throw new Error(errorMsg || `expected calendar '${calendarId}' to not exist`); + } + }); + }, + + async createCalendarEvents(calendarId: string, events: CalendarEvent[]) { + log.debug(`Creating events for calendar with id '${calendarId}'...`); + await esSupertest.post(`/_ml/calendars/${calendarId}/events`).send({ events }).expect(200); + await this.waitForEventsToExistInCalendar(calendarId, events); + }, + + async getCalendarEvents(calendarId: string, expectedCode = 200) { + return await esSupertest.get(`/_ml/calendars/${calendarId}/events`).expect(expectedCode); + }, + + assertAllEventsExistInCalendar: ( + eventsToCheck: CalendarEvent[], + calendar: Calendar + ): boolean => { + const updatedCalendarEvents = calendar.events as CalendarEvent[]; + let allEventsAreUpdated = true; + for (const eventToCheck of eventsToCheck) { + // if at least one of the events that we need to check is not in the updated events + // no need to continue + if ( + updatedCalendarEvents.findIndex( + (updatedEvent) => + updatedEvent.description === eventToCheck.description && + updatedEvent.start_time === eventToCheck.start_time && + updatedEvent.end_time === eventToCheck.end_time + ) < 0 + ) { + allEventsAreUpdated = false; + break; + } + } + expect(allEventsAreUpdated).to.eql( + true, + `Expected calendar ${calendar.calendar_id} to contain events ${JSON.stringify( + eventsToCheck + )}` + ); + return true; + }, + + async waitForEventsToExistInCalendar( + calendarId: string, + eventsToCheck: CalendarEvent[], + errorMsg?: string + ) { + await retry.waitForWithTimeout(`'${calendarId}' events to exist`, 5 * 1000, async () => { + // validate if calendar events have been updated with the requested events + const { body } = await this.getCalendarEvents(calendarId, 200); + + if (this.assertAllEventsExistInCalendar(eventsToCheck, body)) { + return true; + } else { + throw new Error( + errorMsg || + `expected events for calendar '${calendarId}' to have been updated correctly` + ); } }); }, @@ -515,5 +596,43 @@ export function MachineLearningAPIProvider({ getService }: FtrProviderContext) { } ); }, + + async getFilter(filterId: string, expectedCode = 200) { + return await esSupertest.get(`/_ml/filters/${filterId}`).expect(expectedCode); + }, + + async createFilter(filterId: string, requestBody: object) { + log.debug(`Creating filter with id '${filterId}'...`); + await esSupertest.put(`/_ml/filters/${filterId}`).send(requestBody).expect(200); + + await this.waitForFilterToExist(filterId, `expected filter '${filterId}' to be created`); + }, + + async deleteFilter(filterId: string) { + log.debug(`Deleting filter with id '${filterId}'...`); + await esSupertest.delete(`/_ml/filters/${filterId}`); + + await this.waitForFilterToNotExist(filterId, `expected filter '${filterId}' to be deleted`); + }, + + async waitForFilterToExist(filterId: string, errorMsg?: string) { + await retry.waitForWithTimeout(`'${filterId}' to exist`, 5 * 1000, async () => { + if (await this.getFilter(filterId, 200)) { + return true; + } else { + throw new Error(errorMsg || `expected filter '${filterId}' to exist`); + } + }); + }, + + async waitForFilterToNotExist(filterId: string, errorMsg?: string) { + await retry.waitForWithTimeout(`'${filterId}' to not exist`, 5 * 1000, async () => { + if (await this.getFilter(filterId, 404)) { + return true; + } else { + throw new Error(errorMsg || `expected filter '${filterId}' to not exist`); + } + }); + }, }; } From 2932b169a27b45eadfc03aaa9bd5a57cab2d3750 Mon Sep 17 00:00:00 2001 From: Sandra Gonzales Date: Thu, 23 Jul 2020 11:45:38 -0500 Subject: [PATCH 103/142] [Ingest Manager] Integration test install/uninstall a package (#72957) * integration test for initial installation of package * add all services to integration config * rename files, test removing package * remove import from merge conflict * rename es_assets to all_assets package * move install package to before clause and update test descriptions * fix typo * update ilm policy name * use skipIfNoDockerRegistry helper --- .../epm/{install.ts => install_overrides.ts} | 0 .../apis/epm/install_remove_assets.ts | 197 ++++++++++++++++++ .../apis/epm/list.ts | 2 +- .../elasticsearch/ilm_policy/all_assets.json | 15 ++ .../elasticsearch/ingest_pipeline/default.yml | 7 + .../0.1.0/dataset/test_logs/fields/fields.yml | 16 ++ .../0.1.0/dataset/test_logs/manifest.yml | 9 + .../dataset/test_metrics/fields/fields.yml | 16 ++ .../0.1.0/dataset/test_metrics/manifest.yml | 3 + .../all_assets/0.1.0/docs/README.md | 3 + .../0.1.0/img/logo_overrides_64_color.svg | 7 + .../kibana/dashboard/sample_dashboard.json | 16 ++ .../kibana/dashboard/sample_dashboard2.json | 16 ++ .../0.1.0/kibana/search/sample_search.json | 24 +++ .../visualization/sample_visualization.json | 11 + .../all_assets/0.1.0/manifest.yml | 20 ++ .../apis/index.js | 3 +- .../ingest_manager_api_integration/config.ts | 5 +- .../ingest_manager_api_integration/helpers.ts | 2 +- 19 files changed, 365 insertions(+), 7 deletions(-) rename x-pack/test/ingest_manager_api_integration/apis/epm/{install.ts => install_overrides.ts} (100%) create mode 100644 x-pack/test/ingest_manager_api_integration/apis/epm/install_remove_assets.ts create mode 100644 x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/dataset/test_logs/elasticsearch/ilm_policy/all_assets.json create mode 100644 x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/dataset/test_logs/elasticsearch/ingest_pipeline/default.yml create mode 100644 x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/dataset/test_logs/fields/fields.yml create mode 100644 x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/dataset/test_logs/manifest.yml create mode 100644 x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/dataset/test_metrics/fields/fields.yml create mode 100644 x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/dataset/test_metrics/manifest.yml create mode 100644 x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/docs/README.md create mode 100644 x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/img/logo_overrides_64_color.svg create mode 100644 x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/dashboard/sample_dashboard.json create mode 100644 x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/dashboard/sample_dashboard2.json create mode 100644 x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/search/sample_search.json create mode 100644 x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/visualization/sample_visualization.json create mode 100644 x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/manifest.yml diff --git a/x-pack/test/ingest_manager_api_integration/apis/epm/install.ts b/x-pack/test/ingest_manager_api_integration/apis/epm/install_overrides.ts similarity index 100% rename from x-pack/test/ingest_manager_api_integration/apis/epm/install.ts rename to x-pack/test/ingest_manager_api_integration/apis/epm/install_overrides.ts diff --git a/x-pack/test/ingest_manager_api_integration/apis/epm/install_remove_assets.ts b/x-pack/test/ingest_manager_api_integration/apis/epm/install_remove_assets.ts new file mode 100644 index 000000000000..9ca8ebf13607 --- /dev/null +++ b/x-pack/test/ingest_manager_api_integration/apis/epm/install_remove_assets.ts @@ -0,0 +1,197 @@ +/* + * 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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../api_integration/ftr_provider_context'; +import { skipIfNoDockerRegistry } from '../../helpers'; + +export default function (providerContext: FtrProviderContext) { + const { getService } = providerContext; + const kibanaServer = getService('kibanaServer'); + const supertest = getService('supertest'); + const es = getService('es'); + const pkgName = 'all_assets'; + const pkgVersion = '0.1.0'; + const pkgKey = `${pkgName}-${pkgVersion}`; + const logsTemplateName = `logs-${pkgName}.test_logs`; + const metricsTemplateName = `metrics-${pkgName}.test_metrics`; + + const uninstallPackage = async (pkg: string) => { + await supertest.delete(`/api/ingest_manager/epm/packages/${pkg}`).set('kbn-xsrf', 'xxxx'); + }; + const installPackage = async (pkg: string) => { + await supertest.post(`/api/ingest_manager/epm/packages/${pkg}`).set('kbn-xsrf', 'xxxx'); + }; + + describe('installs and uninstalls all assets', async () => { + describe('installs all assets when installing a package for the first time', async () => { + skipIfNoDockerRegistry(providerContext); + before(async () => { + await installPackage(pkgKey); + }); + it('should have installed the ILM policy', async function () { + const resPolicy = await es.transport.request({ + method: 'GET', + path: `/_ilm/policy/all_assets`, + }); + expect(resPolicy.statusCode).equal(200); + }); + it('should have installed the index templates', async function () { + const resLogsTemplate = await es.transport.request({ + method: 'GET', + path: `/_index_template/${logsTemplateName}`, + }); + expect(resLogsTemplate.statusCode).equal(200); + + const resMetricsTemplate = await es.transport.request({ + method: 'GET', + path: `/_index_template/${metricsTemplateName}`, + }); + expect(resMetricsTemplate.statusCode).equal(200); + }); + it('should have installed the pipelines', async function () { + const res = await es.transport.request({ + method: 'GET', + path: `/_ingest/pipeline/${logsTemplateName}-${pkgVersion}`, + }); + expect(res.statusCode).equal(200); + }); + it('should have installed the template components', async function () { + const res = await es.transport.request({ + method: 'GET', + path: `/_component_template/${logsTemplateName}-mappings`, + }); + expect(res.statusCode).equal(200); + const resSettings = await es.transport.request({ + method: 'GET', + path: `/_component_template/${logsTemplateName}-settings`, + }); + expect(resSettings.statusCode).equal(200); + }); + it('should have installed the kibana assets', async function () { + const resIndexPatternLogs = await kibanaServer.savedObjects.get({ + type: 'index-pattern', + id: 'logs-*', + }); + expect(resIndexPatternLogs.id).equal('logs-*'); + const resIndexPatternMetrics = await kibanaServer.savedObjects.get({ + type: 'index-pattern', + id: 'metrics-*', + }); + expect(resIndexPatternMetrics.id).equal('metrics-*'); + const resIndexPatternEvents = await kibanaServer.savedObjects.get({ + type: 'index-pattern', + id: 'events-*', + }); + expect(resIndexPatternEvents.id).equal('events-*'); + const resDashboard = await kibanaServer.savedObjects.get({ + type: 'dashboard', + id: 'sample_dashboard', + }); + expect(resDashboard.id).equal('sample_dashboard'); + const resDashboard2 = await kibanaServer.savedObjects.get({ + type: 'dashboard', + id: 'sample_dashboard2', + }); + expect(resDashboard2.id).equal('sample_dashboard2'); + const resVis = await kibanaServer.savedObjects.get({ + type: 'visualization', + id: 'sample_visualization', + }); + expect(resVis.id).equal('sample_visualization'); + const resSearch = await kibanaServer.savedObjects.get({ + type: 'search', + id: 'sample_search', + }); + expect(resSearch.id).equal('sample_search'); + }); + }); + + describe('uninstalls all assets when uninstalling a package', async () => { + skipIfNoDockerRegistry(providerContext); + before(async () => { + await uninstallPackage(pkgKey); + }); + it('should have uninstalled the index templates', async function () { + const resLogsTemplate = await es.transport.request( + { + method: 'GET', + path: `/_index_template/${logsTemplateName}`, + }, + { + ignore: [404], + } + ); + expect(resLogsTemplate.statusCode).equal(404); + + const resMetricsTemplate = await es.transport.request( + { + method: 'GET', + path: `/_index_template/${metricsTemplateName}`, + }, + { + ignore: [404], + } + ); + expect(resMetricsTemplate.statusCode).equal(404); + }); + it('should have uninstalled the pipelines', async function () { + const res = await es.transport.request( + { + method: 'GET', + path: `/_ingest/pipeline/${logsTemplateName}-${pkgVersion}`, + }, + { + ignore: [404], + } + ); + expect(res.statusCode).equal(404); + }); + it('should have uninstalled the kibana assets', async function () { + let resDashboard; + try { + resDashboard = await kibanaServer.savedObjects.get({ + type: 'dashboard', + id: 'sample_dashboard', + }); + } catch (err) { + resDashboard = err; + } + expect(resDashboard.response.data.statusCode).equal(404); + let resDashboard2; + try { + resDashboard2 = await kibanaServer.savedObjects.get({ + type: 'dashboard', + id: 'sample_dashboard2', + }); + } catch (err) { + resDashboard2 = err; + } + expect(resDashboard2.response.data.statusCode).equal(404); + let resVis; + try { + resVis = await kibanaServer.savedObjects.get({ + type: 'visualization', + id: 'sample_visualization', + }); + } catch (err) { + resVis = err; + } + expect(resVis.response.data.statusCode).equal(404); + let resSearch; + try { + resVis = await kibanaServer.savedObjects.get({ + type: 'search', + id: 'sample_search', + }); + } catch (err) { + resSearch = err; + } + expect(resSearch.response.data.statusCode).equal(404); + }); + }); + }); +} diff --git a/x-pack/test/ingest_manager_api_integration/apis/epm/list.ts b/x-pack/test/ingest_manager_api_integration/apis/epm/list.ts index 74aaf48d1567..2fbda8f2d3c8 100644 --- a/x-pack/test/ingest_manager_api_integration/apis/epm/list.ts +++ b/x-pack/test/ingest_manager_api_integration/apis/epm/list.ts @@ -29,7 +29,7 @@ export default function ({ getService }: FtrProviderContext) { return response.body; }; const listResponse = await fetchPackageList(); - expect(listResponse.response.length).to.be(5); + expect(listResponse.response.length).to.be(6); } else { warnAndSkipTest(this, log); } diff --git a/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/dataset/test_logs/elasticsearch/ilm_policy/all_assets.json b/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/dataset/test_logs/elasticsearch/ilm_policy/all_assets.json new file mode 100644 index 000000000000..7cf62e890f86 --- /dev/null +++ b/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/dataset/test_logs/elasticsearch/ilm_policy/all_assets.json @@ -0,0 +1,15 @@ +{ + "policy": { + "phases": { + "hot": { + "min_age": "0ms", + "actions": { + "rollover": { + "max_size": "50gb", + "max_age": "30d" + } + } + } + } + } +} \ No newline at end of file diff --git a/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/dataset/test_logs/elasticsearch/ingest_pipeline/default.yml b/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/dataset/test_logs/elasticsearch/ingest_pipeline/default.yml new file mode 100644 index 000000000000..580db049d0d5 --- /dev/null +++ b/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/dataset/test_logs/elasticsearch/ingest_pipeline/default.yml @@ -0,0 +1,7 @@ +--- +description: Pipeline for parsing test logs + plugins. +processors: +- set: + field: error.message + value: '{{ _ingest.on_failure_message }}' \ No newline at end of file diff --git a/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/dataset/test_logs/fields/fields.yml b/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/dataset/test_logs/fields/fields.yml new file mode 100644 index 000000000000..12a9a03c1337 --- /dev/null +++ b/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/dataset/test_logs/fields/fields.yml @@ -0,0 +1,16 @@ +- name: dataset.type + type: constant_keyword + description: > + Dataset type. +- name: dataset.name + type: constant_keyword + description: > + Dataset name. +- name: dataset.namespace + type: constant_keyword + description: > + Dataset namespace. +- name: '@timestamp' + type: date + description: > + Event timestamp. diff --git a/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/dataset/test_logs/manifest.yml b/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/dataset/test_logs/manifest.yml new file mode 100644 index 000000000000..8cd522e2845b --- /dev/null +++ b/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/dataset/test_logs/manifest.yml @@ -0,0 +1,9 @@ +title: Test Dataset + +type: logs + +elasticsearch: + index_template.mappings: + dynamic: false + index_template.settings: + index.lifecycle.name: reference \ No newline at end of file diff --git a/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/dataset/test_metrics/fields/fields.yml b/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/dataset/test_metrics/fields/fields.yml new file mode 100644 index 000000000000..12a9a03c1337 --- /dev/null +++ b/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/dataset/test_metrics/fields/fields.yml @@ -0,0 +1,16 @@ +- name: dataset.type + type: constant_keyword + description: > + Dataset type. +- name: dataset.name + type: constant_keyword + description: > + Dataset name. +- name: dataset.namespace + type: constant_keyword + description: > + Dataset namespace. +- name: '@timestamp' + type: date + description: > + Event timestamp. diff --git a/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/dataset/test_metrics/manifest.yml b/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/dataset/test_metrics/manifest.yml new file mode 100644 index 000000000000..6bc20442bd43 --- /dev/null +++ b/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/dataset/test_metrics/manifest.yml @@ -0,0 +1,3 @@ +title: Test Dataset + +type: metrics \ No newline at end of file diff --git a/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/docs/README.md b/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/docs/README.md new file mode 100644 index 000000000000..2617f1fcabe1 --- /dev/null +++ b/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/docs/README.md @@ -0,0 +1,3 @@ +# Test package + +For testing that a package installs its elasticsearch assets when installed for the first time (not updating) and removing the package diff --git a/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/img/logo_overrides_64_color.svg b/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/img/logo_overrides_64_color.svg new file mode 100644 index 000000000000..b03007a76ffc --- /dev/null +++ b/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/img/logo_overrides_64_color.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/dashboard/sample_dashboard.json b/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/dashboard/sample_dashboard.json new file mode 100644 index 000000000000..ef08d6932421 --- /dev/null +++ b/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/dashboard/sample_dashboard.json @@ -0,0 +1,16 @@ +{ + "attributes": { + "description": "Sample dashboard", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"filter\":[],\"highlightAll\":true,\"query\":{\"language\":\"kuery\",\"query\":\"\"},\"version\":true}" + }, + "optionsJSON": "{\"darkTheme\":false}", + "panelsJSON": "[{\"embeddableConfig\":{},\"gridData\":{\"h\":12,\"i\":\"1\",\"w\":24,\"x\":0,\"y\":0},\"panelIndex\":\"1\",\"panelRefName\":\"panel_0\",\"version\":\"7.3.0\"},{\"embeddableConfig\":{\"columns\":[\"kafka.log.class\",\"kafka.log.trace.class\",\"kafka.log.trace.full\"],\"sort\":[\"@timestamp\",\"desc\"]},\"gridData\":{\"h\":12,\"i\":\"2\",\"w\":24,\"x\":24,\"y\":0},\"panelIndex\":\"2\",\"panelRefName\":\"panel_1\",\"version\":\"7.3.0\"},{\"embeddableConfig\":{\"columns\":[\"log.level\",\"kafka.log.component\",\"message\"],\"sort\":[\"@timestamp\",\"desc\"]},\"gridData\":{\"h\":20,\"i\":\"3\",\"w\":48,\"x\":0,\"y\":20},\"panelIndex\":\"3\",\"panelRefName\":\"panel_2\",\"version\":\"7.3.0\"},{\"embeddableConfig\":{},\"gridData\":{\"h\":8,\"i\":\"4\",\"w\":48,\"x\":0,\"y\":12},\"panelIndex\":\"4\",\"panelRefName\":\"panel_3\",\"version\":\"7.3.0\"}]", + "timeRestore": false, + "title": "[Logs Sample] Overview ECS", + "version": 1 + }, + "id": "sample_dashboard", + "type": "dashboard" +} \ No newline at end of file diff --git a/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/dashboard/sample_dashboard2.json b/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/dashboard/sample_dashboard2.json new file mode 100644 index 000000000000..7ea63c5d444b --- /dev/null +++ b/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/dashboard/sample_dashboard2.json @@ -0,0 +1,16 @@ +{ + "attributes": { + "description": "Sample dashboard 2", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"filter\":[],\"highlightAll\":true,\"query\":{\"language\":\"kuery\",\"query\":\"\"},\"version\":true}" + }, + "optionsJSON": "{\"darkTheme\":false}", + "panelsJSON": "[{\"embeddableConfig\":{},\"gridData\":{\"h\":12,\"i\":\"1\",\"w\":24,\"x\":0,\"y\":0},\"panelIndex\":\"1\",\"panelRefName\":\"panel_0\",\"version\":\"7.3.0\"},{\"embeddableConfig\":{\"columns\":[\"kafka.log.class\",\"kafka.log.trace.class\",\"kafka.log.trace.full\"],\"sort\":[\"@timestamp\",\"desc\"]},\"gridData\":{\"h\":12,\"i\":\"2\",\"w\":24,\"x\":24,\"y\":0},\"panelIndex\":\"2\",\"panelRefName\":\"panel_1\",\"version\":\"7.3.0\"},{\"embeddableConfig\":{\"columns\":[\"log.level\",\"kafka.log.component\",\"message\"],\"sort\":[\"@timestamp\",\"desc\"]},\"gridData\":{\"h\":20,\"i\":\"3\",\"w\":48,\"x\":0,\"y\":20},\"panelIndex\":\"3\",\"panelRefName\":\"panel_2\",\"version\":\"7.3.0\"},{\"embeddableConfig\":{},\"gridData\":{\"h\":8,\"i\":\"4\",\"w\":48,\"x\":0,\"y\":12},\"panelIndex\":\"4\",\"panelRefName\":\"panel_3\",\"version\":\"7.3.0\"}]", + "timeRestore": false, + "title": "[Logs Sample2] Overview ECS", + "version": 1 + }, + "id": "sample_dashboard2", + "type": "dashboard" +} \ No newline at end of file diff --git a/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/search/sample_search.json b/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/search/sample_search.json new file mode 100644 index 000000000000..28185affabef --- /dev/null +++ b/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/search/sample_search.json @@ -0,0 +1,24 @@ +{ + "attributes": { + "columns": [ + "log.level", + "kafka.log.component", + "message" + ], + "description": "", + "hits": 0, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"filter\":[{\"$state\":{\"store\":\"appState\"},\"meta\":{\"alias\":null,\"disabled\":false,\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.filter[0].meta.index\",\"key\":\"dataset.name\",\"negate\":false,\"params\":{\"query\":\"kafka.log\",\"type\":\"phrase\"},\"type\":\"phrase\",\"value\":\"log\"},\"query\":{\"match\":{\"dataset.name\":{\"query\":\"kafka.log\",\"type\":\"phrase\"}}}}],\"highlightAll\":true,\"indexRefName\":\"kibanaSavedObjectMeta.searchSourceJSON.index\",\"query\":{\"language\":\"kuery\",\"query\":\"\"},\"version\":true}" + }, + "sort": [ + [ + "@timestamp", + "desc" + ] + ], + "title": "All logs [Logs Kafka] ECS", + "version": 1 + }, + "id": "sample_search", + "type": "search" +} \ No newline at end of file diff --git a/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/visualization/sample_visualization.json b/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/visualization/sample_visualization.json new file mode 100644 index 000000000000..e814b83bbf32 --- /dev/null +++ b/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/visualization/sample_visualization.json @@ -0,0 +1,11 @@ +{ + "attributes": { + "description": "sample visualization", + "title": "sample vis title", + "uiStateJSON": "{}", + "version": 1, + "visState": "{\"aggs\":[{\"enabled\":true,\"id\":\"1\",\"params\":{},\"schema\":\"metric\",\"type\":\"count\"},{\"enabled\":true,\"id\":\"2\",\"params\":{\"extended_bounds\":{},\"field\":\"@timestamp\",\"interval\":\"auto\",\"min_doc_count\":1},\"schema\":\"segment\",\"type\":\"date_histogram\"},{\"enabled\":true,\"id\":\"3\",\"params\":{\"customLabel\":\"Log Level\",\"field\":\"log.level\",\"order\":\"desc\",\"orderBy\":\"1\",\"size\":5},\"schema\":\"group\",\"type\":\"terms\"}],\"params\":{\"addLegend\":true,\"addTimeMarker\":false,\"addTooltip\":true,\"categoryAxes\":[{\"id\":\"CategoryAxis-1\",\"labels\":{\"show\":true,\"truncate\":100},\"position\":\"bottom\",\"scale\":{\"type\":\"linear\"},\"show\":true,\"style\":{},\"title\":{\"text\":\"@timestamp per day\"},\"type\":\"category\"}],\"grid\":{\"categoryLines\":false,\"style\":{\"color\":\"#eee\"}},\"legendPosition\":\"right\",\"seriesParams\":[{\"data\":{\"id\":\"1\",\"label\":\"Count\"},\"drawLinesBetweenPoints\":true,\"mode\":\"stacked\",\"show\":\"true\",\"showCircles\":true,\"type\":\"histogram\",\"valueAxis\":\"ValueAxis-1\"}],\"times\":[],\"type\":\"histogram\",\"valueAxes\":[{\"id\":\"ValueAxis-1\",\"labels\":{\"filter\":false,\"rotate\":0,\"show\":true,\"truncate\":100},\"name\":\"LeftAxis-1\",\"position\":\"left\",\"scale\":{\"mode\":\"normal\",\"type\":\"linear\"},\"show\":true,\"style\":{},\"title\":{\"text\":\"Count\"},\"type\":\"value\"}]},\"title\":\"Log levels over time [Logs Kafka] ECS\",\"type\":\"histogram\"}" + }, + "id": "sample_visualization", + "type": "visualization" +} \ No newline at end of file diff --git a/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/manifest.yml b/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/manifest.yml new file mode 100644 index 000000000000..3c11b5103fbe --- /dev/null +++ b/x-pack/test/ingest_manager_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/manifest.yml @@ -0,0 +1,20 @@ +format_version: 1.0.0 +name: all_assets +title: All Assets Installed/Uninstalled Test +description: This is a test package for testing that all assets were installed when installing a package for the first time and removing the assets during package uninstall +version: 0.1.0 +categories: [] +release: beta +type: integration +license: basic + +requirement: + elasticsearch: + versions: '>7.7.0' + kibana: + versions: '>7.7.0' + +icons: + - src: '/img/logo_overrides_64_color.svg' + size: '16x16' + type: 'image/svg+xml' diff --git a/x-pack/test/ingest_manager_api_integration/apis/index.js b/x-pack/test/ingest_manager_api_integration/apis/index.js index c0c8ce3ff082..1045ff5d82d1 100644 --- a/x-pack/test/ingest_manager_api_integration/apis/index.js +++ b/x-pack/test/ingest_manager_api_integration/apis/index.js @@ -16,7 +16,8 @@ export default function ({ loadTestFile }) { loadTestFile(require.resolve('./epm/file')); //loadTestFile(require.resolve('./epm/template')); loadTestFile(require.resolve('./epm/ilm')); - loadTestFile(require.resolve('./epm/install')); + loadTestFile(require.resolve('./epm/install_overrides')); + loadTestFile(require.resolve('./epm/install_remove_assets')); // Package configs loadTestFile(require.resolve('./package_config/create')); diff --git a/x-pack/test/ingest_manager_api_integration/config.ts b/x-pack/test/ingest_manager_api_integration/config.ts index 6f5d8eed4351..2aa2e62a4b9e 100644 --- a/x-pack/test/ingest_manager_api_integration/config.ts +++ b/x-pack/test/ingest_manager_api_integration/config.ts @@ -8,7 +8,6 @@ import path from 'path'; import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; import { defineDockerServersConfig } from '@kbn/test'; -import { services } from '../api_integration/services'; export default async function ({ readConfigFile }: FtrConfigProviderContext) { const xPackAPITestsConfig = await readConfigFile(require.resolve('../api_integration/config.ts')); @@ -49,9 +48,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { }), esArchiver: xPackAPITestsConfig.get('esArchiver'), services: { - ...services, - supertest: xPackAPITestsConfig.get('services.supertest'), - es: xPackAPITestsConfig.get('services.es'), + ...xPackAPITestsConfig.get('services'), }, junit: { reportName: 'X-Pack EPM API Integration Tests', diff --git a/x-pack/test/ingest_manager_api_integration/helpers.ts b/x-pack/test/ingest_manager_api_integration/helpers.ts index b1755e30f61f..a5ffc4e7adc2 100644 --- a/x-pack/test/ingest_manager_api_integration/helpers.ts +++ b/x-pack/test/ingest_manager_api_integration/helpers.ts @@ -22,7 +22,7 @@ export function skipIfNoDockerRegistry(providerContext: FtrProviderContext) { const server = dockerServers.get('registry'); const log = getService('log'); - beforeEach(function beforeSetupWithDockerRegistyry() { + beforeEach(function beforeSetupWithDockerRegistry() { if (!server.enabled) { warnAndSkipTest(this, log); } From 52f3cc311d0a73792e2b38a21ea894f60aaa867e Mon Sep 17 00:00:00 2001 From: spalger Date: Thu, 23 Jul 2020 09:51:40 -0700 Subject: [PATCH 104/142] fix skip of flaky test (#72994) --- x-pack/test/accessibility/apps/uptime.ts | 3 ++- x-pack/test/functional/apps/uptime/settings.ts | 3 +-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/x-pack/test/accessibility/apps/uptime.ts b/x-pack/test/accessibility/apps/uptime.ts index ebd120fa0fee..e6ef1cfe8cfe 100644 --- a/x-pack/test/accessibility/apps/uptime.ts +++ b/x-pack/test/accessibility/apps/uptime.ts @@ -17,7 +17,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const es = getService('es'); - describe('uptime', () => { + // FLAKY: https://github.com/elastic/kibana/issues/72994 + describe.skip('uptime', () => { before(async () => { await esArchiver.load('uptime/blank'); await makeChecks(es, A11Y_TEST_MONITOR_ID, 150, 1, 1000, { diff --git a/x-pack/test/functional/apps/uptime/settings.ts b/x-pack/test/functional/apps/uptime/settings.ts index a258cccffbd8..744b9120028d 100644 --- a/x-pack/test/functional/apps/uptime/settings.ts +++ b/x-pack/test/functional/apps/uptime/settings.ts @@ -16,8 +16,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const es = getService('es'); - // Flaky https://github.com/elastic/kibana/issues/72994 - describe.skip('uptime settings page', () => { + describe('uptime settings page', () => { beforeEach('navigate to clean app root', async () => { // make 10 checks await makeChecks(es, 'myMonitor', 1, 1, 1); From 7d51b978068fa04317cc96ae1ea07dc9281f9cf3 Mon Sep 17 00:00:00 2001 From: Ryland Herrick Date: Thu, 23 Jul 2020 12:01:18 -0500 Subject: [PATCH 105/142] [Security Solution][Detections] Fix display of exceptions after creation on Rule Details (#72951) * Refresh rule details when exception list modal modifies the rule This addresses a bug where, when opening the exceptions modal for the first time and creating exceptions, the details page does not reflect these created exceptions until a full refresh. This is due to the hook performing the refresh being dependent on the rule's exceptions_list attribute, which is not populated until after opening the modal. Because the UI is not informed of the rule update, it did not know to refresh the rule. This adds the machinery necessary to make the above work. It: * adds a new hook for fetching/refreshing a rule * Adds an onRuleChange callback to both the ExceptionsViewer and the mutating AddExceptionModal * passes the refresh function in as the onRuleChange callback There's currently a gross intermediate state here where the loading screen is displayed while the rule refreshes in the background; I'll be fixing that shortly. * Do not show loading/blank state while refreshing rule On Rule Details, when the Add Exceptions modal creates the rule's exception list, we refresh quietly in the background by setting our rule from null -> ruleA -> ruleB instead of null -> ruleA -> null -> ruleB. This also simplifies the loading logic in a few places now that we're using our new rule: we mainly care whether or not our rule is populated. * Display toast error if rule fetch fails This should now have feature parity with useRule, while additionally providing a function to refresh the rule. * Refactor tests to leverage existing helpers * Add return type to our callback function Co-authored-by: Elastic Machine --- x-pack/plugins/lists/public/shared_exports.ts | 2 + .../exceptions/add_exception_modal/index.tsx | 11 +++++ ...tch_or_create_rule_exception_list.test.tsx | 29 +++++++++++ ...se_fetch_or_create_rule_exception_list.tsx | 7 ++- .../components/exceptions/viewer/index.tsx | 3 ++ .../containers/detection_engine/rules/api.ts | 19 +++++++- .../detection_engine/rules/use_rule_async.tsx | 48 +++++++++++++++++++ .../detection_engine/rules/details/index.tsx | 23 ++++++--- .../public/shared_imports.ts | 2 + 9 files changed, 136 insertions(+), 8 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_async.tsx diff --git a/x-pack/plugins/lists/public/shared_exports.ts b/x-pack/plugins/lists/public/shared_exports.ts index 56341035f839..16026a436f15 100644 --- a/x-pack/plugins/lists/public/shared_exports.ts +++ b/x-pack/plugins/lists/public/shared_exports.ts @@ -5,7 +5,9 @@ */ // Exports to be shared with plugins +export { withOptionalSignal } from './common/with_optional_signal'; export { useIsMounted } from './common/hooks/use_is_mounted'; +export { useAsync } from './common/hooks/use_async'; export { useApi } from './exceptions/hooks/use_api'; export { usePersistExceptionItem } from './exceptions/hooks/persist_exception_item'; export { usePersistExceptionList } from './exceptions/hooks/persist_exception_list'; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx index 2abbaee5187a..0d93a1ea8871 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx @@ -61,6 +61,7 @@ export interface AddExceptionModalBaseProps { export interface AddExceptionModalProps extends AddExceptionModalBaseProps { onCancel: () => void; onConfirm: (didCloseAlert: boolean) => void; + onRuleChange?: () => void; alertStatus?: Status; } @@ -99,6 +100,7 @@ export const AddExceptionModal = memo(function AddExceptionModal({ alertData, onCancel, onConfirm, + onRuleChange, alertStatus, }: AddExceptionModalProps) { const { http } = useKibana().services; @@ -152,6 +154,14 @@ export const AddExceptionModal = memo(function AddExceptionModal({ [setExceptionItemsToAdd] ); + const handleRuleChange = useCallback( + (ruleChanged: boolean): void => { + if (ruleChanged && onRuleChange) { + onRuleChange(); + } + }, + [onRuleChange] + ); const onFetchOrCreateExceptionListError = useCallback( (error: Error) => { setFetchOrCreateListError(true); @@ -163,6 +173,7 @@ export const AddExceptionModal = memo(function AddExceptionModal({ ruleId, exceptionListType, onError: onFetchOrCreateExceptionListError, + onSuccess: handleRuleChange, }); const initialExceptionItems = useMemo(() => { diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/use_fetch_or_create_rule_exception_list.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/use_fetch_or_create_rule_exception_list.test.tsx index 7bef771d367f..6dbf5922e0a9 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/use_fetch_or_create_rule_exception_list.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/use_fetch_or_create_rule_exception_list.test.tsx @@ -38,6 +38,7 @@ describe('useFetchOrCreateRuleExceptionList', () => { ReturnUseFetchOrCreateRuleExceptionList >; const onError = jest.fn(); + const onSuccess = jest.fn(); const error = new Error('Something went wrong'); const ruleId = 'myRuleId'; const abortCtrl = new AbortController(); @@ -94,6 +95,7 @@ describe('useFetchOrCreateRuleExceptionList', () => { ruleId, exceptionListType: listType, onError, + onSuccess, }) ); }); @@ -168,6 +170,15 @@ describe('useFetchOrCreateRuleExceptionList', () => { expect(patchRule).toHaveBeenCalledTimes(1); }); }); + it('invokes onSuccess indicating that the rule changed', async () => { + await act(async () => { + const { waitForNextUpdate } = render(); + await waitForNextUpdate(); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(onSuccess).toHaveBeenCalledWith(true); + }); + }); }); describe("when the rule has exception list references and 'detection' is passed in", () => { @@ -207,6 +218,15 @@ describe('useFetchOrCreateRuleExceptionList', () => { expect(result.current[1]).toEqual(detectionExceptionList); }); }); + it('invokes onSuccess indicating that the rule did not change', async () => { + await act(async () => { + const { waitForNextUpdate } = render(); + await waitForNextUpdate(); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(onSuccess).toHaveBeenCalledWith(false); + }); + }); describe("but the rule does not have a reference to 'detection' type exception list", () => { beforeEach(() => { @@ -362,5 +382,14 @@ describe('useFetchOrCreateRuleExceptionList', () => { expect(onError).toHaveBeenCalledWith(error); }); }); + + it('does not call onSuccess', async () => { + await act(async () => { + const { waitForNextUpdate } = render(); + await waitForNextUpdate(); + await waitForNextUpdate(); + expect(onSuccess).not.toHaveBeenCalled(); + }); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/use_fetch_or_create_rule_exception_list.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/use_fetch_or_create_rule_exception_list.tsx index b238e25f6de5..2a5ef7b21b51 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/use_fetch_or_create_rule_exception_list.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/use_fetch_or_create_rule_exception_list.tsx @@ -31,6 +31,7 @@ export interface UseFetchOrCreateRuleExceptionListProps { ruleId: Rule['id']; exceptionListType: ExceptionListSchema['type']; onError: (arg: Error) => void; + onSuccess?: (ruleWasChanged: boolean) => void; } /** @@ -47,6 +48,7 @@ export const useFetchOrCreateRuleExceptionList = ({ ruleId, exceptionListType, onError, + onSuccess, }: UseFetchOrCreateRuleExceptionListProps): ReturnUseFetchOrCreateRuleExceptionList => { const [isLoading, setIsLoading] = useState(false); const [exceptionList, setExceptionList] = useState(null); @@ -168,6 +170,9 @@ export const useFetchOrCreateRuleExceptionList = ({ if (isSubscribed) { setExceptionList(exceptionListToUse); setIsLoading(false); + if (onSuccess) { + onSuccess(matchingList == null); + } } } catch (error) { if (isSubscribed) { @@ -183,7 +188,7 @@ export const useFetchOrCreateRuleExceptionList = ({ isSubscribed = false; abortCtrl.abort(); }; - }, [http, ruleId, exceptionListType, onError]); + }, [http, ruleId, exceptionListType, onError, onSuccess]); return [isLoading, exceptionList]; }; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.tsx index 9cc73d449114..34dc47b9cd41 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.tsx @@ -57,6 +57,7 @@ interface ExceptionsViewerProps { exceptionListsMeta: ExceptionIdentifiers[]; availableListTypes: ExceptionListTypeEnum[]; commentsAccordionId: string; + onRuleChange?: () => void; } const ExceptionsViewerComponent = ({ @@ -66,6 +67,7 @@ const ExceptionsViewerComponent = ({ exceptionListsMeta, availableListTypes, commentsAccordionId, + onRuleChange, }: ExceptionsViewerProps): JSX.Element => { const { services } = useKibana(); const [, dispatchToaster] = useStateToaster(); @@ -275,6 +277,7 @@ const ExceptionsViewerComponent = ({ exceptionListType={exceptionListTypeToEdit} onCancel={handleOnCancelExceptionModal} onConfirm={handleOnConfirmExceptionModal} + onRuleChange={onRuleChange} /> )} diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.ts index 66be5397c72c..08d564230b85 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { HttpStart } from '../../../../../../../../src/core/public'; import { DETECTION_ENGINE_RULES_URL, DETECTION_ENGINE_PREPACKAGED_URL, @@ -126,7 +127,23 @@ export const fetchRules = async ({ * @throws An error if response is not OK */ export const fetchRuleById = async ({ id, signal }: FetchRuleProps): Promise => - KibanaServices.get().http.fetch(DETECTION_ENGINE_RULES_URL, { + pureFetchRuleById({ id, http: KibanaServices.get().http, signal }); + +/** + * Fetch a Rule by providing a Rule ID + * + * @param id Rule ID's (not rule_id) + * @param http Kibana http service + * @param signal to cancel request + * + * @throws An error if response is not OK + */ +export const pureFetchRuleById = async ({ + id, + http, + signal, +}: FetchRuleProps & { http: HttpStart }): Promise => + http.fetch(DETECTION_ENGINE_RULES_URL, { method: 'GET', query: { id }, signal, diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_async.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_async.tsx new file mode 100644 index 000000000000..fbca46097dcd --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_async.tsx @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useEffect, useCallback } from 'react'; + +import { useAsync, withOptionalSignal } from '../../../../shared_imports'; +import { useHttp } from '../../../../common/lib/kibana'; +import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; +import { pureFetchRuleById } from './api'; +import { Rule } from './types'; +import * as i18n from './translations'; + +export interface UseRuleAsync { + error: unknown; + loading: boolean; + refresh: () => void; + rule: Rule | null; +} + +const _fetchRule = withOptionalSignal(pureFetchRuleById); +const _useRuleAsync = () => useAsync(_fetchRule); + +export const useRuleAsync = (ruleId: string): UseRuleAsync => { + const { start, loading, result, error } = _useRuleAsync(); + const http = useHttp(); + const { addError } = useAppToasts(); + + const fetch = useCallback(() => { + start({ id: ruleId, http }); + }, [http, ruleId, start]); + + // initial fetch + useEffect(() => { + fetch(); + }, [fetch]); + + // toast on error + useEffect(() => { + if (error != null) { + addError(error, { title: i18n.RULE_AND_TIMELINE_FETCH_FAILURE }); + } + }, [addError, error]); + + return { error, loading, refresh: fetch, rule: result ?? null }; +}; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx index 5832f0713493..9c130a7d351f 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx @@ -37,7 +37,7 @@ import { } from '../../../../../common/components/link_to/redirect_to_detection_engine'; import { SiemSearchBar } from '../../../../../common/components/search_bar'; import { WrapperPage } from '../../../../../common/components/wrapper_page'; -import { useRule, Rule } from '../../../../containers/detection_engine/rules'; +import { Rule } from '../../../../containers/detection_engine/rules'; import { useListsConfig } from '../../../../containers/detection_engine/lists/use_lists_config'; import { useWithSource } from '../../../../../common/containers/source'; @@ -84,7 +84,7 @@ import { ExceptionsViewer } from '../../../../../common/components/exceptions/vi import { DEFAULT_INDEX_PATTERN, FILTERS_GLOBAL_HEIGHT } from '../../../../../../common/constants'; import { useFullScreen } from '../../../../../common/containers/use_full_screen'; import { Display } from '../../../../../hosts/pages/display'; -import { ExceptionListTypeEnum, ExceptionIdentifiers } from '../../../../../lists_plugin_deps'; +import { ExceptionListTypeEnum, ExceptionIdentifiers } from '../../../../../shared_imports'; import { getEventsViewerBodyHeight, MIN_EVENTS_VIEWER_BODY_HEIGHT, @@ -92,6 +92,7 @@ import { import { footerHeight } from '../../../../../timelines/components/timeline/footer'; import { isMlRule } from '../../../../../../common/machine_learning/helpers'; import { isThresholdRule } from '../../../../../../common/detection_engine/utils'; +import { useRuleAsync } from '../../../../containers/detection_engine/rules/use_rule_async'; import { showGlobalFilters } from '../../../../../timelines/components/timeline/helpers'; import { timelineSelectors } from '../../../../../timelines/store/timeline'; import { timelineDefaults } from '../../../../../timelines/store/timeline/defaults'; @@ -146,7 +147,9 @@ export const RuleDetailsPageComponent: FC = ({ } = useListsConfig(); const loading = userInfoLoading || listsConfigLoading; const { detailName: ruleId } = useParams(); - const [isLoading, rule] = useRule(ruleId); + const { rule: maybeRule, refresh: refreshRule, loading: ruleLoading } = useRuleAsync(ruleId); + const [rule, setRule] = useState(null); + const isLoading = ruleLoading && rule == null; // This is used to re-trigger api rule status when user de/activate rule const [ruleEnabled, setRuleEnabled] = useState(null); const [ruleDetailTab, setRuleDetailTab] = useState(RuleDetailTabs.alerts); @@ -172,10 +175,17 @@ export const RuleDetailsPageComponent: FC = ({ mlCapabilities.isPlatinumOrTrialLicense && hasMlAdminPermissions(mlCapabilities); const ruleDetailTabs = getRuleDetailsTabs(rule); - const title = isLoading === true || rule === null ? : rule.name; + // persist rule until refresh is complete + useEffect(() => { + if (maybeRule != null) { + setRule(maybeRule); + } + }, [maybeRule]); + + const title = rule?.name ?? ; const subTitle = useMemo( () => - isLoading === true || rule === null ? ( + rule == null ? ( ) : ( [ @@ -211,7 +221,7 @@ export const RuleDetailsPageComponent: FC = ({ ), ] ), - [isLoading, rule] + [rule] ); // Set showBuildingBlockAlerts if rule is a Building Block Rule otherwise we won't show alerts @@ -524,6 +534,7 @@ export const RuleDetailsPageComponent: FC = ({ availableListTypes={exceptionLists.allowedExceptionListTypes} commentsAccordionId={'ruleDetailsTabExceptions'} exceptionListsMeta={exceptionLists.lists} + onRuleChange={refreshRule} /> )} {ruleDetailTab === RuleDetailTabs.failures && } diff --git a/x-pack/plugins/security_solution/public/shared_imports.ts b/x-pack/plugins/security_solution/public/shared_imports.ts index 9939345324f1..b2c7319b9457 100644 --- a/x-pack/plugins/security_solution/public/shared_imports.ts +++ b/x-pack/plugins/security_solution/public/shared_imports.ts @@ -32,6 +32,7 @@ export { useIsMounted, useCursor, useApi, + useAsync, useExceptionList, usePersistExceptionItem, usePersistExceptionList, @@ -50,4 +51,5 @@ export { Pagination, UseExceptionListSuccess, addEndpointExceptionList, + withOptionalSignal, } from '../../lists/public'; From e1a3dccf034863278cd51a534acf47d403767619 Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Thu, 23 Jul 2020 11:01:59 -0600 Subject: [PATCH 106/142] [Maps] fix cloned clustered documents layer returns error (#72975) * [Maps] fix cloned clustered documents layer returns error * tslint --- .../blended_vector_layer.test.tsx | 150 ++++++++++++++++++ .../blended_vector_layer.ts | 16 +- 2 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.test.tsx diff --git a/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.test.tsx b/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.test.tsx new file mode 100644 index 000000000000..5d234f5be44a --- /dev/null +++ b/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.test.tsx @@ -0,0 +1,150 @@ +/* + * 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 { SCALING_TYPES, SOURCE_TYPES } from '../../../../common/constants'; +import { BlendedVectorLayer } from './blended_vector_layer'; +// @ts-expect-error +import { ESSearchSource } from '../../sources/es_search_source'; +import { ESGeoGridSourceDescriptor } from '../../../../common/descriptor_types'; + +jest.mock('../../../kibana_services', () => { + return { + getIsDarkMode() { + return false; + }, + }; +}); + +const mapColors: string[] = []; + +const notClusteredDataRequest = { + data: { isSyncClustered: false }, + dataId: 'ACTIVE_COUNT_DATA_ID', +}; + +const clusteredDataRequest = { + data: { isSyncClustered: true }, + dataId: 'ACTIVE_COUNT_DATA_ID', +}; + +const documentSourceDescriptor = ESSearchSource.createDescriptor({ + geoField: 'myGeoField', + indexPatternId: 'myIndexPattern', + scalingType: SCALING_TYPES.CLUSTERS, +}); + +describe('getSource', () => { + describe('isClustered: true', () => { + test('should return cluster source', async () => { + const blendedVectorLayer = new BlendedVectorLayer({ + source: new ESSearchSource(documentSourceDescriptor), + layerDescriptor: BlendedVectorLayer.createDescriptor( + { + sourceDescriptor: documentSourceDescriptor, + __dataRequests: [clusteredDataRequest], + }, + mapColors + ), + }); + + const source = blendedVectorLayer.getSource(); + expect(source.cloneDescriptor().type).toBe(SOURCE_TYPES.ES_GEO_GRID); + }); + + test('cluster source applyGlobalQuery should be true when document source applyGlobalQuery is true', async () => { + const blendedVectorLayer = new BlendedVectorLayer({ + source: new ESSearchSource(documentSourceDescriptor), + layerDescriptor: BlendedVectorLayer.createDescriptor( + { + sourceDescriptor: documentSourceDescriptor, + __dataRequests: [clusteredDataRequest], + }, + mapColors + ), + }); + + const source = blendedVectorLayer.getSource(); + expect((source.cloneDescriptor() as ESGeoGridSourceDescriptor).applyGlobalQuery).toBe(true); + }); + + test('cluster source applyGlobalQuery should be false when document source applyGlobalQuery is false', async () => { + const blendedVectorLayer = new BlendedVectorLayer({ + source: new ESSearchSource({ + ...documentSourceDescriptor, + applyGlobalQuery: false, + }), + layerDescriptor: BlendedVectorLayer.createDescriptor( + { + sourceDescriptor: documentSourceDescriptor, + __dataRequests: [clusteredDataRequest], + }, + mapColors + ), + }); + + const source = blendedVectorLayer.getSource(); + expect((source.cloneDescriptor() as ESGeoGridSourceDescriptor).applyGlobalQuery).toBe(false); + }); + }); + + describe('isClustered: false', () => { + test('should return document source', async () => { + const blendedVectorLayer = new BlendedVectorLayer({ + source: new ESSearchSource(documentSourceDescriptor), + layerDescriptor: BlendedVectorLayer.createDescriptor( + { + sourceDescriptor: documentSourceDescriptor, + __dataRequests: [notClusteredDataRequest], + }, + mapColors + ), + }); + + const source = blendedVectorLayer.getSource(); + expect(source.cloneDescriptor().type).toBe(SOURCE_TYPES.ES_SEARCH); + }); + }); +}); + +describe('cloneDescriptor', () => { + describe('isClustered: true', () => { + test('Cloned layer descriptor sourceDescriptor should be document source', async () => { + const blendedVectorLayer = new BlendedVectorLayer({ + source: new ESSearchSource(documentSourceDescriptor), + layerDescriptor: BlendedVectorLayer.createDescriptor( + { + sourceDescriptor: documentSourceDescriptor, + __dataRequests: [clusteredDataRequest], + }, + mapColors + ), + }); + + const clonedLayerDescriptor = await blendedVectorLayer.cloneDescriptor(); + expect(clonedLayerDescriptor.sourceDescriptor!.type).toBe(SOURCE_TYPES.ES_SEARCH); + expect(clonedLayerDescriptor.label).toBe('Clone of myIndexPattern'); + }); + }); + + describe('isClustered: false', () => { + test('Cloned layer descriptor sourceDescriptor should be document source', async () => { + const blendedVectorLayer = new BlendedVectorLayer({ + source: new ESSearchSource(documentSourceDescriptor), + layerDescriptor: BlendedVectorLayer.createDescriptor( + { + sourceDescriptor: documentSourceDescriptor, + __dataRequests: [notClusteredDataRequest], + }, + mapColors + ), + }); + + const clonedLayerDescriptor = await blendedVectorLayer.cloneDescriptor(); + expect(clonedLayerDescriptor.sourceDescriptor!.type).toBe(SOURCE_TYPES.ES_SEARCH); + expect(clonedLayerDescriptor.label).toBe('Clone of myIndexPattern'); + }); + }); +}); diff --git a/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts b/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts index da28574189e6..950d9890a3c6 100644 --- a/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts +++ b/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts @@ -34,6 +34,7 @@ import { SizeDynamicOptions, DynamicStylePropertyOptions, StylePropertyOptions, + LayerDescriptor, VectorLayerDescriptor, } from '../../../../common/descriptor_types'; import { IStyle } from '../../styles/style'; @@ -216,7 +217,7 @@ export class BlendedVectorLayer extends VectorLayer implements IVectorLayer { } } - async getDisplayName(source: ISource) { + async getDisplayName(source?: ISource) { const displayName = await super.getDisplayName(source); return this._isClustered ? i18n.translate('xpack.maps.blendedVectorLayer.clusteredLayerName', { @@ -242,6 +243,19 @@ export class BlendedVectorLayer extends VectorLayer implements IVectorLayer { return false; } + async cloneDescriptor(): Promise { + const clonedDescriptor = await super.cloneDescriptor(); + + // Use super getDisplayName instead of instance getDisplayName to avoid getting 'Clustered Clone of Clustered' + const displayName = await super.getDisplayName(); + clonedDescriptor.label = `Clone of ${displayName}`; + + // sourceDescriptor must be document source descriptor + clonedDescriptor.sourceDescriptor = this._documentSource.cloneDescriptor(); + + return clonedDescriptor; + } + getSource() { return this._isClustered ? this._clusterSource : this._documentSource; } From 4b7c16c2ba2ed61b003540a52b0f75baacd1a7e6 Mon Sep 17 00:00:00 2001 From: Michael Olorunnisola Date: Thu, 23 Jul 2020 13:04:11 -0400 Subject: [PATCH 107/142] [Security Solution] [Resolver] Select origin node on load (#72946) --- .../security_solution/public/resolver/store/reducer.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/security_solution/public/resolver/store/reducer.ts b/x-pack/plugins/security_solution/public/resolver/store/reducer.ts index 028c28d94a41..d0f9701fe944 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/reducer.ts @@ -18,7 +18,14 @@ const uiReducer: Reducer = ( }, action ) => { - if (action.type === 'userFocusedOnResolverNode') { + if (action.type === 'serverReturnedResolverData') { + const next: ResolverUIState = { + ...state, + ariaActiveDescendant: action.payload.result.entityID, + selectedNode: action.payload.result.entityID, + }; + return next; + } else if (action.type === 'userFocusedOnResolverNode') { const next: ResolverUIState = { ...state, ariaActiveDescendant: action.payload, From cb48e6e98ecdadbffbaeee5d998c0f25d6af8f6a Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Thu, 23 Jul 2020 11:14:54 -0600 Subject: [PATCH 108/142] [maps] fix un-hiding layer not syncing data (#73039) * [maps] fix un-hiding layer not syncing data * revert syncDataForLayer to syncDataForLayerId * remove unused method --- .../maps/public/actions/layer_actions.ts | 15 +++------ x-pack/test/functional/apps/maps/index.js | 1 + .../functional/apps/maps/layer_visibility.js | 33 +++++++++++++++++++ .../es_archives/maps/kibana/data.json | 31 +++++++++++++++++ 4 files changed, 70 insertions(+), 10 deletions(-) create mode 100644 x-pack/test/functional/apps/maps/layer_visibility.js diff --git a/x-pack/plugins/maps/public/actions/layer_actions.ts b/x-pack/plugins/maps/public/actions/layer_actions.ts index a0d2152e8866..208f6dc6c6f8 100644 --- a/x-pack/plugins/maps/public/actions/layer_actions.ts +++ b/x-pack/plugins/maps/public/actions/layer_actions.ts @@ -35,12 +35,7 @@ import { UPDATE_LAYER_STYLE, UPDATE_SOURCE_PROP, } from './map_action_constants'; -import { - clearDataRequests, - syncDataForLayerId, - syncDataForLayer, - updateStyleMeta, -} from './data_request_actions'; +import { clearDataRequests, syncDataForLayerId, updateStyleMeta } from './data_request_actions'; import { cleanTooltipStateForLayer } from './tooltip_actions'; import { JoinDescriptor, LayerDescriptor, StyleDescriptor } from '../../common/descriptor_types'; import { ILayer } from '../classes/layers/layer'; @@ -175,7 +170,7 @@ export function promotePreviewLayers() { } export function setLayerVisibility(layerId: string, makeVisible: boolean) { - return async (dispatch: Dispatch, getState: () => MapStoreState) => { + return (dispatch: Dispatch, getState: () => MapStoreState) => { // if the current-state is invisible, we also want to sync data // e.g. if a layer was invisible at start-up, it won't have any data loaded const layer = getLayerById(layerId, getState()); @@ -189,19 +184,19 @@ export function setLayerVisibility(layerId: string, makeVisible: boolean) { dispatch(cleanTooltipStateForLayer(layerId)); } - await dispatch({ + dispatch({ type: SET_LAYER_VISIBILITY, layerId, visibility: makeVisible, }); if (makeVisible) { - dispatch(syncDataForLayer(layer)); + dispatch(syncDataForLayerId(layerId)); } }; } export function toggleLayerVisible(layerId: string) { - return async (dispatch: Dispatch, getState: () => MapStoreState) => { + return (dispatch: Dispatch, getState: () => MapStoreState) => { const layer = getLayerById(layerId, getState()); if (!layer) { return; diff --git a/x-pack/test/functional/apps/maps/index.js b/x-pack/test/functional/apps/maps/index.js index d0735aecda78..4bbe38367d0a 100644 --- a/x-pack/test/functional/apps/maps/index.js +++ b/x-pack/test/functional/apps/maps/index.js @@ -35,6 +35,7 @@ export default function ({ loadTestFile, getService }) { loadTestFile(require.resolve('./saved_object_management')); loadTestFile(require.resolve('./sample_data')); loadTestFile(require.resolve('./auto_fit_to_bounds')); + loadTestFile(require.resolve('./layer_visibility')); loadTestFile(require.resolve('./feature_controls/maps_security')); loadTestFile(require.resolve('./feature_controls/maps_spaces')); loadTestFile(require.resolve('./full_screen_mode')); diff --git a/x-pack/test/functional/apps/maps/layer_visibility.js b/x-pack/test/functional/apps/maps/layer_visibility.js new file mode 100644 index 000000000000..22cff6de416c --- /dev/null +++ b/x-pack/test/functional/apps/maps/layer_visibility.js @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +export default function ({ getPageObjects, getService }) { + const PageObjects = getPageObjects(['maps']); + const inspector = getService('inspector'); + + describe('layer visibility', () => { + before(async () => { + await PageObjects.maps.loadSavedMap('document example hidden'); + }); + + afterEach(async () => { + await inspector.close(); + }); + + it('should not make any requests when layer is hidden', async () => { + const noRequests = await PageObjects.maps.doesInspectorHaveRequests(); + expect(noRequests).to.equal(true); + }); + + it('should fetch layer data when layer is made visible', async () => { + await PageObjects.maps.toggleLayerVisibility('logstash'); + const hits = await PageObjects.maps.getHits(); + expect(hits).to.equal('6'); + }); + }); +} diff --git a/x-pack/test/functional/es_archives/maps/kibana/data.json b/x-pack/test/functional/es_archives/maps/kibana/data.json index d2206009d9e6..7690c9258931 100644 --- a/x-pack/test/functional/es_archives/maps/kibana/data.json +++ b/x-pack/test/functional/es_archives/maps/kibana/data.json @@ -446,6 +446,37 @@ } } +{ + "type": "doc", + "value": { + "id": "map:2de4de10-cc82-11ea-9b0a-eb2886fc84af", + "index": ".kibana", + "source": { + "map": { + "title" : "document example hidden", + "description" : "", + "mapStateJSON" : "{\"zoom\":4.1,\"center\":{\"lon\":-100.61091,\"lat\":33.23887},\"timeFilters\":{\"from\":\"2015-09-20T00:00:00.000Z\",\"to\":\"2015-09-20T01:00:00.000Z\"},\"refreshConfig\":{\"isPaused\":true,\"interval\":1000},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[],\"settings\":{\"autoFitToDataBounds\":false,\"initialLocation\":\"LAST_SAVED_LOCATION\",\"fixedLocation\":{\"lat\":0,\"lon\":0,\"zoom\":2},\"browserLocation\":{\"zoom\":2},\"maxZoom\":24,\"minZoom\":0,\"showSpatialFilters\":true,\"spatialFiltersAlpa\":0.3,\"spatialFiltersFillColor\":\"#DA8B45\",\"spatialFiltersLineColor\":\"#DA8B45\"}}", + "layerListJSON" : "[{\"id\":\"0hmz5\",\"sourceDescriptor\":{\"type\":\"EMS_TMS\",\"id\":\"road_map\"},\"visible\":true,\"temporary\":false,\"style\":{\"type\":\"TILE\",\"properties\":{}},\"type\":\"VECTOR_TILE\",\"minZoom\":0,\"maxZoom\":24},{\"id\":\"z52lq\",\"label\":\"logstash\",\"minZoom\":0,\"maxZoom\":24,\"sourceDescriptor\":{\"id\":\"e1a5e1a6-676c-4a89-8ea9-0d91d64b73c6\",\"type\":\"ES_SEARCH\",\"geoField\":\"geo.coordinates\",\"limit\":2048,\"filterByMapBounds\":true,\"showTooltip\":true,\"tooltipProperties\":[],\"applyGlobalQuery\":true,\"scalingType\":\"LIMIT\",\"indexPatternRefName\":\"layer_1_source_index_pattern\"},\"visible\":false,\"temporary\":false,\"style\":{\"type\":\"VECTOR\",\"properties\":{\"fillColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#e6194b\"}},\"lineColor\":{\"type\":\"STATIC\",\"options\":{\"color\":\"#FFFFFF\"}},\"lineWidth\":{\"type\":\"STATIC\",\"options\":{\"size\":1}},\"iconSize\":{\"type\":\"STATIC\",\"options\":{\"size\":10}},\"symbolizeAs\":{\"options\":{\"value\":\"circle\"}},\"icon\":{\"type\":\"STATIC\",\"options\":{\"value\":\"marker\"}}},\"previousStyle\":null},\"type\":\"VECTOR\"}]", + "uiStateJSON" : "{\"isLayerTOCOpen\":true,\"openTOCDetails\":[]}" + }, + "type" : "map", + "references" : [ + { + "name" : "layer_1_source_index_pattern", + "type" : "index-pattern", + "id" : "c698b940-e149-11e8-a35a-370a8516603a" + } + ], + "migrationVersion" : { + "map" : "7.9.0" + }, + "updated_at" : "2020-07-23T01:16:47.600Z" + } + } +} + + + { "type": "doc", "value": { From e285beb148bab1e8ac02f4d7edc5e0a686fa750e Mon Sep 17 00:00:00 2001 From: Jen Huang Date: Thu, 23 Jul 2020 10:47:40 -0700 Subject: [PATCH 109/142] [Ingest Manager] Match create agent config flyout with designs (#72973) * Update create agent config flyout and form * Remove cross icon from all flyout Close buttons * Make tooltip icons in overview panels subdued * Add tooltips to agent monitoring options --- .../components/alpha_flyout.tsx | 2 +- .../components/settings_flyout.tsx | 2 +- .../agent_config/components/config_form.tsx | 61 ++++++++++++++----- .../components/config_yaml_flyout.tsx | 2 +- .../list_page/components/create_config.tsx | 14 +++-- .../agent_enrollment_flyout/index.tsx | 2 +- .../agent_reassign_config_flyout/index.tsx | 2 +- .../components/new_enrollment_key_flyout.tsx | 2 +- .../overview/components/overview_panel.tsx | 2 +- 9 files changed, 62 insertions(+), 27 deletions(-) diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/alpha_flyout.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/alpha_flyout.tsx index 03c70f71529c..110d6de02c12 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/alpha_flyout.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/alpha_flyout.tsx @@ -73,7 +73,7 @@ export const AlphaFlyout: React.FunctionComponent = ({ onClose }) => { - + = ({ onClose }) => { - + = ({ options={[ { id: 'logs', - label: i18n.translate( - 'xpack.ingestManager.agentConfigForm.monitoringLogsFieldLabel', - { defaultMessage: 'Collect agent logs' } + label: ( + <> + {' '} + + ), }, { id: 'metrics', - label: i18n.translate( - 'xpack.ingestManager.agentConfigForm.monitoringMetricsFieldLabel', - { defaultMessage: 'Collect agent metrics' } + label: ( + <> + {' '} + + ), }, ]} @@ -315,16 +347,14 @@ export const AgentConfigForm: React.FunctionComponent = ({ {!isEditing ? ( - - + } > - = ({ )} position="right" type="iInCircle" + color="subdued" /> } diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/config_yaml_flyout.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/config_yaml_flyout.tsx index 6cf60fe1dc50..9c2d09b02665 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/config_yaml_flyout.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/components/config_yaml_flyout.tsx @@ -72,7 +72,7 @@ export const ConfigYamlFlyout = memo<{ configId: string; onClose: () => void }>( - + = ({ /> + - +

+ +

); @@ -95,7 +99,7 @@ export const CreateAgentConfigFlyout: React.FunctionComponent = ({ - onClose()} flush="left"> + onClose()} flush="left"> = ({ - + = ({ onCl - + = ({ - + - + From 52a1b05623826650ff0aff67d0428b78caa29a46 Mon Sep 17 00:00:00 2001 From: Jen Huang Date: Thu, 23 Jul 2020 10:47:53 -0700 Subject: [PATCH 110/142] Change copy to Agent ID (#72953) --- .../fleet/agent_details_page/components/agent_details.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/agent_details.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/agent_details.tsx index 63d93f14c63f..de0c65d508db 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/agent_details.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/agent_details.tsx @@ -37,7 +37,7 @@ export const AgentDetailsContent: React.FunctionComponent<{ }, { title: i18n.translate('xpack.ingestManager.agentDetails.hostIdLabel', { - defaultMessage: 'Host ID', + defaultMessage: 'Agent ID', }), description: agent.id, }, From 8834ca3e9a4991997dd938abf2ca0a33f7e36a63 Mon Sep 17 00:00:00 2001 From: Spencer Date: Thu, 23 Jul 2020 10:58:04 -0700 Subject: [PATCH 111/142] [src/dev/build] typescript-ify and convert tests to jest (#72525) Co-authored-by: spalger --- package.json | 3 + packages/kbn-dev-utils/package.json | 1 + packages/kbn-dev-utils/src/index.ts | 2 +- .../serializers/absolute_path_serializer.ts | 2 +- .../serializers/any_instance_serizlizer.ts | 25 ++ .../kbn-dev-utils/src/serializers/index.ts | 5 +- .../src/serializers/recursive_serializer.ts | 29 ++ .../src/serializers/strip_ansi_serializer.ts | 29 ++ .../index.js => packages/kbn-pm/index.d.ts | 2 +- packages/kbn-pm/tsconfig.json | 1 + src/dev/build/args.test.ts | 264 +++++++------ src/dev/build/args.ts | 56 ++- src/dev/build/build_distributables.js | 174 --------- src/dev/build/build_distributables.ts | 123 ++++++ src/dev/build/{cli.js => cli.ts} | 8 +- .../bin/world_executable | 0 .../fixtures => __fixtures__}/foo.txt.gz | Bin .../fixtures => __fixtures__}/foo_dir.tar.gz | Bin .../fixtures => __fixtures__}/foo_dir/.bar | 0 .../fixtures => __fixtures__}/foo_dir/bar.txt | 0 .../foo_dir/foo/foo.txt | 0 .../build/lib/__fixtures__}/log_on_sigint.js | 0 src/dev/build/lib/__tests__/build.js | 168 -------- src/dev/build/lib/__tests__/config.js | 174 --------- src/dev/build/lib/__tests__/download.js | 237 ------------ src/dev/build/lib/__tests__/exec.js | 58 --- src/dev/build/lib/__tests__/fs.js | 362 ------------------ src/dev/build/lib/__tests__/platform.js | 68 ---- src/dev/build/lib/__tests__/runner.js | 184 --------- src/dev/build/lib/__tests__/version_info.js | 70 ---- src/dev/build/lib/build.js | 60 --- src/dev/build/lib/build.test.ts | 120 ++++++ src/dev/build/lib/build.ts | 63 +++ src/dev/build/lib/config.js | 168 -------- src/dev/build/lib/config.test.ts | 201 ++++++++++ src/dev/build/lib/config.ts | 173 +++++++++ .../build/lib/{download.js => download.ts} | 21 +- .../{__tests__/errors.js => errors.test.ts} | 22 +- src/dev/build/lib/{errors.js => errors.ts} | 6 +- src/dev/build/lib/exec.test.ts | 67 ++++ src/dev/build/lib/{exec.js => exec.ts} | 17 +- src/dev/build/lib/{fs.js => fs.ts} | 157 ++++++-- src/dev/build/lib/index.js | 39 -- src/dev/build/lib/index.ts | 30 ++ .../lib/integration_tests/download.test.ts | 226 +++++++++++ .../build/lib/integration_tests/fs.test.ts | 358 +++++++++++++++++ .../{ => integration_tests}/scan_copy.test.ts | 9 +- .../watch_stdio_for_line.test.ts | 52 +++ src/dev/build/lib/platform.js | 50 --- src/dev/build/lib/platform.test.ts | 62 +++ src/dev/build/lib/platform.ts | 64 ++++ src/dev/build/lib/runner.test.ts | 248 ++++++++++++ src/dev/build/lib/{runner.js => runner.ts} | 51 +-- src/dev/build/lib/version_info.test.ts | 62 +++ .../lib/{version_info.js => version_info.ts} | 14 +- .../build/lib/watch_stdio_for_line.ts} | 19 +- ...ripts_task.js => copy_bin_scripts_task.ts} | 4 +- .../build/tasks/bin/{index.js => index.ts} | 2 +- ...ns.js => build_kibana_platform_plugins.ts} | 6 +- ...ackages_task.js => build_packages_task.ts} | 6 +- .../tasks/{clean_tasks.js => clean_tasks.ts} | 16 +- ...opy_source_task.js => copy_source_task.ts} | 4 +- ...ask.js => create_archives_sources_task.ts} | 4 +- ...chives_task.js => create_archives_task.ts} | 44 +-- ...js => create_empty_dirs_and_files_task.ts} | 4 +- ...on_task.js => create_package_json_task.ts} | 10 +- ...e_readme_task.js => create_readme_task.ts} | 4 +- src/dev/build/tasks/{index.js => index.ts} | 4 +- src/dev/build/tasks/install_chromium.js | 6 +- ...s_task.js => install_dependencies_task.ts} | 4 +- ...ense_file_task.js => license_file_task.ts} | 4 +- .../__tests__/download_node_builds_task.js | 97 ----- .../__tests__/extract_node_builds_task.js | 93 ----- .../verify_existing_node_builds_task.js | 106 ----- ...ilds_task.js => clean_node_builds_task.ts} | 4 +- .../nodejs/download_node_builds_task.test.ts | 136 +++++++ ...s_task.js => download_node_builds_task.ts} | 4 +- .../nodejs/extract_node_builds_task.test.ts | 108 ++++++ ...ds_task.js => extract_node_builds_task.ts} | 30 +- src/dev/build/tasks/nodejs/index.js | 25 -- src/dev/build/tasks/nodejs/index.ts | 24 ++ ...download_info.js => node_download_info.ts} | 4 +- .../verify_existing_node_builds_task.test.ts | 225 +++++++++++ ...js => verify_existing_node_builds_task.ts} | 4 +- ...otice_file_task.js => notice_file_task.ts} | 6 +- .../{optimize_task.js => optimize_task.ts} | 4 +- ...ge_tasks.js => create_os_package_tasks.ts} | 9 +- .../docker_generator/bundle_dockerfiles.js | 5 +- .../{index.js => docker_generator/index.ts} | 8 +- src/dev/build/tasks/os_packages/index.ts | 20 + .../os_packages/{run_fpm.js => run_fpm.ts} | 16 +- ...s_task.js => patch_native_modules_task.ts} | 40 +- ...ath_length_task.js => path_length_task.ts} | 4 +- ..._babel_task.js => transpile_babel_task.ts} | 18 +- ...le_scss_task.js => transpile_scss_task.ts} | 5 +- ...tion_task.js => uuid_verification_task.ts} | 4 +- ...{verify_env_task.js => verify_env_task.ts} | 4 +- ...ha_sums_task.js => write_sha_sums_task.ts} | 4 +- .../utils/__tests__/watch_stdio_for_line.js | 55 --- src/legacy/utils/index.js | 1 - src/legacy/utils/streams/index.d.ts | 2 +- x-pack/package.json | 2 +- yarn.lock | 8 +- 103 files changed, 3008 insertions(+), 2593 deletions(-) create mode 100644 packages/kbn-dev-utils/src/serializers/any_instance_serizlizer.ts create mode 100644 packages/kbn-dev-utils/src/serializers/recursive_serializer.ts create mode 100644 packages/kbn-dev-utils/src/serializers/strip_ansi_serializer.ts rename src/dev/build/tasks/os_packages/docker_generator/index.js => packages/kbn-pm/index.d.ts (96%) delete mode 100644 src/dev/build/build_distributables.js create mode 100644 src/dev/build/build_distributables.ts rename src/dev/build/{cli.js => cli.ts} (91%) rename src/dev/build/lib/{__tests__/fixtures => __fixtures__}/bin/world_executable (100%) rename src/dev/build/lib/{__tests__/fixtures => __fixtures__}/foo.txt.gz (100%) rename src/dev/build/lib/{__tests__/fixtures => __fixtures__}/foo_dir.tar.gz (100%) rename src/dev/build/lib/{__tests__/fixtures => __fixtures__}/foo_dir/.bar (100%) rename src/dev/build/lib/{__tests__/fixtures => __fixtures__}/foo_dir/bar.txt (100%) rename src/dev/build/lib/{__tests__/fixtures => __fixtures__}/foo_dir/foo/foo.txt (100%) rename src/{legacy/utils/__tests__/fixtures => dev/build/lib/__fixtures__}/log_on_sigint.js (100%) delete mode 100644 src/dev/build/lib/__tests__/build.js delete mode 100644 src/dev/build/lib/__tests__/config.js delete mode 100644 src/dev/build/lib/__tests__/download.js delete mode 100644 src/dev/build/lib/__tests__/exec.js delete mode 100644 src/dev/build/lib/__tests__/fs.js delete mode 100644 src/dev/build/lib/__tests__/platform.js delete mode 100644 src/dev/build/lib/__tests__/runner.js delete mode 100644 src/dev/build/lib/__tests__/version_info.js delete mode 100644 src/dev/build/lib/build.js create mode 100644 src/dev/build/lib/build.test.ts create mode 100644 src/dev/build/lib/build.ts delete mode 100644 src/dev/build/lib/config.js create mode 100644 src/dev/build/lib/config.test.ts create mode 100644 src/dev/build/lib/config.ts rename src/dev/build/lib/{download.js => download.ts} (81%) rename src/dev/build/lib/{__tests__/errors.js => errors.test.ts} (67%) rename src/dev/build/lib/{errors.js => errors.ts} (86%) create mode 100644 src/dev/build/lib/exec.test.ts rename src/dev/build/lib/{exec.js => exec.ts} (73%) rename src/dev/build/lib/{fs.js => fs.ts} (56%) delete mode 100644 src/dev/build/lib/index.js create mode 100644 src/dev/build/lib/index.ts create mode 100644 src/dev/build/lib/integration_tests/download.test.ts create mode 100644 src/dev/build/lib/integration_tests/fs.test.ts rename src/dev/build/lib/{ => integration_tests}/scan_copy.test.ts (94%) create mode 100644 src/dev/build/lib/integration_tests/watch_stdio_for_line.test.ts delete mode 100644 src/dev/build/lib/platform.js create mode 100644 src/dev/build/lib/platform.test.ts create mode 100644 src/dev/build/lib/platform.ts create mode 100644 src/dev/build/lib/runner.test.ts rename src/dev/build/lib/{runner.js => runner.ts} (72%) create mode 100644 src/dev/build/lib/version_info.test.ts rename src/dev/build/lib/{version_info.js => version_info.ts} (84%) rename src/{legacy/utils/watch_stdio_for_line.js => dev/build/lib/watch_stdio_for_line.ts} (83%) rename src/dev/build/tasks/bin/{copy_bin_scripts_task.js => copy_bin_scripts_task.ts} (92%) rename src/dev/build/tasks/bin/{index.js => index.ts} (92%) rename src/dev/build/tasks/{build_kibana_platform_plugins.js => build_kibana_platform_plugins.ts} (92%) rename src/dev/build/tasks/{build_packages_task.js => build_packages_task.ts} (97%) rename src/dev/build/tasks/{clean_tasks.js => clean_tasks.ts} (92%) rename src/dev/build/tasks/{copy_source_task.js => copy_source_task.ts} (95%) rename src/dev/build/tasks/{create_archives_sources_task.js => create_archives_sources_task.ts} (95%) rename src/dev/build/tasks/{create_archives_task.js => create_archives_task.ts} (80%) rename src/dev/build/tasks/{create_empty_dirs_and_files_task.js => create_empty_dirs_and_files_task.ts} (92%) rename src/dev/build/tasks/{create_package_json_task.js => create_package_json_task.ts} (92%) rename src/dev/build/tasks/{create_readme_task.js => create_readme_task.ts} (93%) rename src/dev/build/tasks/{index.js => index.ts} (92%) rename src/dev/build/tasks/{install_dependencies_task.js => install_dependencies_task.ts} (94%) rename src/dev/build/tasks/{license_file_task.js => license_file_task.ts} (94%) delete mode 100644 src/dev/build/tasks/nodejs/__tests__/download_node_builds_task.js delete mode 100644 src/dev/build/tasks/nodejs/__tests__/extract_node_builds_task.js delete mode 100644 src/dev/build/tasks/nodejs/__tests__/verify_existing_node_builds_task.js rename src/dev/build/tasks/nodejs/{clean_node_builds_task.js => clean_node_builds_task.ts} (93%) create mode 100644 src/dev/build/tasks/nodejs/download_node_builds_task.test.ts rename src/dev/build/tasks/nodejs/{download_node_builds_task.js => download_node_builds_task.ts} (93%) create mode 100644 src/dev/build/tasks/nodejs/extract_node_builds_task.test.ts rename src/dev/build/tasks/nodejs/{extract_node_builds_task.js => extract_node_builds_task.ts} (56%) delete mode 100644 src/dev/build/tasks/nodejs/index.js create mode 100644 src/dev/build/tasks/nodejs/index.ts rename src/dev/build/tasks/nodejs/{node_download_info.js => node_download_info.ts} (92%) create mode 100644 src/dev/build/tasks/nodejs/verify_existing_node_builds_task.test.ts rename src/dev/build/tasks/nodejs/{verify_existing_node_builds_task.js => verify_existing_node_builds_task.ts} (93%) rename src/dev/build/tasks/{notice_file_task.js => notice_file_task.ts} (95%) rename src/dev/build/tasks/{optimize_task.js => optimize_task.ts} (95%) rename src/dev/build/tasks/os_packages/{create_os_package_tasks.js => create_os_package_tasks.ts} (89%) rename src/dev/build/tasks/os_packages/{index.js => docker_generator/index.ts} (84%) create mode 100644 src/dev/build/tasks/os_packages/index.ts rename src/dev/build/tasks/os_packages/{run_fpm.js => run_fpm.ts} (91%) rename src/dev/build/tasks/{patch_native_modules_task.js => patch_native_modules_task.ts} (82%) rename src/dev/build/tasks/{path_length_task.js => path_length_task.ts} (95%) rename src/dev/build/tasks/{transpile_babel_task.js => transpile_babel_task.ts} (80%) rename src/dev/build/tasks/{transpile_scss_task.js => transpile_scss_task.ts} (89%) rename src/dev/build/tasks/{uuid_verification_task.js => uuid_verification_task.ts} (94%) rename src/dev/build/tasks/{verify_env_task.js => verify_env_task.ts} (93%) rename src/dev/build/tasks/{write_sha_sums_task.js => write_sha_sums_task.ts} (92%) delete mode 100644 src/legacy/utils/__tests__/watch_stdio_for_line.js diff --git a/package.json b/package.json index 0d6bc8cc1fce..594f0ce58398 100644 --- a/package.json +++ b/package.json @@ -317,6 +317,7 @@ "@types/accept": "3.1.1", "@types/angular": "^1.6.56", "@types/angular-mocks": "^1.7.0", + "@types/archiver": "^3.1.0", "@types/babel__core": "^7.1.2", "@types/bluebird": "^3.1.1", "@types/boom": "^7.2.0", @@ -398,6 +399,7 @@ "@types/testing-library__react-hooks": "^3.1.0", "@types/type-detect": "^4.0.1", "@types/uuid": "^3.4.4", + "@types/vinyl": "^2.0.4", "@types/vinyl-fs": "^2.4.11", "@types/zen-observable": "^0.8.0", "@typescript-eslint/eslint-plugin": "^2.34.0", @@ -474,6 +476,7 @@ "license-checker": "^16.0.0", "listr": "^0.14.1", "load-grunt-config": "^3.0.1", + "load-json-file": "^6.2.0", "mocha": "^7.1.1", "mock-fs": "^4.12.0", "mock-http-server": "1.3.0", diff --git a/packages/kbn-dev-utils/package.json b/packages/kbn-dev-utils/package.json index b307bd41bb4d..83a7a7607816 100644 --- a/packages/kbn-dev-utils/package.json +++ b/packages/kbn-dev-utils/package.json @@ -20,6 +20,7 @@ "normalize-path": "^3.0.0", "moment": "^2.24.0", "rxjs": "^6.5.5", + "strip-ansi": "^6.0.0", "tree-kill": "^1.2.2", "tslib": "^2.0.0" }, diff --git a/packages/kbn-dev-utils/src/index.ts b/packages/kbn-dev-utils/src/index.ts index 582526f939e4..798746d159f6 100644 --- a/packages/kbn-dev-utils/src/index.ts +++ b/packages/kbn-dev-utils/src/index.ts @@ -19,7 +19,7 @@ export { withProcRunner, ProcRunner } from './proc_runner'; export * from './tooling_log'; -export { createAbsolutePathSerializer } from './serializers'; +export * from './serializers'; export { CA_CERT_PATH, ES_KEY_PATH, diff --git a/packages/kbn-dev-utils/src/serializers/absolute_path_serializer.ts b/packages/kbn-dev-utils/src/serializers/absolute_path_serializer.ts index af55622c7619..884614c8b955 100644 --- a/packages/kbn-dev-utils/src/serializers/absolute_path_serializer.ts +++ b/packages/kbn-dev-utils/src/serializers/absolute_path_serializer.ts @@ -21,7 +21,7 @@ import { REPO_ROOT } from '../repo_root'; export function createAbsolutePathSerializer(rootPath: string = REPO_ROOT) { return { - serialize: (value: string) => value.replace(rootPath, '').replace(/\\/g, '/'), test: (value: any) => typeof value === 'string' && value.startsWith(rootPath), + serialize: (value: string) => value.replace(rootPath, '').replace(/\\/g, '/'), }; } diff --git a/packages/kbn-dev-utils/src/serializers/any_instance_serizlizer.ts b/packages/kbn-dev-utils/src/serializers/any_instance_serizlizer.ts new file mode 100644 index 000000000000..c5cc095e9ee8 --- /dev/null +++ b/packages/kbn-dev-utils/src/serializers/any_instance_serizlizer.ts @@ -0,0 +1,25 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export function createAnyInstanceSerializer(Class: Function, name?: string) { + return { + test: (v: any) => v instanceof Class, + serialize: () => `<${name ?? Class.name}>`, + }; +} diff --git a/packages/kbn-dev-utils/src/serializers/index.ts b/packages/kbn-dev-utils/src/serializers/index.ts index 3b49e243058d..e645a3be3fe5 100644 --- a/packages/kbn-dev-utils/src/serializers/index.ts +++ b/packages/kbn-dev-utils/src/serializers/index.ts @@ -17,4 +17,7 @@ * under the License. */ -export { createAbsolutePathSerializer } from './absolute_path_serializer'; +export * from './absolute_path_serializer'; +export * from './strip_ansi_serializer'; +export * from './recursive_serializer'; +export * from './any_instance_serizlizer'; diff --git a/packages/kbn-dev-utils/src/serializers/recursive_serializer.ts b/packages/kbn-dev-utils/src/serializers/recursive_serializer.ts new file mode 100644 index 000000000000..537ae4972c84 --- /dev/null +++ b/packages/kbn-dev-utils/src/serializers/recursive_serializer.ts @@ -0,0 +1,29 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export function createRecursiveSerializer(test: (v: any) => boolean, print: (v: any) => string) { + return { + test: (v: any) => test(v), + serialize: (v: any, ...rest: any[]) => { + const replacement = print(v); + const printer = rest.pop()!; + return printer(replacement, ...rest); + }, + }; +} diff --git a/packages/kbn-dev-utils/src/serializers/strip_ansi_serializer.ts b/packages/kbn-dev-utils/src/serializers/strip_ansi_serializer.ts new file mode 100644 index 000000000000..4a2151c06f34 --- /dev/null +++ b/packages/kbn-dev-utils/src/serializers/strip_ansi_serializer.ts @@ -0,0 +1,29 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import stripAnsi from 'strip-ansi'; + +import { createRecursiveSerializer } from './recursive_serializer'; + +export function createStripAnsiSerializer() { + return createRecursiveSerializer( + (v) => typeof v === 'string' && stripAnsi(v) !== v, + (v) => stripAnsi(v) + ); +} diff --git a/src/dev/build/tasks/os_packages/docker_generator/index.js b/packages/kbn-pm/index.d.ts similarity index 96% rename from src/dev/build/tasks/os_packages/docker_generator/index.js rename to packages/kbn-pm/index.d.ts index 9e0bbf51f9a5..aa55df9215c2 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/index.js +++ b/packages/kbn-pm/index.d.ts @@ -17,4 +17,4 @@ * under the License. */ -export * from './run'; +export * from './src/index'; diff --git a/packages/kbn-pm/tsconfig.json b/packages/kbn-pm/tsconfig.json index bfb13ee8dcf8..c13a9243c50a 100644 --- a/packages/kbn-pm/tsconfig.json +++ b/packages/kbn-pm/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "../../tsconfig.json", "include": [ + "./index.d.ts", "./src/**/*.ts", "./dist/*.d.ts", ], diff --git a/src/dev/build/args.test.ts b/src/dev/build/args.test.ts index 6a464eef209e..bd118b8887c7 100644 --- a/src/dev/build/args.test.ts +++ b/src/dev/build/args.test.ts @@ -17,160 +17,158 @@ * under the License. */ -import { ToolingLog } from '@kbn/dev-utils'; +import { ToolingLog, createAnyInstanceSerializer } from '@kbn/dev-utils'; import { readCliArgs } from './args'; -const fn = (...subArgs: string[]) => { - const result = readCliArgs(['node', 'scripts/build', ...subArgs]); - (result as any).log = result.log instanceof ToolingLog ? '' : String(result.log); - return result; -}; +expect.addSnapshotSerializer(createAnyInstanceSerializer(ToolingLog)); it('renders help if `--help` passed', () => { - expect(fn('--help')).toMatchInlineSnapshot(` -Object { - "log": "undefined", - "showHelp": true, - "unknownFlags": Array [], -} -`); + expect(readCliArgs(['node', 'scripts/build', '--help'])).toMatchInlineSnapshot(` + Object { + "log": , + "showHelp": true, + "unknownFlags": Array [], + } + `); }); it('build default and oss dist for current platform, without packages, by default', () => { - expect(fn()).toMatchInlineSnapshot(` -Object { - "buildArgs": Object { - "buildDefaultDist": true, - "buildOssDist": true, - "createArchives": true, - "createDebPackage": false, - "createDockerPackage": false, - "createDockerUbiPackage": false, - "createRpmPackage": false, - "downloadFreshNode": true, - "isRelease": false, - "targetAllPlatforms": false, - "versionQualifier": "", - }, - "log": "", - "showHelp": false, - "unknownFlags": Array [], -} -`); + expect(readCliArgs(['node', 'scripts/build'])).toMatchInlineSnapshot(` + Object { + "buildOptions": Object { + "buildDefaultDist": true, + "buildOssDist": true, + "createArchives": true, + "createDebPackage": false, + "createDockerPackage": false, + "createDockerUbiPackage": false, + "createRpmPackage": false, + "downloadFreshNode": true, + "isRelease": false, + "targetAllPlatforms": false, + "versionQualifier": "", + }, + "log": , + "showHelp": false, + "unknownFlags": Array [], + } + `); }); it('builds packages if --all-platforms is passed', () => { - expect(fn('--all-platforms')).toMatchInlineSnapshot(` -Object { - "buildArgs": Object { - "buildDefaultDist": true, - "buildOssDist": true, - "createArchives": true, - "createDebPackage": true, - "createDockerPackage": true, - "createDockerUbiPackage": true, - "createRpmPackage": true, - "downloadFreshNode": true, - "isRelease": false, - "targetAllPlatforms": true, - "versionQualifier": "", - }, - "log": "", - "showHelp": false, - "unknownFlags": Array [], -} -`); + expect(readCliArgs(['node', 'scripts/build', '--all-platforms'])).toMatchInlineSnapshot(` + Object { + "buildOptions": Object { + "buildDefaultDist": true, + "buildOssDist": true, + "createArchives": true, + "createDebPackage": true, + "createDockerPackage": true, + "createDockerUbiPackage": true, + "createRpmPackage": true, + "downloadFreshNode": true, + "isRelease": false, + "targetAllPlatforms": true, + "versionQualifier": "", + }, + "log": , + "showHelp": false, + "unknownFlags": Array [], + } + `); }); it('limits packages if --rpm passed with --all-platforms', () => { - expect(fn('--all-platforms', '--rpm')).toMatchInlineSnapshot(` -Object { - "buildArgs": Object { - "buildDefaultDist": true, - "buildOssDist": true, - "createArchives": true, - "createDebPackage": false, - "createDockerPackage": false, - "createDockerUbiPackage": false, - "createRpmPackage": true, - "downloadFreshNode": true, - "isRelease": false, - "targetAllPlatforms": true, - "versionQualifier": "", - }, - "log": "", - "showHelp": false, - "unknownFlags": Array [], -} -`); + expect(readCliArgs(['node', 'scripts/build', '--all-platforms', '--rpm'])).toMatchInlineSnapshot(` + Object { + "buildOptions": Object { + "buildDefaultDist": true, + "buildOssDist": true, + "createArchives": true, + "createDebPackage": false, + "createDockerPackage": false, + "createDockerUbiPackage": false, + "createRpmPackage": true, + "downloadFreshNode": true, + "isRelease": false, + "targetAllPlatforms": true, + "versionQualifier": "", + }, + "log": , + "showHelp": false, + "unknownFlags": Array [], + } + `); }); it('limits packages if --deb passed with --all-platforms', () => { - expect(fn('--all-platforms', '--deb')).toMatchInlineSnapshot(` -Object { - "buildArgs": Object { - "buildDefaultDist": true, - "buildOssDist": true, - "createArchives": true, - "createDebPackage": true, - "createDockerPackage": false, - "createDockerUbiPackage": false, - "createRpmPackage": false, - "downloadFreshNode": true, - "isRelease": false, - "targetAllPlatforms": true, - "versionQualifier": "", - }, - "log": "", - "showHelp": false, - "unknownFlags": Array [], -} -`); + expect(readCliArgs(['node', 'scripts/build', '--all-platforms', '--deb'])).toMatchInlineSnapshot(` + Object { + "buildOptions": Object { + "buildDefaultDist": true, + "buildOssDist": true, + "createArchives": true, + "createDebPackage": true, + "createDockerPackage": false, + "createDockerUbiPackage": false, + "createRpmPackage": false, + "downloadFreshNode": true, + "isRelease": false, + "targetAllPlatforms": true, + "versionQualifier": "", + }, + "log": , + "showHelp": false, + "unknownFlags": Array [], + } + `); }); it('limits packages if --docker passed with --all-platforms', () => { - expect(fn('--all-platforms', '--docker')).toMatchInlineSnapshot(` -Object { - "buildArgs": Object { - "buildDefaultDist": true, - "buildOssDist": true, - "createArchives": true, - "createDebPackage": false, - "createDockerPackage": true, - "createDockerUbiPackage": true, - "createRpmPackage": false, - "downloadFreshNode": true, - "isRelease": false, - "targetAllPlatforms": true, - "versionQualifier": "", - }, - "log": "", - "showHelp": false, - "unknownFlags": Array [], -} -`); + expect(readCliArgs(['node', 'scripts/build', '--all-platforms', '--docker'])) + .toMatchInlineSnapshot(` + Object { + "buildOptions": Object { + "buildDefaultDist": true, + "buildOssDist": true, + "createArchives": true, + "createDebPackage": false, + "createDockerPackage": true, + "createDockerUbiPackage": true, + "createRpmPackage": false, + "downloadFreshNode": true, + "isRelease": false, + "targetAllPlatforms": true, + "versionQualifier": "", + }, + "log": , + "showHelp": false, + "unknownFlags": Array [], + } + `); }); it('limits packages if --docker passed with --skip-docker-ubi and --all-platforms', () => { - expect(fn('--all-platforms', '--docker', '--skip-docker-ubi')).toMatchInlineSnapshot(` -Object { - "buildArgs": Object { - "buildDefaultDist": true, - "buildOssDist": true, - "createArchives": true, - "createDebPackage": false, - "createDockerPackage": true, - "createDockerUbiPackage": false, - "createRpmPackage": false, - "downloadFreshNode": true, - "isRelease": false, - "targetAllPlatforms": true, - "versionQualifier": "", - }, - "log": "", - "showHelp": false, - "unknownFlags": Array [], -} -`); + expect(readCliArgs(['node', 'scripts/build', '--all-platforms', '--docker', '--skip-docker-ubi'])) + .toMatchInlineSnapshot(` + Object { + "buildOptions": Object { + "buildDefaultDist": true, + "buildOssDist": true, + "createArchives": true, + "createDebPackage": false, + "createDockerPackage": true, + "createDockerUbiPackage": false, + "createRpmPackage": false, + "downloadFreshNode": true, + "isRelease": false, + "targetAllPlatforms": true, + "versionQualifier": "", + }, + "log": , + "showHelp": false, + "unknownFlags": Array [], + } + `); }); diff --git a/src/dev/build/args.ts b/src/dev/build/args.ts index 1ff42d524c59..8e77024a7e8a 100644 --- a/src/dev/build/args.ts +++ b/src/dev/build/args.ts @@ -20,16 +20,9 @@ import getopts from 'getopts'; import { ToolingLog, pickLevelFromFlags } from '@kbn/dev-utils'; -interface ParsedArgs { - showHelp: boolean; - unknownFlags: string[]; - log?: ToolingLog; - buildArgs?: { - [key: string]: any; - }; -} +import { BuildOptions } from './build_distributables'; -export function readCliArgs(argv: string[]): ParsedArgs { +export function readCliArgs(argv: string[]) { const unknownFlags: string[] = []; const flags = getopts(argv, { boolean: [ @@ -70,8 +63,16 @@ export function readCliArgs(argv: string[]): ParsedArgs { }, }); + const log = new ToolingLog({ + level: pickLevelFromFlags(flags, { + default: flags.debug === false ? 'info' : 'debug', + }), + writeTo: process.stdout, + }); + if (unknownFlags.length || flags.help) { return { + log, showHelp: true, unknownFlags, }; @@ -83,13 +84,6 @@ export function readCliArgs(argv: string[]): ParsedArgs { flags['all-platforms'] = true; } - const log = new ToolingLog({ - level: pickLevelFromFlags(flags, { - default: flags.debug === false ? 'info' : 'debug', - }), - writeTo: process.stdout, - }); - function isOsPackageDesired(name: string) { if (flags['skip-os-packages'] || !flags['all-platforms']) { return false; @@ -103,22 +97,24 @@ export function readCliArgs(argv: string[]): ParsedArgs { return Boolean(flags[name]); } + const buildOptions: BuildOptions = { + isRelease: Boolean(flags.release), + versionQualifier: flags['version-qualifier'], + buildOssDist: flags.oss !== false, + buildDefaultDist: !flags.oss, + downloadFreshNode: !Boolean(flags['skip-node-download']), + createArchives: !Boolean(flags['skip-archives']), + createRpmPackage: isOsPackageDesired('rpm'), + createDebPackage: isOsPackageDesired('deb'), + createDockerPackage: isOsPackageDesired('docker'), + createDockerUbiPackage: isOsPackageDesired('docker') && !Boolean(flags['skip-docker-ubi']), + targetAllPlatforms: Boolean(flags['all-platforms']), + }; + return { + log, showHelp: false, unknownFlags: [], - log, - buildArgs: { - isRelease: Boolean(flags.release), - versionQualifier: flags['version-qualifier'], - buildOssDist: flags.oss !== false, - buildDefaultDist: !flags.oss, - downloadFreshNode: !Boolean(flags['skip-node-download']), - createArchives: !Boolean(flags['skip-archives']), - createRpmPackage: isOsPackageDesired('rpm'), - createDebPackage: isOsPackageDesired('deb'), - createDockerPackage: isOsPackageDesired('docker'), - createDockerUbiPackage: isOsPackageDesired('docker') && !Boolean(flags['skip-docker-ubi']), - targetAllPlatforms: Boolean(flags['all-platforms']), - }, + buildOptions, }; } diff --git a/src/dev/build/build_distributables.js b/src/dev/build/build_distributables.js deleted file mode 100644 index 39a32fff891c..000000000000 --- a/src/dev/build/build_distributables.js +++ /dev/null @@ -1,174 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { getConfig, createRunner } from './lib'; - -import { - BuildKibanaPlatformPluginsTask, - BuildPackagesTask, - CleanEmptyFoldersTask, - CleanExtraBinScriptsTask, - CleanExtraFilesFromModulesTask, - CleanNodeBuildsTask, - CleanPackagesTask, - CleanTask, - CleanTypescriptTask, - CopyBinScriptsTask, - CopySourceTask, - CreateArchivesSourcesTask, - CreateArchivesTask, - CreateDebPackageTask, - CreateDockerPackageTask, - CreateDockerUbiPackageTask, - CreateEmptyDirsAndFilesTask, - CreateNoticeFileTask, - CreatePackageJsonTask, - CreateReadmeTask, - CreateRpmPackageTask, - DownloadNodeBuildsTask, - ExtractNodeBuildsTask, - InstallChromiumTask, - InstallDependenciesTask, - OptimizeBuildTask, - PatchNativeModulesTask, - PathLengthTask, - RemovePackageJsonDepsTask, - RemoveWorkspacesTask, - TranspileBabelTask, - TranspileScssTask, - UpdateLicenseFileTask, - UuidVerificationTask, - VerifyEnvTask, - VerifyExistingNodeBuildsTask, - WriteShaSumsTask, -} from './tasks'; - -export async function buildDistributables(options) { - const { - log, - isRelease, - buildOssDist, - buildDefaultDist, - downloadFreshNode, - createArchives, - createRpmPackage, - createDebPackage, - createDockerPackage, - createDockerUbiPackage, - versionQualifier, - targetAllPlatforms, - } = options; - - log.verbose('building distributables with options:', { - isRelease, - buildOssDist, - buildDefaultDist, - downloadFreshNode, - createArchives, - createRpmPackage, - createDebPackage, - versionQualifier, - }); - - const config = await getConfig({ - isRelease, - versionQualifier, - targetAllPlatforms, - }); - - const run = createRunner({ - config, - log, - buildOssDist, - buildDefaultDist, - }); - - /** - * verify, reset, and initialize the build environment - */ - await run(VerifyEnvTask); - await run(CleanTask); - await run(downloadFreshNode ? DownloadNodeBuildsTask : VerifyExistingNodeBuildsTask); - await run(ExtractNodeBuildsTask); - - /** - * run platform-generic build tasks - */ - await run(CopySourceTask); - await run(CopyBinScriptsTask); - await run(CreateEmptyDirsAndFilesTask); - await run(CreateReadmeTask); - await run(TranspileBabelTask); - await run(BuildPackagesTask); - await run(CreatePackageJsonTask); - await run(InstallDependenciesTask); - await run(RemoveWorkspacesTask); - await run(CleanPackagesTask); - await run(CreateNoticeFileTask); - await run(UpdateLicenseFileTask); - await run(RemovePackageJsonDepsTask); - await run(TranspileScssTask); - await run(BuildKibanaPlatformPluginsTask); - await run(OptimizeBuildTask); - await run(CleanTypescriptTask); - await run(CleanExtraFilesFromModulesTask); - await run(CleanEmptyFoldersTask); - - /** - * copy generic build outputs into platform-specific build - * directories and perform platform/architecture-specific steps - */ - await run(CreateArchivesSourcesTask); - await run(PatchNativeModulesTask); - await run(InstallChromiumTask); - await run(CleanExtraBinScriptsTask); - await run(CleanNodeBuildsTask); - - await run(PathLengthTask); - await run(UuidVerificationTask); - - /** - * package platform-specific builds into archives - * or os-specific packages in the target directory - */ - if (createArchives) { - // control w/ --skip-archives - await run(CreateArchivesTask); - } - if (createDebPackage) { - // control w/ --deb or --skip-os-packages - await run(CreateDebPackageTask); - } - if (createRpmPackage) { - // control w/ --rpm or --skip-os-packages - await run(CreateRpmPackageTask); - } - if (createDockerPackage) { - // control w/ --docker or --skip-docker-ubi or --skip-os-packages - await run(CreateDockerPackageTask); - if (createDockerUbiPackage) { - await run(CreateDockerUbiPackageTask); - } - } - - /** - * finalize artifacts by writing sha1sums of each into the target directory - */ - await run(WriteShaSumsTask); -} diff --git a/src/dev/build/build_distributables.ts b/src/dev/build/build_distributables.ts new file mode 100644 index 000000000000..bfcc98d6cd9a --- /dev/null +++ b/src/dev/build/build_distributables.ts @@ -0,0 +1,123 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ToolingLog } from '@kbn/dev-utils'; + +import { Config, createRunner } from './lib'; +import * as Tasks from './tasks'; + +export interface BuildOptions { + isRelease: boolean; + buildOssDist: boolean; + buildDefaultDist: boolean; + downloadFreshNode: boolean; + createArchives: boolean; + createRpmPackage: boolean; + createDebPackage: boolean; + createDockerPackage: boolean; + createDockerUbiPackage: boolean; + versionQualifier: string | undefined; + targetAllPlatforms: boolean; +} + +export async function buildDistributables(log: ToolingLog, options: BuildOptions) { + log.verbose('building distributables with options:', options); + + const config = await Config.create(options); + + const run = createRunner({ + config, + log, + buildDefaultDist: options.buildDefaultDist, + buildOssDist: options.buildOssDist, + }); + + /** + * verify, reset, and initialize the build environment + */ + await run(Tasks.VerifyEnv); + await run(Tasks.Clean); + await run(options.downloadFreshNode ? Tasks.DownloadNodeBuilds : Tasks.VerifyExistingNodeBuilds); + await run(Tasks.ExtractNodeBuilds); + + /** + * run platform-generic build tasks + */ + await run(Tasks.CopySource); + await run(Tasks.CopyBinScripts); + await run(Tasks.CreateEmptyDirsAndFiles); + await run(Tasks.CreateReadme); + await run(Tasks.TranspileBabel); + await run(Tasks.BuildPackages); + await run(Tasks.CreatePackageJson); + await run(Tasks.InstallDependencies); + await run(Tasks.RemoveWorkspaces); + await run(Tasks.CleanPackages); + await run(Tasks.CreateNoticeFile); + await run(Tasks.UpdateLicenseFile); + await run(Tasks.RemovePackageJsonDeps); + await run(Tasks.TranspileScss); + await run(Tasks.BuildKibanaPlatformPlugins); + await run(Tasks.OptimizeBuild); + await run(Tasks.CleanTypescript); + await run(Tasks.CleanExtraFilesFromModules); + await run(Tasks.CleanEmptyFolders); + + /** + * copy generic build outputs into platform-specific build + * directories and perform platform/architecture-specific steps + */ + await run(Tasks.CreateArchivesSources); + await run(Tasks.PatchNativeModules); + await run(Tasks.InstallChromium); + await run(Tasks.CleanExtraBinScripts); + await run(Tasks.CleanNodeBuilds); + + await run(Tasks.PathLength); + await run(Tasks.UuidVerification); + + /** + * package platform-specific builds into archives + * or os-specific packages in the target directory + */ + if (options.createArchives) { + // control w/ --skip-archives + await run(Tasks.CreateArchives); + } + if (options.createDebPackage) { + // control w/ --deb or --skip-os-packages + await run(Tasks.CreateDebPackage); + } + if (options.createRpmPackage) { + // control w/ --rpm or --skip-os-packages + await run(Tasks.CreateRpmPackage); + } + if (options.createDockerPackage) { + // control w/ --docker or --skip-docker-ubi or --skip-os-packages + await run(Tasks.CreateDockerPackage); + if (options.createDockerUbiPackage) { + await run(Tasks.CreateDockerUbiPackage); + } + } + + /** + * finalize artifacts by writing sha1sums of each into the target directory + */ + await run(Tasks.WriteShaSums); +} diff --git a/src/dev/build/cli.js b/src/dev/build/cli.ts similarity index 91% rename from src/dev/build/cli.js rename to src/dev/build/cli.ts index 9d23f92a3baf..5811fc42d200 100644 --- a/src/dev/build/cli.js +++ b/src/dev/build/cli.ts @@ -29,15 +29,15 @@ import { readCliArgs } from './args'; // ensure the cwd() is always the repo root process.chdir(resolve(__dirname, '../../../')); -const { showHelp, unknownFlags, log, buildArgs } = readCliArgs(process.argv); +const { showHelp, unknownFlags, log, buildOptions } = readCliArgs(process.argv); if (unknownFlags.length) { const pluralized = unknownFlags.length > 1 ? 'flags' : 'flag'; - console.log(chalk`\n{red Unknown ${pluralized}: ${unknownFlags.join(', ')}}\n`); + log.error(`Unknown ${pluralized}: ${unknownFlags.join(', ')}}`); } if (showHelp) { - console.log( + log.write( dedent(chalk` {dim usage:} node scripts/build @@ -63,7 +63,7 @@ if (showHelp) { process.exit(1); } -buildDistributables({ log, ...buildArgs }).catch((error) => { +buildDistributables(log, buildOptions!).catch((error) => { if (!isErrorLogged(error)) { log.error('Uncaught error'); log.error(error); diff --git a/src/dev/build/lib/__tests__/fixtures/bin/world_executable b/src/dev/build/lib/__fixtures__/bin/world_executable similarity index 100% rename from src/dev/build/lib/__tests__/fixtures/bin/world_executable rename to src/dev/build/lib/__fixtures__/bin/world_executable diff --git a/src/dev/build/lib/__tests__/fixtures/foo.txt.gz b/src/dev/build/lib/__fixtures__/foo.txt.gz similarity index 100% rename from src/dev/build/lib/__tests__/fixtures/foo.txt.gz rename to src/dev/build/lib/__fixtures__/foo.txt.gz diff --git a/src/dev/build/lib/__tests__/fixtures/foo_dir.tar.gz b/src/dev/build/lib/__fixtures__/foo_dir.tar.gz similarity index 100% rename from src/dev/build/lib/__tests__/fixtures/foo_dir.tar.gz rename to src/dev/build/lib/__fixtures__/foo_dir.tar.gz diff --git a/src/dev/build/lib/__tests__/fixtures/foo_dir/.bar b/src/dev/build/lib/__fixtures__/foo_dir/.bar similarity index 100% rename from src/dev/build/lib/__tests__/fixtures/foo_dir/.bar rename to src/dev/build/lib/__fixtures__/foo_dir/.bar diff --git a/src/dev/build/lib/__tests__/fixtures/foo_dir/bar.txt b/src/dev/build/lib/__fixtures__/foo_dir/bar.txt similarity index 100% rename from src/dev/build/lib/__tests__/fixtures/foo_dir/bar.txt rename to src/dev/build/lib/__fixtures__/foo_dir/bar.txt diff --git a/src/dev/build/lib/__tests__/fixtures/foo_dir/foo/foo.txt b/src/dev/build/lib/__fixtures__/foo_dir/foo/foo.txt similarity index 100% rename from src/dev/build/lib/__tests__/fixtures/foo_dir/foo/foo.txt rename to src/dev/build/lib/__fixtures__/foo_dir/foo/foo.txt diff --git a/src/legacy/utils/__tests__/fixtures/log_on_sigint.js b/src/dev/build/lib/__fixtures__/log_on_sigint.js similarity index 100% rename from src/legacy/utils/__tests__/fixtures/log_on_sigint.js rename to src/dev/build/lib/__fixtures__/log_on_sigint.js diff --git a/src/dev/build/lib/__tests__/build.js b/src/dev/build/lib/__tests__/build.js deleted file mode 100644 index af9479e73f3d..000000000000 --- a/src/dev/build/lib/__tests__/build.js +++ /dev/null @@ -1,168 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import expect from '@kbn/expect'; -import sinon from 'sinon'; - -import { createBuild } from '../build'; - -describe('dev/build/lib/build', () => { - describe('Build instance', () => { - describe('#isOss()', () => { - it('returns true if passed oss: true', () => { - const build = createBuild({ - oss: true, - }); - - expect(build.isOss()).to.be(true); - }); - - it('returns false if passed oss: false', () => { - const build = createBuild({ - oss: false, - }); - - expect(build.isOss()).to.be(false); - }); - }); - - describe('#getName()', () => { - it('returns kibana when oss: false', () => { - const build = createBuild({ - oss: false, - }); - - expect(build.getName()).to.be('kibana'); - }); - it('returns kibana-oss when oss: true', () => { - const build = createBuild({ - oss: true, - }); - - expect(build.getName()).to.be('kibana-oss'); - }); - }); - - describe('#getLogTag()', () => { - it('returns string with build name in it', () => { - const build = createBuild({}); - - expect(build.getLogTag()).to.contain(build.getName()); - }); - }); - - describe('#resolvePath()', () => { - it('uses passed config to resolve a path relative to the build', () => { - const resolveFromRepo = sinon.stub(); - const build = createBuild({ - config: { resolveFromRepo }, - }); - - build.resolvePath('bar'); - sinon.assert.calledWithExactly(resolveFromRepo, 'build', 'kibana', 'bar'); - }); - - it('passes all arguments to config.resolveFromRepo()', () => { - const resolveFromRepo = sinon.stub(); - const build = createBuild({ - config: { resolveFromRepo }, - }); - - build.resolvePath('bar', 'baz', 'box'); - sinon.assert.calledWithExactly(resolveFromRepo, 'build', 'kibana', 'bar', 'baz', 'box'); - }); - }); - - describe('#resolvePathForPlatform()', () => { - it('uses config.resolveFromRepo(), config.getBuildVersion(), and platform.getBuildName() to create path', () => { - const resolveFromRepo = sinon.stub(); - const getBuildVersion = sinon.stub().returns('buildVersion'); - const build = createBuild({ - oss: true, - config: { resolveFromRepo, getBuildVersion }, - }); - - const getBuildName = sinon.stub().returns('platformName'); - const platform = { - getBuildName, - }; - - build.resolvePathForPlatform(platform, 'foo', 'bar'); - sinon.assert.calledWithExactly(getBuildName); - sinon.assert.calledWithExactly(getBuildVersion); - sinon.assert.calledWithExactly( - resolveFromRepo, - 'build', - 'oss', - `kibana-buildVersion-platformName`, - 'foo', - 'bar' - ); - }); - }); - - describe('#getPlatformArchivePath()', () => { - const sandbox = sinon.createSandbox(); - - const config = { - resolveFromRepo: sandbox.stub(), - getBuildVersion: sandbox.stub().returns('buildVersion'), - }; - - const build = createBuild({ - oss: false, - config, - }); - - const platform = { - getBuildName: sandbox.stub().returns('platformName'), - isWindows: sandbox.stub().returns(false), - }; - - beforeEach(() => { - sandbox.resetHistory(); - }); - - it('uses config.resolveFromRepo(), config.getBuildVersion, and platform.getBuildName() to create path', () => { - build.getPlatformArchivePath(platform); - sinon.assert.calledWithExactly(platform.getBuildName); - sinon.assert.calledWithExactly(platform.isWindows); - sinon.assert.calledWithExactly(config.getBuildVersion); - sinon.assert.calledWithExactly( - config.resolveFromRepo, - 'target', - `kibana-buildVersion-platformName.tar.gz` - ); - }); - - it('creates .zip path if platform is windows', () => { - platform.isWindows.returns(true); - build.getPlatformArchivePath(platform); - sinon.assert.calledWithExactly(platform.getBuildName); - sinon.assert.calledWithExactly(platform.isWindows); - sinon.assert.calledWithExactly(config.getBuildVersion); - sinon.assert.calledWithExactly( - config.resolveFromRepo, - 'target', - `kibana-buildVersion-platformName.zip` - ); - }); - }); - }); -}); diff --git a/src/dev/build/lib/__tests__/config.js b/src/dev/build/lib/__tests__/config.js deleted file mode 100644 index 9544fc84dc6f..000000000000 --- a/src/dev/build/lib/__tests__/config.js +++ /dev/null @@ -1,174 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { resolve } from 'path'; - -import expect from '@kbn/expect'; - -import pkg from '../../../../../package.json'; -import { getConfig } from '../config'; -import { getVersionInfo } from '../version_info'; - -describe('dev/build/lib/config', () => { - const setup = async function ({ targetAllPlatforms = true } = {}) { - const isRelease = Boolean(Math.round(Math.random())); - const config = await getConfig({ - isRelease, - targetAllPlatforms, - }); - const buildInfo = await getVersionInfo({ - isRelease, - pkg, - }); - return { config, buildInfo }; - }; - - describe('#getKibanaPkg()', () => { - it('returns the parsed package.json from the Kibana repo', async () => { - const { config } = await setup(); - expect(config.getKibanaPkg()).to.eql(pkg); - }); - }); - - describe('#getNodeVersion()', () => { - it('returns the node version from the kibana package.json', async () => { - const { config } = await setup(); - expect(config.getNodeVersion()).to.eql(pkg.engines.node); - }); - }); - - describe('#getRepoRelativePath()', () => { - it('converts an absolute path to relative path, from the root of the repo', async () => { - const { config } = await setup(); - expect(config.getRepoRelativePath(__dirname)).to.match(/^src[\/\\]dev[\/\\]build/); - }); - }); - - describe('#resolveFromRepo()', () => { - it('resolves a relative path', async () => { - const { config } = await setup(); - expect(config.resolveFromRepo('src/dev/build/lib/__tests__')).to.be(__dirname); - }); - - it('resolves a series of relative paths', async () => { - const { config } = await setup(); - expect(config.resolveFromRepo('src', 'dev', 'build', 'lib', '__tests__')).to.be(__dirname); - }); - }); - - describe('#getPlatform()', () => { - it('throws error when platform does not exist', async () => { - const { config } = await setup(); - const fn = () => config.getPlatform('foo', 'x64'); - - expect(fn).to.throwException(/Unable to find platform/); - }); - - it('throws error when architecture does not exist', async () => { - const { config } = await setup(); - const fn = () => config.getPlatform('linux', 'foo'); - - expect(fn).to.throwException(/Unable to find platform/); - }); - }); - - describe('#getTargetPlatforms()', () => { - it('returns an array of all platform objects', async () => { - const { config } = await setup(); - expect( - config - .getTargetPlatforms() - .map((p) => p.getNodeArch()) - .sort() - ).to.eql(['darwin-x64', 'linux-arm64', 'linux-x64', 'win32-x64']); - }); - - it('returns just this platform when targetAllPlatforms = false', async () => { - const { config } = await setup({ targetAllPlatforms: false }); - const platforms = config.getTargetPlatforms(); - - expect(platforms).to.be.an('array'); - expect(platforms).to.have.length(1); - expect(platforms[0]).to.be(config.getPlatformForThisOs()); - }); - }); - - describe('#getNodePlatforms()', () => { - it('returns all platforms', async () => { - const { config } = await setup(); - expect( - config - .getTargetPlatforms() - .map((p) => p.getNodeArch()) - .sort() - ).to.eql(['darwin-x64', 'linux-arm64', 'linux-x64', 'win32-x64']); - }); - - it('returns this platform and linux, when targetAllPlatforms = false', async () => { - const { config } = await setup({ targetAllPlatforms: false }); - const platforms = config.getNodePlatforms(); - expect(platforms).to.be.an('array'); - if (process.platform !== 'linux') { - expect(platforms).to.have.length(2); - expect(platforms[0]).to.be(config.getPlatformForThisOs()); - expect(platforms[1]).to.be(config.getPlatform('linux', 'x64')); - } else { - expect(platforms).to.have.length(1); - expect(platforms[0]).to.be(config.getPlatform('linux', 'x64')); - } - }); - }); - - describe('#getPlatformForThisOs()', () => { - it('returns the platform that matches the arch of this machine', async () => { - const { config } = await setup(); - const currentPlatform = config.getPlatformForThisOs(); - expect(currentPlatform.getName()).to.be(process.platform); - expect(currentPlatform.getArchitecture()).to.be(process.arch); - }); - }); - - describe('#getBuildVersion()', () => { - it('returns the version from the build info', async () => { - const { config, buildInfo } = await setup(); - expect(config.getBuildVersion()).to.be(buildInfo.buildVersion); - }); - }); - - describe('#getBuildNumber()', () => { - it('returns the number from the build info', async () => { - const { config, buildInfo } = await setup(); - expect(config.getBuildNumber()).to.be(buildInfo.buildNumber); - }); - }); - - describe('#getBuildSha()', () => { - it('returns the sha from the build info', async () => { - const { config, buildInfo } = await setup(); - expect(config.getBuildSha()).to.be(buildInfo.buildSha); - }); - }); - - describe('#resolveFromTarget()', () => { - it('resolves a relative path, from the target directory', async () => { - const { config } = await setup(); - expect(config.resolveFromTarget()).to.be(resolve(__dirname, '../../../../../target')); - }); - }); -}); diff --git a/src/dev/build/lib/__tests__/download.js b/src/dev/build/lib/__tests__/download.js deleted file mode 100644 index 49cb9caaaf4e..000000000000 --- a/src/dev/build/lib/__tests__/download.js +++ /dev/null @@ -1,237 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { createServer } from 'http'; -import { join } from 'path'; -import { tmpdir } from 'os'; -import { mkdirp, readFileSync } from 'fs-extra'; - -import del from 'del'; -import sinon from 'sinon'; -import { CI_PARALLEL_PROCESS_PREFIX } from '@kbn/test'; -import expect from '@kbn/expect'; -import Wreck from '@hapi/wreck'; - -import { ToolingLog } from '@kbn/dev-utils'; -import { download } from '../download'; - -const getTempFolder = async () => { - const dir = join(tmpdir(), CI_PARALLEL_PROCESS_PREFIX, 'download-js-test-tmp-dir'); - console.log(dir); - await mkdirp(dir); - return dir; -}; - -describe('src/dev/build/tasks/nodejs/download', () => { - const sandbox = sinon.createSandbox(); - let TMP_DESTINATION; - let TMP_DIR; - - beforeEach(async () => { - TMP_DIR = await getTempFolder(); - TMP_DESTINATION = join(TMP_DIR, '__tmp_download_js_test_file__'); - }); - - afterEach(async () => { - await del(TMP_DIR, { force: true }); - }); - afterEach(() => sandbox.reset()); - - const onLogLine = sandbox.stub(); - const log = new ToolingLog({ - level: 'verbose', - writeTo: { - write: onLogLine, - }, - }); - - const FOO_SHA256 = '2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae'; - const createSendHandler = (send) => (req, res) => { - res.statusCode = 200; - res.end(send); - }; - const sendErrorHandler = (req, res) => { - res.statusCode = 500; - res.end(); - }; - - let server; - let serverUrl; - let nextHandler; - afterEach(() => (nextHandler = null)); - - before(async () => { - server = createServer((req, res) => { - if (!nextHandler) { - nextHandler = sendErrorHandler; - } - - const handler = nextHandler; - nextHandler = null; - handler(req, res); - }); - - await Promise.race([ - new Promise((resolve, reject) => { - server.once('error', reject); - }), - new Promise((resolve) => { - server.listen(resolve); - }), - ]); - - serverUrl = `http://localhost:${server.address().port}/`; - }); - - after(async () => { - server.close(); - server = null; - }); - - it('downloads from URL and checks that content matches sha256', async () => { - nextHandler = createSendHandler('foo'); - await download({ - log, - url: serverUrl, - destination: TMP_DESTINATION, - sha256: FOO_SHA256, - }); - expect(readFileSync(TMP_DESTINATION, 'utf8')).to.be('foo'); - }); - - it('rejects and deletes destination if sha256 does not match', async () => { - nextHandler = createSendHandler('foo'); - - try { - await download({ - log, - url: serverUrl, - destination: TMP_DESTINATION, - sha256: 'bar', - }); - throw new Error('Expected download() to reject'); - } catch (error) { - expect(error) - .to.have.property('message') - .contain('does not match the expected sha256 checksum'); - } - - try { - readFileSync(TMP_DESTINATION); - throw new Error('Expected download to be deleted'); - } catch (error) { - expect(error).to.have.property('code', 'ENOENT'); - } - }); - - describe('reties download retries: number of times', () => { - it('resolves if retries = 1 and first attempt fails', async () => { - let reqCount = 0; - nextHandler = function sequenceHandler(req, res) { - switch (++reqCount) { - case 1: - nextHandler = sequenceHandler; - return sendErrorHandler(req, res); - default: - return createSendHandler('foo')(req, res); - } - }; - - await download({ - log, - url: serverUrl, - destination: TMP_DESTINATION, - sha256: FOO_SHA256, - retries: 2, - }); - - expect(readFileSync(TMP_DESTINATION, 'utf8')).to.be('foo'); - }); - - it('resolves if first fails, second is bad shasum, but third succeeds', async () => { - let reqCount = 0; - nextHandler = function sequenceHandler(req, res) { - switch (++reqCount) { - case 1: - nextHandler = sequenceHandler; - return sendErrorHandler(req, res); - case 2: - nextHandler = sequenceHandler; - return createSendHandler('bar')(req, res); - default: - return createSendHandler('foo')(req, res); - } - }; - - await download({ - log, - url: serverUrl, - destination: TMP_DESTINATION, - sha256: FOO_SHA256, - retries: 2, - }); - }); - - it('makes 6 requests if `retries: 5` and all failed', async () => { - let reqCount = 0; - nextHandler = function sequenceHandler(req, res) { - reqCount += 1; - nextHandler = sequenceHandler; - sendErrorHandler(req, res); - }; - - try { - await download({ - log, - url: serverUrl, - destination: TMP_DESTINATION, - sha256: FOO_SHA256, - retries: 5, - }); - throw new Error('Expected download() to reject'); - } catch (error) { - expect(error).to.have.property('message').contain('Request failed with status code 500'); - expect(reqCount).to.be(6); - } - }); - }); - - describe('sha256 option not supplied', () => { - before(() => { - sinon.stub(Wreck, 'request'); - }); - after(() => { - Wreck.request.restore(); - }); - - it('refuses to download', async () => { - try { - await download({ - log, - url: 'http://google.com', - destination: TMP_DESTINATION, - }); - - throw new Error('expected download() to reject'); - } catch (error) { - expect(error).to.have.property('message').contain('refusing to download'); - } - }); - }); -}); diff --git a/src/dev/build/lib/__tests__/exec.js b/src/dev/build/lib/__tests__/exec.js deleted file mode 100644 index 8e122c65132a..000000000000 --- a/src/dev/build/lib/__tests__/exec.js +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import sinon from 'sinon'; -import stripAnsi from 'strip-ansi'; - -import { ToolingLog } from '@kbn/dev-utils'; -import { exec } from '../exec'; - -describe('dev/build/lib/exec', () => { - const sandbox = sinon.createSandbox(); - afterEach(() => sandbox.reset()); - - const onLogLine = sandbox.stub(); - const log = new ToolingLog({ - level: 'verbose', - writeTo: { - write: (chunk) => { - onLogLine(stripAnsi(chunk)); - }, - }, - }); - - it('executes a command, logs the command, and logs the output', async () => { - await exec(log, process.execPath, ['-e', 'console.log("hi")']); - - // logs the command before execution - sinon.assert.calledWithExactly(onLogLine, sinon.match(`$ ${process.execPath}`)); - - // log output of the process - sinon.assert.calledWithExactly(onLogLine, sinon.match(/debg\s+hi/)); - }); - - it('logs using level: option', async () => { - await exec(log, process.execPath, ['-e', 'console.log("hi")'], { - level: 'info', - }); - - // log output of the process - sinon.assert.calledWithExactly(onLogLine, sinon.match(/info\s+hi/)); - }); -}); diff --git a/src/dev/build/lib/__tests__/fs.js b/src/dev/build/lib/__tests__/fs.js deleted file mode 100644 index bf7596b012f7..000000000000 --- a/src/dev/build/lib/__tests__/fs.js +++ /dev/null @@ -1,362 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { resolve } from 'path'; -import { chmodSync, statSync } from 'fs'; - -import del from 'del'; -import expect from '@kbn/expect'; - -import { mkdirp, write, read, getChildPaths, copyAll, getFileHash, untar, gunzip } from '../fs'; - -const TMP = resolve(__dirname, '__tmp__'); -const FIXTURES = resolve(__dirname, 'fixtures'); -const FOO_TAR_PATH = resolve(FIXTURES, 'foo_dir.tar.gz'); -const FOO_GZIP_PATH = resolve(FIXTURES, 'foo.txt.gz'); -const BAR_TXT_PATH = resolve(FIXTURES, 'foo_dir/bar.txt'); -const WORLD_EXECUTABLE = resolve(FIXTURES, 'bin/world_executable'); - -const isWindows = /^win/.test(process.platform); - -// get the mode of a file as a string, like 777, or 644, -function getCommonMode(path) { - return statSync(path).mode.toString(8).slice(-3); -} - -function assertNonAbsoluteError(error) { - expect(error).to.be.an(Error); - expect(error.message).to.contain('Please use absolute paths'); -} - -describe('dev/build/lib/fs', () => { - // ensure WORLD_EXECUTABLE is actually executable by all - before(async () => { - chmodSync(WORLD_EXECUTABLE, 0o777); - }); - - // clean and recreate TMP directory - beforeEach(async () => { - await del(TMP); - await mkdirp(TMP); - }); - - // cleanup TMP directory - after(async () => { - await del(TMP); - }); - - describe('mkdirp()', () => { - it('rejects if path is not absolute', async () => { - try { - await mkdirp('foo/bar'); - throw new Error('Expected mkdirp() to reject'); - } catch (error) { - assertNonAbsoluteError(error); - } - }); - - it('makes directory and necessary parent directories', async () => { - const destination = resolve(TMP, 'a/b/c/d/e/f/g'); - - expect(await mkdirp(destination)).to.be(undefined); - - expect(statSync(destination).isDirectory()).to.be(true); - }); - }); - - describe('write()', () => { - it('rejects if path is not absolute', async () => { - try { - await write('foo/bar'); - throw new Error('Expected write() to reject'); - } catch (error) { - assertNonAbsoluteError(error); - } - }); - - it('writes content to a file with existing parent directory', async () => { - const destination = resolve(TMP, 'a'); - - expect(await write(destination, 'bar')).to.be(undefined); - expect(await read(destination)).to.be('bar'); - }); - - it('writes content to a file with missing parents', async () => { - const destination = resolve(TMP, 'a/b/c/d/e'); - - expect(await write(destination, 'bar')).to.be(undefined); - expect(await read(destination)).to.be('bar'); - }); - }); - - describe('read()', () => { - it('rejects if path is not absolute', async () => { - try { - await read('foo/bar'); - throw new Error('Expected read() to reject'); - } catch (error) { - assertNonAbsoluteError(error); - } - }); - - it('reads file, resolves with result', async () => { - expect(await read(BAR_TXT_PATH)).to.be('bar\n'); - }); - }); - - describe('getChildPaths()', () => { - it('rejects if path is not absolute', async () => { - try { - await getChildPaths('foo/bar'); - throw new Error('Expected getChildPaths() to reject'); - } catch (error) { - assertNonAbsoluteError(error); - } - }); - - it('resolves with absolute paths to the children of directory', async () => { - const path = resolve(FIXTURES, 'foo_dir'); - expect((await getChildPaths(path)).sort()).to.eql([ - resolve(FIXTURES, 'foo_dir/.bar'), - BAR_TXT_PATH, - resolve(FIXTURES, 'foo_dir/foo'), - ]); - }); - - it('rejects with ENOENT if path does not exist', async () => { - try { - await getChildPaths(resolve(FIXTURES, 'notrealpath')); - throw new Error('Expected getChildPaths() to reject'); - } catch (error) { - expect(error).to.have.property('code', 'ENOENT'); - } - }); - }); - - describe('copyAll()', () => { - it('rejects if source path is not absolute', async () => { - try { - await copyAll('foo/bar', __dirname); - throw new Error('Expected copyAll() to reject'); - } catch (error) { - assertNonAbsoluteError(error); - } - }); - - it('rejects if destination path is not absolute', async () => { - try { - await copyAll(__dirname, 'foo/bar'); - throw new Error('Expected copyAll() to reject'); - } catch (error) { - assertNonAbsoluteError(error); - } - }); - - it('rejects if neither path is not absolute', async () => { - try { - await copyAll('foo/bar', 'foo/bar'); - throw new Error('Expected copyAll() to reject'); - } catch (error) { - assertNonAbsoluteError(error); - } - }); - - it('copies files and directories from source to dest, creating dest if necessary, respecting mode', async () => { - const destination = resolve(TMP, 'a/b/c'); - await copyAll(FIXTURES, destination); - - expect((await getChildPaths(resolve(destination, 'foo_dir'))).sort()).to.eql([ - resolve(destination, 'foo_dir/bar.txt'), - resolve(destination, 'foo_dir/foo'), - ]); - - expect(getCommonMode(resolve(destination, 'bin/world_executable'))).to.be( - isWindows ? '666' : '777' - ); - expect(getCommonMode(resolve(destination, 'foo_dir/bar.txt'))).to.be( - isWindows ? '666' : '644' - ); - }); - - it('applies select globs if specified, ignores dot files', async () => { - const destination = resolve(TMP, 'a/b/c/d'); - await copyAll(FIXTURES, destination, { - select: ['**/*bar*'], - }); - - try { - statSync(resolve(destination, 'bin/world_executable')); - throw new Error('expected bin/world_executable to not by copied'); - } catch (error) { - expect(error).to.have.property('code', 'ENOENT'); - } - - try { - statSync(resolve(destination, 'foo_dir/.bar')); - throw new Error('expected foo_dir/.bar to not by copied'); - } catch (error) { - expect(error).to.have.property('code', 'ENOENT'); - } - - expect(await read(resolve(destination, 'foo_dir/bar.txt'))).to.be('bar\n'); - }); - - it('supports select globs and dot option together', async () => { - const destination = resolve(TMP, 'a/b/c/d'); - await copyAll(FIXTURES, destination, { - select: ['**/*bar*'], - dot: true, - }); - - try { - statSync(resolve(destination, 'bin/world_executable')); - throw new Error('expected bin/world_executable to not by copied'); - } catch (error) { - expect(error).to.have.property('code', 'ENOENT'); - } - - expect(await read(resolve(destination, 'foo_dir/bar.txt'))).to.be('bar\n'); - expect(await read(resolve(destination, 'foo_dir/.bar'))).to.be('dotfile\n'); - }); - - it('supports atime and mtime', async () => { - const destination = resolve(TMP, 'a/b/c/d/e'); - const time = new Date(1425298511000); - await copyAll(FIXTURES, destination, { - time, - }); - const barTxt = statSync(resolve(destination, 'foo_dir/bar.txt')); - const fooDir = statSync(resolve(destination, 'foo_dir')); - - // precision is platform specific - const oneDay = 86400000; - expect(Math.abs(barTxt.atimeMs - time.getTime())).to.be.below(oneDay); - expect(Math.abs(fooDir.atimeMs - time.getTime())).to.be.below(oneDay); - expect(Math.abs(barTxt.mtimeMs - time.getTime())).to.be.below(oneDay); - }); - }); - - describe('getFileHash()', () => { - it('rejects if path is not absolute', async () => { - try { - await getFileHash('foo/bar'); - throw new Error('Expected getFileHash() to reject'); - } catch (error) { - assertNonAbsoluteError(error); - } - }); - - it('resolves with the sha1 hash of a file', async () => { - expect(await getFileHash(BAR_TXT_PATH, 'sha1')).to.be( - 'e242ed3bffccdf271b7fbaf34ed72d089537b42f' - ); - }); - it('resolves with the sha256 hash of a file', async () => { - expect(await getFileHash(BAR_TXT_PATH, 'sha256')).to.be( - '7d865e959b2466918c9863afca942d0fb89d7c9ac0c99bafc3749504ded97730' - ); - }); - it('resolves with the md5 hash of a file', async () => { - expect(await getFileHash(BAR_TXT_PATH, 'md5')).to.be('c157a79031e1c40f85931829bc5fc552'); - }); - }); - - describe('untar()', () => { - it('rejects if source path is not absolute', async () => { - try { - await untar('foo/bar', '**/*', __dirname); - throw new Error('Expected untar() to reject'); - } catch (error) { - assertNonAbsoluteError(error); - } - }); - - it('rejects if destination path is not absolute', async () => { - try { - await untar(__dirname, '**/*', 'foo/bar'); - throw new Error('Expected untar() to reject'); - } catch (error) { - assertNonAbsoluteError(error); - } - }); - - it('rejects if neither path is not absolute', async () => { - try { - await untar('foo/bar', '**/*', 'foo/bar'); - throw new Error('Expected untar() to reject'); - } catch (error) { - assertNonAbsoluteError(error); - } - }); - - it('extracts tarbar from source into destination, creating destination if necessary', async () => { - const destination = resolve(TMP, 'a/b/c/d/e/f'); - await untar(FOO_TAR_PATH, destination); - expect(await read(resolve(destination, 'foo_dir/bar.txt'))).to.be('bar\n'); - expect(await read(resolve(destination, 'foo_dir/foo/foo.txt'))).to.be('foo\n'); - }); - - it('passed thrid argument to Extract class, overriding path with destination', async () => { - const destination = resolve(TMP, 'a/b/c'); - - await untar(FOO_TAR_PATH, destination, { - path: '/dev/null', - strip: 1, - }); - - expect(await read(resolve(destination, 'bar.txt'))).to.be('bar\n'); - expect(await read(resolve(destination, 'foo/foo.txt'))).to.be('foo\n'); - }); - }); - - describe('gunzip()', () => { - it('rejects if source path is not absolute', async () => { - try { - await gunzip('foo/bar', '**/*', __dirname); - throw new Error('Expected gunzip() to reject'); - } catch (error) { - assertNonAbsoluteError(error); - } - }); - - it('rejects if destination path is not absolute', async () => { - try { - await gunzip(__dirname, '**/*', 'foo/bar'); - throw new Error('Expected gunzip() to reject'); - } catch (error) { - assertNonAbsoluteError(error); - } - }); - - it('rejects if neither path is not absolute', async () => { - try { - await gunzip('foo/bar', '**/*', 'foo/bar'); - throw new Error('Expected gunzip() to reject'); - } catch (error) { - assertNonAbsoluteError(error); - } - }); - - it('extracts gzip from source into destination, creating destination if necessary', async () => { - const destination = resolve(TMP, 'z/y/x/v/u/t/foo.txt'); - await gunzip(FOO_GZIP_PATH, destination); - expect(await read(resolve(destination))).to.be('foo\n'); - }); - }); -}); diff --git a/src/dev/build/lib/__tests__/platform.js b/src/dev/build/lib/__tests__/platform.js deleted file mode 100644 index a7bb5670ee41..000000000000 --- a/src/dev/build/lib/__tests__/platform.js +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import expect from '@kbn/expect'; - -import { createPlatform } from '../platform'; - -describe('src/dev/build/lib/platform', () => { - describe('getName()', () => { - it('returns the name argument', () => { - expect(createPlatform('foo').getName()).to.be('foo'); - }); - }); - - describe('getNodeArch()', () => { - it('returns the node arch for the passed name', () => { - expect(createPlatform('win32', 'x64').getNodeArch()).to.be('win32-x64'); - }); - }); - - describe('getBuildName()', () => { - it('returns the build name for the passed name', () => { - expect(createPlatform('linux', 'arm64', 'linux-aarch64').getBuildName()).to.be( - 'linux-aarch64' - ); - }); - }); - - describe('isWindows()', () => { - it('returns true if name is win32', () => { - expect(createPlatform('win32', 'x64').isWindows()).to.be(true); - expect(createPlatform('linux', 'x64').isWindows()).to.be(false); - expect(createPlatform('darwin', 'x64').isWindows()).to.be(false); - }); - }); - - describe('isLinux()', () => { - it('returns true if name is linux', () => { - expect(createPlatform('win32', 'x64').isLinux()).to.be(false); - expect(createPlatform('linux', 'x64').isLinux()).to.be(true); - expect(createPlatform('darwin', 'x64').isLinux()).to.be(false); - }); - }); - - describe('isMac()', () => { - it('returns true if name is darwin', () => { - expect(createPlatform('win32', 'x64').isMac()).to.be(false); - expect(createPlatform('linux', 'x64').isMac()).to.be(false); - expect(createPlatform('darwin', 'x64').isMac()).to.be(true); - }); - }); -}); diff --git a/src/dev/build/lib/__tests__/runner.js b/src/dev/build/lib/__tests__/runner.js deleted file mode 100644 index 314c2dd45d50..000000000000 --- a/src/dev/build/lib/__tests__/runner.js +++ /dev/null @@ -1,184 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import sinon from 'sinon'; -import expect from '@kbn/expect'; - -import { ToolingLog } from '@kbn/dev-utils'; -import { createRunner } from '../runner'; -import { isErrorLogged, markErrorLogged } from '../errors'; - -describe('dev/build/lib/runner', () => { - const sandbox = sinon.createSandbox(); - - const config = {}; - - const onLogLine = sandbox.stub(); - const log = new ToolingLog({ - level: 'verbose', - writeTo: { - write: onLogLine, - }, - }); - - const buildMatcher = sinon.match({ - isOss: sinon.match.func, - resolvePath: sinon.match.func, - resolvePathForPlatform: sinon.match.func, - getPlatformArchivePath: sinon.match.func, - getName: sinon.match.func, - getLogTag: sinon.match.func, - }); - - const ossBuildMatcher = buildMatcher.and(sinon.match((b) => b.isOss(), 'is oss build')); - const defaultBuildMatcher = buildMatcher.and(sinon.match((b) => !b.isOss(), 'is not oss build')); - - afterEach(() => sandbox.reset()); - - describe('defaults', () => { - const run = createRunner({ - config, - log, - }); - - it('returns a promise', () => { - expect(run({ run: sinon.stub() })).to.be.a(Promise); - }); - - it('runs global task once, passing config and log', async () => { - const runTask = sinon.stub(); - await run({ global: true, run: runTask }); - sinon.assert.calledOnce(runTask); - sinon.assert.calledWithExactly(runTask, config, log, sinon.match.array); - }); - - it('does not call local tasks', async () => { - const runTask = sinon.stub(); - await run({ run: runTask }); - sinon.assert.notCalled(runTask); - }); - }); - - describe('buildOssDist = true, buildDefaultDist = true', () => { - const run = createRunner({ - config, - log, - buildOssDist: true, - buildDefaultDist: true, - }); - - it('runs global task once, passing config and log', async () => { - const runTask = sinon.stub(); - await run({ global: true, run: runTask }); - sinon.assert.calledOnce(runTask); - sinon.assert.calledWithExactly(runTask, config, log, sinon.match.array); - }); - - it('runs local tasks twice, passing config log and both builds', async () => { - const runTask = sinon.stub(); - await run({ run: runTask }); - sinon.assert.calledTwice(runTask); - sinon.assert.calledWithExactly(runTask, config, log, ossBuildMatcher); - sinon.assert.calledWithExactly(runTask, config, log, defaultBuildMatcher); - }); - }); - - describe('just default dist', () => { - const run = createRunner({ - config, - log, - buildDefaultDist: true, - }); - - it('runs global task once, passing config and log', async () => { - const runTask = sinon.stub(); - await run({ global: true, run: runTask }); - sinon.assert.calledOnce(runTask); - sinon.assert.calledWithExactly(runTask, config, log, sinon.match.array); - }); - - it('runs local tasks once, passing config log and default build', async () => { - const runTask = sinon.stub(); - await run({ run: runTask }); - sinon.assert.calledOnce(runTask); - sinon.assert.calledWithExactly(runTask, config, log, defaultBuildMatcher); - }); - }); - - describe('just oss dist', () => { - const run = createRunner({ - config, - log, - buildOssDist: true, - }); - - it('runs global task once, passing config and log', async () => { - const runTask = sinon.stub(); - await run({ global: true, run: runTask }); - sinon.assert.calledOnce(runTask); - sinon.assert.calledWithExactly(runTask, config, log, sinon.match.array); - }); - - it('runs local tasks once, passing config log and oss build', async () => { - const runTask = sinon.stub(); - await run({ run: runTask }); - sinon.assert.calledOnce(runTask); - sinon.assert.calledWithExactly(runTask, config, log, ossBuildMatcher); - }); - }); - - describe('task rejects', () => { - const run = createRunner({ - config, - log, - buildOssDist: true, - }); - - it('rejects, logs error, and marks error logged', async () => { - try { - await run({ - async run() { - throw new Error('FOO'); - }, - }); - throw new Error('expected run() to reject'); - } catch (error) { - expect(error).to.have.property('message').be('FOO'); - sinon.assert.calledWith(onLogLine, sinon.match(/FOO/)); - expect(isErrorLogged(error)).to.be(true); - } - }); - - it('just rethrows errors that have already been logged', async () => { - try { - await run({ - async run() { - throw markErrorLogged(new Error('FOO')); - }, - }); - - throw new Error('expected run() to reject'); - } catch (error) { - expect(error).to.have.property('message').be('FOO'); - sinon.assert.neverCalledWith(onLogLine, sinon.match(/FOO/)); - expect(isErrorLogged(error)).to.be(true); - } - }); - }); -}); diff --git a/src/dev/build/lib/__tests__/version_info.js b/src/dev/build/lib/__tests__/version_info.js deleted file mode 100644 index a7329642e4f9..000000000000 --- a/src/dev/build/lib/__tests__/version_info.js +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import expect from '@kbn/expect'; - -import pkg from '../../../../../package.json'; -import { getVersionInfo } from '../version_info'; - -describe('dev/build/lib/version_info', () => { - describe('isRelease = true', () => { - it('returns unchanged package.version, build sha, and build number', async () => { - const versionInfo = await getVersionInfo({ - isRelease: true, - pkg, - }); - - expect(versionInfo).to.have.property('buildVersion', pkg.version); - expect(versionInfo) - .to.have.property('buildSha') - .match(/^[0-9a-f]{40}$/); - expect(versionInfo).to.have.property('buildNumber').a('number').greaterThan(1000); - }); - }); - describe('isRelease = false', () => { - it('returns snapshot version, build sha, and build number', async () => { - const versionInfo = await getVersionInfo({ - isRelease: false, - pkg, - }); - - expect(versionInfo) - .to.have.property('buildVersion') - .contain(pkg.version) - .match(/-SNAPSHOT$/); - expect(versionInfo) - .to.have.property('buildSha') - .match(/^[0-9a-f]{40}$/); - expect(versionInfo).to.have.property('buildNumber').a('number').greaterThan(1000); - }); - }); - - describe('versionQualifier', () => { - it('appends a version qualifier', async () => { - const versionInfo = await getVersionInfo({ - isRelease: true, - versionQualifier: 'beta55', - pkg, - }); - expect(versionInfo) - .to.have.property('buildVersion') - .be(pkg.version + '-beta55'); - }); - }); -}); diff --git a/src/dev/build/lib/build.js b/src/dev/build/lib/build.js deleted file mode 100644 index fe5111ad1377..000000000000 --- a/src/dev/build/lib/build.js +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import chalk from 'chalk'; - -export function createBuild({ config, oss }) { - const name = oss ? 'kibana-oss' : 'kibana'; - const logTag = oss ? chalk`{magenta [kibana-oss]}` : chalk`{cyan [ kibana ]}`; - - return new (class Build { - isOss() { - return !!oss; - } - - resolvePath(...args) { - return config.resolveFromRepo('build', name, ...args); - } - - resolvePathForPlatform(platform, ...args) { - return config.resolveFromRepo( - 'build', - oss ? 'oss' : 'default', - `kibana-${config.getBuildVersion()}-${platform.getBuildName()}`, - ...args - ); - } - - getPlatformArchivePath(platform) { - const ext = platform.isWindows() ? 'zip' : 'tar.gz'; - return config.resolveFromRepo( - 'target', - `${name}-${config.getBuildVersion()}-${platform.getBuildName()}.${ext}` - ); - } - - getName() { - return name; - } - - getLogTag() { - return logTag; - } - })(); -} diff --git a/src/dev/build/lib/build.test.ts b/src/dev/build/lib/build.test.ts new file mode 100644 index 000000000000..9fdf21cee656 --- /dev/null +++ b/src/dev/build/lib/build.test.ts @@ -0,0 +1,120 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { REPO_ROOT, createAbsolutePathSerializer } from '@kbn/dev-utils'; + +import { Config } from './config'; +import { Build } from './build'; + +expect.addSnapshotSerializer(createAbsolutePathSerializer()); + +const config = new Config( + true, + { + version: '8.0.0', + engines: { + node: '*', + }, + workspaces: { + packages: [], + }, + }, + '1.2.3', + REPO_ROOT, + { + buildNumber: 1234, + buildSha: 'abcd1234', + buildVersion: '8.0.0', + }, + true +); + +const linuxPlatform = config.getPlatform('linux', 'x64'); +const linuxArmPlatform = config.getPlatform('linux', 'arm64'); +const windowsPlatform = config.getPlatform('win32', 'x64'); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +const ossBuild = new Build(config, true); +const defaultBuild = new Build(config, false); + +describe('#isOss()', () => { + it('returns true for oss', () => { + expect(ossBuild.isOss()).toBe(true); + }); + + it('returns false for default build', () => { + expect(defaultBuild.isOss()).toBe(false); + }); +}); + +describe('#getName()', () => { + it('returns kibana for default build', () => { + expect(defaultBuild.getName()).toBe('kibana'); + }); + + it('returns kibana-oss for oss', () => { + expect(ossBuild.getName()).toBe('kibana-oss'); + }); +}); + +describe('#getLogTag()', () => { + it('returns string with build name in it', () => { + expect(defaultBuild.getLogTag()).toContain(defaultBuild.getName()); + expect(ossBuild.getLogTag()).toContain(ossBuild.getName()); + }); +}); + +describe('#resolvePath()', () => { + it('uses passed config to resolve a path relative to the repo', () => { + expect(ossBuild.resolvePath('bar')).toMatchInlineSnapshot( + `/build/kibana-oss/bar` + ); + }); + + it('passes all arguments to config.resolveFromRepo()', () => { + expect(defaultBuild.resolvePath('bar', 'baz', 'box')).toMatchInlineSnapshot( + `/build/kibana/bar/baz/box` + ); + }); +}); + +describe('#resolvePathForPlatform()', () => { + it('uses config.resolveFromRepo(), config.getBuildVersion(), and platform.getBuildName() to create path', () => { + expect(ossBuild.resolvePathForPlatform(linuxPlatform, 'foo', 'bar')).toMatchInlineSnapshot( + `/build/oss/kibana-8.0.0-linux-x86_64/foo/bar` + ); + }); +}); + +describe('#getPlatformArchivePath()', () => { + it('creates correct path for different platforms', () => { + expect(ossBuild.getPlatformArchivePath(linuxPlatform)).toMatchInlineSnapshot( + `/target/kibana-oss-8.0.0-linux-x86_64.tar.gz` + ); + expect(ossBuild.getPlatformArchivePath(linuxArmPlatform)).toMatchInlineSnapshot( + `/target/kibana-oss-8.0.0-linux-aarch64.tar.gz` + ); + expect(ossBuild.getPlatformArchivePath(windowsPlatform)).toMatchInlineSnapshot( + `/target/kibana-oss-8.0.0-windows-x86_64.zip` + ); + }); +}); diff --git a/src/dev/build/lib/build.ts b/src/dev/build/lib/build.ts new file mode 100644 index 000000000000..d0b03b4c5e4b --- /dev/null +++ b/src/dev/build/lib/build.ts @@ -0,0 +1,63 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import chalk from 'chalk'; + +import { Config } from './config'; +import { Platform } from './platform'; + +export class Build { + private name = this.oss ? 'kibana-oss' : 'kibana'; + private logTag = this.oss ? chalk`{magenta [kibana-oss]}` : chalk`{cyan [ kibana ]}`; + + constructor(private config: Config, private oss: boolean) {} + + isOss() { + return !!this.oss; + } + + resolvePath(...args: string[]) { + return this.config.resolveFromRepo('build', this.name, ...args); + } + + resolvePathForPlatform(platform: Platform, ...args: string[]) { + return this.config.resolveFromRepo( + 'build', + this.oss ? 'oss' : 'default', + `kibana-${this.config.getBuildVersion()}-${platform.getBuildName()}`, + ...args + ); + } + + getPlatformArchivePath(platform: Platform) { + const ext = platform.isWindows() ? 'zip' : 'tar.gz'; + return this.config.resolveFromRepo( + 'target', + `${this.name}-${this.config.getBuildVersion()}-${platform.getBuildName()}.${ext}` + ); + } + + getName() { + return this.name; + } + + getLogTag() { + return this.logTag; + } +} diff --git a/src/dev/build/lib/config.js b/src/dev/build/lib/config.js deleted file mode 100644 index 36621f1c2d4a..000000000000 --- a/src/dev/build/lib/config.js +++ /dev/null @@ -1,168 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { dirname, resolve, relative } from 'path'; -import os from 'os'; - -import { getVersionInfo } from './version_info'; -import { createPlatform } from './platform'; - -export async function getConfig({ isRelease, targetAllPlatforms, versionQualifier }) { - const pkgPath = resolve(__dirname, '../../../../package.json'); - const pkg = require(pkgPath); // eslint-disable-line import/no-dynamic-require - const repoRoot = dirname(pkgPath); - const nodeVersion = pkg.engines.node; - - const platforms = [ - createPlatform('linux', 'x64', 'linux-x86_64'), - createPlatform('linux', 'arm64', 'linux-aarch64'), - createPlatform('darwin', 'x64', 'darwin-x86_64'), - createPlatform('win32', 'x64', 'windows-x86_64'), - ]; - - const versionInfo = await getVersionInfo({ - isRelease, - versionQualifier, - pkg, - }); - - return new (class Config { - /** - * Get Kibana's parsed package.json file - * @return {Object} - */ - getKibanaPkg() { - return pkg; - } - - isRelease() { - return isRelease; - } - - /** - * Get the node version required by Kibana - * @return {String} - */ - getNodeVersion() { - return nodeVersion; - } - - /** - * Convert an absolute path to a relative path, based from the repo - * @param {String} absolutePath - * @return {String} - */ - getRepoRelativePath(absolutePath) { - return relative(repoRoot, absolutePath); - } - - /** - * Resolve a set of relative paths based from the directory of the Kibana repo - * @param {...String} ...subPaths - * @return {String} - */ - resolveFromRepo(...subPaths) { - return resolve(repoRoot, ...subPaths); - } - - /** - * Return the list of Platforms we are targeting, if --this-platform flag is - * specified only the platform for this OS will be returned - * @return {Array} - */ - getTargetPlatforms() { - if (targetAllPlatforms) { - return platforms; - } - - return [this.getPlatformForThisOs()]; - } - - /** - * Return the list of Platforms we need/have node downloads for. We always - * include the linux platform even if we aren't targeting linux so we can - * reliably get the LICENSE file, which isn't included in the windows version - * @return {Array} - */ - getNodePlatforms() { - if (targetAllPlatforms) { - return platforms; - } - - if (process.platform === 'linux') { - return [this.getPlatform('linux', 'x64')]; - } - - return [this.getPlatformForThisOs(), this.getPlatform('linux', 'x64')]; - } - - getPlatform(name, arch) { - const selected = platforms.find((p) => { - return name === p.getName() && arch === p.getArchitecture(); - }); - - if (!selected) { - throw new Error(`Unable to find platform (${name}) with architecture (${arch})`); - } - - return selected; - } - - /** - * Get the platform object representing the OS on this machine - * @return {Platform} - */ - getPlatformForThisOs() { - return this.getPlatform(os.platform(), os.arch()); - } - - /** - * Get the version to use for this build - * @return {String} - */ - getBuildVersion() { - return versionInfo.buildVersion; - } - - /** - * Get the build number of this build - * @return {Number} - */ - getBuildNumber() { - return versionInfo.buildNumber; - } - - /** - * Get the git sha for this build - * @return {String} - */ - getBuildSha() { - return versionInfo.buildSha; - } - - /** - * Resolve a set of paths based from the target directory for this build. - * @param {...String} ...subPaths - * @return {String} - */ - resolveFromTarget(...subPaths) { - return resolve(repoRoot, 'target', ...subPaths); - } - })(); -} diff --git a/src/dev/build/lib/config.test.ts b/src/dev/build/lib/config.test.ts new file mode 100644 index 000000000000..0539adc840a6 --- /dev/null +++ b/src/dev/build/lib/config.test.ts @@ -0,0 +1,201 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { resolve } from 'path'; + +import { createAbsolutePathSerializer, REPO_ROOT } from '@kbn/dev-utils'; + +import pkg from '../../../../package.json'; +import { Config } from './config'; + +jest.mock('./version_info', () => ({ + getVersionInfo: () => ({ + buildSha: 'abc1234', + buildVersion: '8.0.0', + buildNumber: 1234, + }), +})); + +const versionInfo = jest.requireMock('./version_info').getVersionInfo(); + +expect.addSnapshotSerializer(createAbsolutePathSerializer()); + +const setup = async ({ targetAllPlatforms = true }: { targetAllPlatforms?: boolean } = {}) => { + return await Config.create({ + isRelease: true, + targetAllPlatforms, + }); +}; + +describe('#getKibanaPkg()', () => { + it('returns the parsed package.json from the Kibana repo', async () => { + const config = await setup(); + expect(config.getKibanaPkg()).toEqual(pkg); + }); +}); + +describe('#getNodeVersion()', () => { + it('returns the node version from the kibana package.json', async () => { + const config = await setup(); + expect(config.getNodeVersion()).toEqual(pkg.engines.node); + }); +}); + +describe('#getRepoRelativePath()', () => { + it('converts an absolute path to relative path, from the root of the repo', async () => { + const config = await setup(); + expect(config.getRepoRelativePath(__dirname)).toMatchInlineSnapshot(`"src/dev/build/lib"`); + }); +}); + +describe('#resolveFromRepo()', () => { + it('resolves a relative path', async () => { + const config = await setup(); + expect(config.resolveFromRepo('src/dev/build')).toMatchInlineSnapshot( + `/src/dev/build` + ); + }); + + it('resolves a series of relative paths', async () => { + const config = await setup(); + expect(config.resolveFromRepo('src', 'dev', 'build')).toMatchInlineSnapshot( + `/src/dev/build` + ); + }); +}); + +describe('#getPlatform()', () => { + it('throws error when platform does not exist', async () => { + const config = await setup(); + expect(() => { + config.getPlatform( + // @ts-expect-error invalid platform name + 'foo', + 'x64' + ); + }).toThrowErrorMatchingInlineSnapshot( + `"Unable to find platform (foo) with architecture (x64)"` + ); + }); + + it('throws error when architecture does not exist', async () => { + const config = await setup(); + expect(() => { + config.getPlatform( + 'linux', + // @ts-expect-error invalid platform arch + 'foo' + ); + }).toThrowErrorMatchingInlineSnapshot( + `"Unable to find platform (linux) with architecture (foo)"` + ); + }); +}); + +describe('#getTargetPlatforms()', () => { + it('returns an array of all platform objects', async () => { + const config = await setup(); + expect( + config + .getTargetPlatforms() + .map((p) => p.getNodeArch()) + .sort() + ).toMatchInlineSnapshot(` + Array [ + "darwin-x64", + "linux-arm64", + "linux-x64", + "win32-x64", + ] + `); + }); + + it('returns just this platform when targetAllPlatforms = false', async () => { + const config = await setup({ + targetAllPlatforms: false, + }); + + expect(config.getTargetPlatforms()).toEqual([config.getPlatformForThisOs()]); + }); +}); + +describe('#getNodePlatforms()', () => { + it('returns all platforms', async () => { + const config = await setup(); + expect( + config + .getTargetPlatforms() + .map((p) => p.getNodeArch()) + .sort() + ).toEqual(['darwin-x64', 'linux-arm64', 'linux-x64', 'win32-x64']); + }); + + it('returns this platform and linux, when targetAllPlatforms = false', async () => { + const config = await setup({ + targetAllPlatforms: false, + }); + const platforms = config.getNodePlatforms(); + expect(platforms).toBeInstanceOf(Array); + if (process.platform !== 'linux') { + expect(platforms).toHaveLength(2); + expect(platforms[0]).toBe(config.getPlatformForThisOs()); + expect(platforms[1]).toBe(config.getPlatform('linux', 'x64')); + } else { + expect(platforms).toHaveLength(1); + expect(platforms[0]).toBe(config.getPlatform('linux', 'x64')); + } + }); +}); + +describe('#getPlatformForThisOs()', () => { + it('returns the platform that matches the arch of this machine', async () => { + const config = await setup(); + const currentPlatform = config.getPlatformForThisOs(); + expect(currentPlatform.getName()).toBe(process.platform); + expect(currentPlatform.getArchitecture()).toBe(process.arch); + }); +}); + +describe('#getBuildVersion()', () => { + it('returns the version from the build info', async () => { + const config = await setup(); + expect(config.getBuildVersion()).toBe(versionInfo.buildVersion); + }); +}); + +describe('#getBuildNumber()', () => { + it('returns the number from the build info', async () => { + const config = await setup(); + expect(config.getBuildNumber()).toBe(versionInfo.buildNumber); + }); +}); + +describe('#getBuildSha()', () => { + it('returns the sha from the build info', async () => { + const config = await setup(); + expect(config.getBuildSha()).toBe(versionInfo.buildSha); + }); +}); + +describe('#resolveFromTarget()', () => { + it('resolves a relative path, from the target directory', async () => { + const config = await setup(); + expect(config.resolveFromTarget()).toBe(resolve(REPO_ROOT, 'target')); + }); +}); diff --git a/src/dev/build/lib/config.ts b/src/dev/build/lib/config.ts new file mode 100644 index 000000000000..338c89b1930d --- /dev/null +++ b/src/dev/build/lib/config.ts @@ -0,0 +1,173 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { dirname, resolve, relative } from 'path'; +import os from 'os'; +import loadJsonFile from 'load-json-file'; + +import { getVersionInfo, VersionInfo } from './version_info'; +import { PlatformName, PlatformArchitecture, ALL_PLATFORMS } from './platform'; + +interface Options { + isRelease: boolean; + targetAllPlatforms: boolean; + versionQualifier?: string; +} + +interface Package { + version: string; + engines: { node: string }; + workspaces: { + packages: string[]; + }; + [key: string]: unknown; +} + +export class Config { + static async create({ isRelease, targetAllPlatforms, versionQualifier }: Options) { + const pkgPath = resolve(__dirname, '../../../../package.json'); + const pkg: Package = loadJsonFile.sync(pkgPath); + + return new Config( + targetAllPlatforms, + pkg, + pkg.engines.node, + dirname(pkgPath), + await getVersionInfo({ + isRelease, + versionQualifier, + pkg, + }), + isRelease + ); + } + + constructor( + private readonly targetAllPlatforms: boolean, + private readonly pkg: Package, + private readonly nodeVersion: string, + private readonly repoRoot: string, + private readonly versionInfo: VersionInfo, + public readonly isRelease: boolean + ) {} + + /** + * Get Kibana's parsed package.json file + */ + getKibanaPkg() { + return this.pkg; + } + + /** + * Get the node version required by Kibana + */ + getNodeVersion() { + return this.nodeVersion; + } + + /** + * Convert an absolute path to a relative path, based from the repo + */ + getRepoRelativePath(absolutePath: string) { + return relative(this.repoRoot, absolutePath); + } + + /** + * Resolve a set of relative paths based from the directory of the Kibana repo + */ + resolveFromRepo(...subPaths: string[]) { + return resolve(this.repoRoot, ...subPaths); + } + + /** + * Return the list of Platforms we are targeting, if --this-platform flag is + * specified only the platform for this OS will be returned + */ + getTargetPlatforms() { + if (this.targetAllPlatforms) { + return ALL_PLATFORMS; + } + + return [this.getPlatformForThisOs()]; + } + + /** + * Return the list of Platforms we need/have node downloads for. We always + * include the linux platform even if we aren't targeting linux so we can + * reliably get the LICENSE file, which isn't included in the windows version + */ + getNodePlatforms() { + if (this.targetAllPlatforms) { + return ALL_PLATFORMS; + } + + if (process.platform === 'linux') { + return [this.getPlatform('linux', 'x64')]; + } + + return [this.getPlatformForThisOs(), this.getPlatform('linux', 'x64')]; + } + + getPlatform(name: PlatformName, arch: PlatformArchitecture) { + const selected = ALL_PLATFORMS.find((p) => { + return name === p.getName() && arch === p.getArchitecture(); + }); + + if (!selected) { + throw new Error(`Unable to find platform (${name}) with architecture (${arch})`); + } + + return selected; + } + + /** + * Get the platform object representing the OS on this machine + */ + getPlatformForThisOs() { + return this.getPlatform(os.platform() as PlatformName, os.arch() as PlatformArchitecture); + } + + /** + * Get the version to use for this build + */ + getBuildVersion() { + return this.versionInfo.buildVersion; + } + + /** + * Get the build number of this build + */ + getBuildNumber() { + return this.versionInfo.buildNumber; + } + + /** + * Get the git sha for this build + */ + getBuildSha() { + return this.versionInfo.buildSha; + } + + /** + * Resolve a set of paths based from the target directory for this build. + */ + resolveFromTarget(...subPaths: string[]) { + return resolve(this.repoRoot, 'target', ...subPaths); + } +} diff --git a/src/dev/build/lib/download.js b/src/dev/build/lib/download.ts similarity index 81% rename from src/dev/build/lib/download.js rename to src/dev/build/lib/download.ts index fbd2d47ff7b0..7c1618b833b4 100644 --- a/src/dev/build/lib/download.js +++ b/src/dev/build/lib/download.ts @@ -23,10 +23,15 @@ import { dirname } from 'path'; import chalk from 'chalk'; import { createHash } from 'crypto'; import Axios from 'axios'; +import { ToolingLog } from '@kbn/dev-utils'; + +// https://github.com/axios/axios/tree/ffea03453f77a8176c51554d5f6c3c6829294649/lib/adapters +// @ts-expect-error untyped internal module used to prevent axios from using xhr adapter in tests +import AxiosHttpAdapter from 'axios/lib/adapters/http'; import { mkdirp } from './fs'; -function tryUnlink(path) { +function tryUnlink(path: string) { try { unlinkSync(path); } catch (error) { @@ -36,7 +41,14 @@ function tryUnlink(path) { } } -export async function download(options) { +interface DownloadOptions { + log: ToolingLog; + url: string; + destination: string; + sha256: string; + retries?: number; +} +export async function download(options: DownloadOptions): Promise { const { log, url, destination, sha256, retries = 0 } = options; if (!sha256) { @@ -52,8 +64,9 @@ export async function download(options) { log.debug(`Attempting download of ${url}`, chalk.dim(sha256)); const response = await Axios.request({ - url: url, + url, responseType: 'stream', + adapter: AxiosHttpAdapter, }); if (response.status !== 200) { @@ -62,7 +75,7 @@ export async function download(options) { const hash = createHash('sha256'); await new Promise((resolve, reject) => { - response.data.on('data', (chunk) => { + response.data.on('data', (chunk: Buffer) => { hash.update(chunk); writeSync(fileHandle, chunk); }); diff --git a/src/dev/build/lib/__tests__/errors.js b/src/dev/build/lib/errors.test.ts similarity index 67% rename from src/dev/build/lib/__tests__/errors.js rename to src/dev/build/lib/errors.test.ts index dc23b3e372bc..0bf96463555f 100644 --- a/src/dev/build/lib/__tests__/errors.js +++ b/src/dev/build/lib/errors.test.ts @@ -17,28 +17,26 @@ * under the License. */ -import expect from '@kbn/expect'; - -import { isErrorLogged, markErrorLogged } from '../errors'; +import { isErrorLogged, markErrorLogged } from './errors'; describe('dev/build/lib/errors', () => { describe('isErrorLogged()/markErrorLogged()', () => { it('returns true if error has been passed to markErrorLogged()', () => { const error = new Error(); - expect(isErrorLogged(error)).to.be(false); + expect(isErrorLogged(error)).toBe(false); markErrorLogged(error); - expect(isErrorLogged(error)).to.be(true); + expect(isErrorLogged(error)).toBe(true); }); describe('isErrorLogged()', () => { it('handles any value type', () => { - expect(isErrorLogged(null)).to.be(false); - expect(isErrorLogged(undefined)).to.be(false); - expect(isErrorLogged(1)).to.be(false); - expect(isErrorLogged([])).to.be(false); - expect(isErrorLogged({})).to.be(false); - expect(isErrorLogged(/foo/)).to.be(false); - expect(isErrorLogged(new Date())).to.be(false); + expect(isErrorLogged(null)).toBe(false); + expect(isErrorLogged(undefined)).toBe(false); + expect(isErrorLogged(1)).toBe(false); + expect(isErrorLogged([])).toBe(false); + expect(isErrorLogged({})).toBe(false); + expect(isErrorLogged(/foo/)).toBe(false); + expect(isErrorLogged(new Date())).toBe(false); }); }); }); diff --git a/src/dev/build/lib/errors.js b/src/dev/build/lib/errors.ts similarity index 86% rename from src/dev/build/lib/errors.js rename to src/dev/build/lib/errors.ts index 7fb8e2dc070d..8405e9d29a03 100644 --- a/src/dev/build/lib/errors.js +++ b/src/dev/build/lib/errors.ts @@ -17,13 +17,13 @@ * under the License. */ -const loggedErrors = new WeakSet(); +const loggedErrors = new WeakSet(); -export function markErrorLogged(error) { +export function markErrorLogged(error: T): T { loggedErrors.add(error); return error; } -export function isErrorLogged(error) { +export function isErrorLogged(error: any) { return loggedErrors.has(error); } diff --git a/src/dev/build/lib/exec.test.ts b/src/dev/build/lib/exec.test.ts new file mode 100644 index 000000000000..6f6ec4f26afb --- /dev/null +++ b/src/dev/build/lib/exec.test.ts @@ -0,0 +1,67 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import Path from 'path'; + +import { + ToolingLog, + ToolingLogCollectingWriter, + createStripAnsiSerializer, + createRecursiveSerializer, +} from '@kbn/dev-utils'; + +import { exec } from './exec'; + +const testWriter = new ToolingLogCollectingWriter(); +const log = new ToolingLog(); +log.setWriters([testWriter]); + +expect.addSnapshotSerializer(createStripAnsiSerializer()); +expect.addSnapshotSerializer( + createRecursiveSerializer( + (v) => v.includes(process.execPath), + (v) => v.split(Path.dirname(process.execPath)).join('') + ) +); + +beforeEach(() => { + testWriter.messages.length = 0; +}); + +it('executes a command, logs the command, and logs the output', async () => { + await exec(log, process.execPath, ['-e', 'console.log("hi")']); + expect(testWriter.messages).toMatchInlineSnapshot(` + Array [ + " debg $ /node -e console.log(\\"hi\\")", + " debg hi", + ] + `); +}); + +it('logs using level: option', async () => { + await exec(log, process.execPath, ['-e', 'console.log("hi")'], { + level: 'info', + }); + expect(testWriter.messages).toMatchInlineSnapshot(` + Array [ + " info $ /node -e console.log(\\"hi\\")", + " info hi", + ] + `); +}); diff --git a/src/dev/build/lib/exec.js b/src/dev/build/lib/exec.ts similarity index 73% rename from src/dev/build/lib/exec.js rename to src/dev/build/lib/exec.ts index 5e47500c72c5..c3870230b8f3 100644 --- a/src/dev/build/lib/exec.js +++ b/src/dev/build/lib/exec.ts @@ -19,12 +19,23 @@ import execa from 'execa'; import chalk from 'chalk'; +import { ToolingLog, LogLevel } from '@kbn/dev-utils'; -import { watchStdioForLine } from '../../../legacy/utils'; +import { watchStdioForLine } from './watch_stdio_for_line'; -export async function exec(log, cmd, args, options = {}) { - const { level = 'debug', cwd, env, exitAfter } = options; +interface Options { + level?: Exclude; + cwd?: string; + env?: Record; + exitAfter?: RegExp; +} +export async function exec( + log: ToolingLog, + cmd: string, + args: string[], + { level = 'debug', cwd, env, exitAfter }: Options = {} +) { log[level](chalk.dim('$'), cmd, ...args); const proc = execa(cmd, args, { diff --git a/src/dev/build/lib/fs.js b/src/dev/build/lib/fs.ts similarity index 56% rename from src/dev/build/lib/fs.js rename to src/dev/build/lib/fs.ts index b905f40d0de1..d86901c41e43 100644 --- a/src/dev/build/lib/fs.js +++ b/src/dev/build/lib/fs.ts @@ -17,28 +17,31 @@ * under the License. */ -import archiver from 'archiver'; import fs from 'fs'; import { createHash } from 'crypto'; +import { pipeline, Writable } from 'stream'; import { resolve, dirname, isAbsolute, sep } from 'path'; import { createGunzip } from 'zlib'; -import { inspect } from 'util'; +import { inspect, promisify } from 'util'; +import archiver from 'archiver'; import vfs from 'vinyl-fs'; -import { promisify } from 'bluebird'; +import File from 'vinyl'; import del from 'del'; import deleteEmpty from 'delete-empty'; -import { createPromiseFromStreams, createMapStream } from '../../../legacy/utils'; - -import tar from 'tar'; +import tar, { ExtractOptions } from 'tar'; +import { ToolingLog } from '@kbn/dev-utils'; +const pipelineAsync = promisify(pipeline); const mkdirAsync = promisify(fs.mkdir); const writeFileAsync = promisify(fs.writeFile); const readFileAsync = promisify(fs.readFile); const readdirAsync = promisify(fs.readdir); const utimesAsync = promisify(fs.utimes); +const copyFileAsync = promisify(fs.copyFile); +const statAsync = promisify(fs.stat); -export function assertAbsolute(path) { +export function assertAbsolute(path: string) { if (!isAbsolute(path)) { throw new TypeError( 'Please use absolute paths to keep things explicit. You probably want to use `build.resolvePath()` or `config.resolveFromRepo()`.' @@ -46,7 +49,7 @@ export function assertAbsolute(path) { } } -export function isFileAccessible(path) { +export function isFileAccessible(path: string) { assertAbsolute(path); try { @@ -57,35 +60,35 @@ export function isFileAccessible(path) { } } -function longInspect(value) { +function longInspect(value: any) { return inspect(value, { maxArrayLength: Infinity, }); } -export async function mkdirp(path) { +export async function mkdirp(path: string) { assertAbsolute(path); await mkdirAsync(path, { recursive: true }); } -export async function write(path, contents) { +export async function write(path: string, contents: string) { assertAbsolute(path); await mkdirp(dirname(path)); await writeFileAsync(path, contents); } -export async function read(path) { +export async function read(path: string) { assertAbsolute(path); return await readFileAsync(path, 'utf8'); } -export async function getChildPaths(path) { +export async function getChildPaths(path: string) { assertAbsolute(path); const childNames = await readdirAsync(path); return childNames.map((name) => resolve(path, name)); } -export async function deleteAll(patterns, log) { +export async function deleteAll(patterns: string[], log: ToolingLog) { if (!Array.isArray(patterns)) { throw new TypeError('Expected patterns to be an array'); } @@ -108,7 +111,11 @@ export async function deleteAll(patterns, log) { } } -export async function deleteEmptyFolders(log, rootFolderPath, foldersToKeep) { +export async function deleteEmptyFolders( + log: ToolingLog, + rootFolderPath: string, + foldersToKeep: string[] +) { if (typeof rootFolderPath !== 'string') { throw new TypeError('Expected root folder to be a string path'); } @@ -121,7 +128,11 @@ export async function deleteEmptyFolders(log, rootFolderPath, foldersToKeep) { // Delete empty is used to gather all the empty folders and // then we use del to actually delete them - const emptyFoldersList = await deleteEmpty(rootFolderPath, { dryRun: true }); + const emptyFoldersList = await deleteEmpty(rootFolderPath, { + // @ts-expect-error DT package has incorrect types https://github.com/jonschlinkert/delete-empty/blob/6ae34547663e6845c3c98b184c606fa90ef79c0a/index.js#L160 + dryRun: true, + }); + const foldersToDelete = emptyFoldersList.filter((folderToDelete) => { return !foldersToKeep.some((folderToKeep) => folderToDelete.includes(folderToKeep)); }); @@ -133,85 +144,153 @@ export async function deleteEmptyFolders(log, rootFolderPath, foldersToKeep) { log.verbose('Deleted:', longInspect(deletedEmptyFolders)); } -export async function copyAll(sourceDir, destination, options = {}) { - const { select = ['**/*'], dot = false, time } = options; +interface CopyOptions { + clone?: boolean; +} +export async function copy(source: string, destination: string, options: CopyOptions = {}) { + assertAbsolute(source); + assertAbsolute(destination); + + // ensure source exists before creating destination directory and copying source + await statAsync(source); + await mkdirp(dirname(destination)); + return await copyFileAsync( + source, + destination, + options.clone ? fs.constants.COPYFILE_FICLONE : 0 + ); +} + +interface CopyAllOptions { + select?: string[]; + dot?: boolean; + time?: string | number | Date; +} + +export async function copyAll( + sourceDir: string, + destination: string, + options: CopyAllOptions = {} +) { + const { select = ['**/*'], dot = false, time = Date.now() } = options; assertAbsolute(sourceDir); assertAbsolute(destination); - await createPromiseFromStreams([ + await pipelineAsync( vfs.src(select, { buffer: false, cwd: sourceDir, base: sourceDir, dot, }), - vfs.dest(destination), - ]); + vfs.dest(destination) + ); // we must update access and modified file times after the file copy // has completed, otherwise the copy action can effect modify times. if (Boolean(time)) { - await createPromiseFromStreams([ + await pipelineAsync( vfs.src(select, { buffer: false, cwd: destination, base: destination, dot, }), - createMapStream((file) => utimesAsync(file.path, time, time)), - ]); + new Writable({ + objectMode: true, + write(file: File, _, cb) { + utimesAsync(file.path, time, time).then(() => cb(), cb); + }, + }) + ); } } -export async function getFileHash(path, algo) { +export async function getFileHash(path: string, algo: string) { assertAbsolute(path); const hash = createHash(algo); const readStream = fs.createReadStream(path); - await new Promise((resolve, reject) => { + await new Promise((res, rej) => { readStream .on('data', (chunk) => hash.update(chunk)) - .on('error', reject) - .on('end', resolve); + .on('error', rej) + .on('end', res); }); return hash.digest('hex'); } -export async function untar(source, destination, extractOptions = {}) { +export async function untar( + source: string, + destination: string, + extractOptions: ExtractOptions = {} +) { assertAbsolute(source); assertAbsolute(destination); await mkdirAsync(destination, { recursive: true }); - await createPromiseFromStreams([ + await pipelineAsync( fs.createReadStream(source), createGunzip(), tar.extract({ ...extractOptions, cwd: destination, - }), - ]); + }) + ); } -export async function gunzip(source, destination) { +export async function gunzip(source: string, destination: string) { assertAbsolute(source); assertAbsolute(destination); await mkdirAsync(dirname(destination), { recursive: true }); - await createPromiseFromStreams([ + await pipelineAsync( fs.createReadStream(source), createGunzip(), - fs.createWriteStream(destination), - ]); + fs.createWriteStream(destination) + ); +} + +interface CompressTarOptions { + createRootDirectory: boolean; + source: string; + destination: string; + archiverOptions?: archiver.TarOptions & archiver.CoreOptions; } +export async function compressTar({ + source, + destination, + archiverOptions, + createRootDirectory, +}: CompressTarOptions) { + const output = fs.createWriteStream(destination); + const archive = archiver('tar', archiverOptions); + const name = createRootDirectory ? source.split(sep).slice(-1)[0] : false; + + archive.pipe(output); -export async function compress(type, options = {}, source, destination) { + return archive.directory(source, name).finalize(); +} + +interface CompressZipOptions { + createRootDirectory: boolean; + source: string; + destination: string; + archiverOptions?: archiver.ZipOptions & archiver.CoreOptions; +} +export async function compressZip({ + source, + destination, + archiverOptions, + createRootDirectory, +}: CompressZipOptions) { const output = fs.createWriteStream(destination); - const archive = archiver(type, options.archiverOptions); - const name = options.createRootDirectory ? source.split(sep).slice(-1)[0] : false; + const archive = archiver('zip', archiverOptions); + const name = createRootDirectory ? source.split(sep).slice(-1)[0] : false; archive.pipe(output); diff --git a/src/dev/build/lib/index.js b/src/dev/build/lib/index.js deleted file mode 100644 index 6540db6f37a7..000000000000 --- a/src/dev/build/lib/index.js +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -export { getConfig } from './config'; -export { createRunner } from './runner'; -export { isErrorLogged } from './errors'; -export { exec } from './exec'; -export { - read, - write, - mkdirp, - copyAll, - getFileHash, - untar, - gunzip, - deleteAll, - deleteEmptyFolders, - compress, - isFileAccessible, -} from './fs'; -export { download } from './download'; -export { scanDelete } from './scan_delete'; -export { scanCopy } from './scan_copy'; diff --git a/src/dev/build/lib/index.ts b/src/dev/build/lib/index.ts new file mode 100644 index 000000000000..339dc41cc6cc --- /dev/null +++ b/src/dev/build/lib/index.ts @@ -0,0 +1,30 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export * from './config'; +export * from './build'; +export * from './runner'; +export * from './errors'; +export * from './exec'; +export * from './fs'; +export * from './download'; +export * from './scan_delete'; +export * from './scan_copy'; +export * from './platform'; +export * from './scan'; diff --git a/src/dev/build/lib/integration_tests/download.test.ts b/src/dev/build/lib/integration_tests/download.test.ts new file mode 100644 index 000000000000..a86d5292501f --- /dev/null +++ b/src/dev/build/lib/integration_tests/download.test.ts @@ -0,0 +1,226 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createServer, IncomingMessage, ServerResponse } from 'http'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { readFileSync } from 'fs'; + +import del from 'del'; +import { CI_PARALLEL_PROCESS_PREFIX } from '@kbn/test'; +import { ToolingLog } from '@kbn/dev-utils'; + +import { mkdirp } from '../fs'; +import { download } from '../download'; + +const TMP_DIR = join(tmpdir(), CI_PARALLEL_PROCESS_PREFIX, 'download-js-test-tmp-dir'); +const TMP_DESTINATION = join(TMP_DIR, '__tmp_download_js_test_file__'); + +beforeEach(async () => { + await del(TMP_DIR, { force: true }); + await mkdirp(TMP_DIR); + jest.clearAllMocks(); +}); + +afterEach(async () => { + await del(TMP_DIR, { force: true }); +}); + +const onLogLine = jest.fn(); +const log = new ToolingLog({ + level: 'verbose', + writeTo: { + write: onLogLine, + }, +}); + +type Handler = (req: IncomingMessage, res: ServerResponse) => void; + +const FOO_SHA256 = '2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae'; +const createSendHandler = (send: any): Handler => (req, res) => { + res.statusCode = 200; + res.end(send); +}; +const sendErrorHandler: Handler = (req, res) => { + res.statusCode = 500; + res.end(); +}; + +let serverUrl: string; +let nextHandler: Handler | null = null; +const server = createServer((req, res) => { + if (!nextHandler) { + nextHandler = sendErrorHandler; + } + + const handler = nextHandler; + nextHandler = null; + handler(req, res); +}); + +afterEach(() => (nextHandler = null)); + +beforeAll(async () => { + await Promise.race([ + new Promise((_, reject) => { + server.once('error', reject); + }), + new Promise((resolve) => { + server.listen(resolve); + }), + ]); + + // address is only a string when listening to a UNIX socket, and undefined when we haven't called listen() yet + const address = server.address() as { port: number }; + + serverUrl = `http://localhost:${address.port}/`; +}); + +afterAll(async () => { + server.close(); +}); + +it('downloads from URL and checks that content matches sha256', async () => { + nextHandler = createSendHandler('foo'); + await download({ + log, + url: serverUrl, + destination: TMP_DESTINATION, + sha256: FOO_SHA256, + }); + expect(readFileSync(TMP_DESTINATION, 'utf8')).toBe('foo'); +}); + +it('rejects and deletes destination if sha256 does not match', async () => { + nextHandler = createSendHandler('foo'); + + try { + await download({ + log, + url: serverUrl, + destination: TMP_DESTINATION, + sha256: 'bar', + }); + throw new Error('Expected download() to reject'); + } catch (error) { + expect(error).toHaveProperty( + 'message', + expect.stringContaining('does not match the expected sha256 checksum') + ); + } + + try { + readFileSync(TMP_DESTINATION); + throw new Error('Expected download to be deleted'); + } catch (error) { + expect(error).toHaveProperty('code', 'ENOENT'); + } +}); + +describe('reties download retries: number of times', () => { + it('resolves if retries = 1 and first attempt fails', async () => { + let reqCount = 0; + nextHandler = function sequenceHandler(req, res) { + switch (++reqCount) { + case 1: + nextHandler = sequenceHandler; + return sendErrorHandler(req, res); + default: + return createSendHandler('foo')(req, res); + } + }; + + await download({ + log, + url: serverUrl, + destination: TMP_DESTINATION, + sha256: FOO_SHA256, + retries: 2, + }); + + expect(readFileSync(TMP_DESTINATION, 'utf8')).toBe('foo'); + }); + + it('resolves if first fails, second is bad shasum, but third succeeds', async () => { + let reqCount = 0; + nextHandler = function sequenceHandler(req, res) { + switch (++reqCount) { + case 1: + nextHandler = sequenceHandler; + return sendErrorHandler(req, res); + case 2: + nextHandler = sequenceHandler; + return createSendHandler('bar')(req, res); + default: + return createSendHandler('foo')(req, res); + } + }; + + await download({ + log, + url: serverUrl, + destination: TMP_DESTINATION, + sha256: FOO_SHA256, + retries: 2, + }); + }); + + it('makes 6 requests if `retries: 5` and all failed', async () => { + let reqCount = 0; + nextHandler = function sequenceHandler(req, res) { + reqCount += 1; + nextHandler = sequenceHandler; + sendErrorHandler(req, res); + }; + + try { + await download({ + log, + url: serverUrl, + destination: TMP_DESTINATION, + sha256: FOO_SHA256, + retries: 5, + }); + throw new Error('Expected download() to reject'); + } catch (error) { + expect(error).toHaveProperty( + 'message', + expect.stringContaining('Request failed with status code 500') + ); + expect(reqCount).toBe(6); + } + }); +}); + +describe('sha256 option not supplied', () => { + it('refuses to download', async () => { + try { + // @ts-expect-error missing sha256 param is intentional + await download({ + log, + url: 'http://google.com', + destination: TMP_DESTINATION, + }); + + throw new Error('expected download() to reject'); + } catch (error) { + expect(error).toHaveProperty('message', expect.stringContaining('refusing to download')); + } + }); +}); diff --git a/src/dev/build/lib/integration_tests/fs.test.ts b/src/dev/build/lib/integration_tests/fs.test.ts new file mode 100644 index 000000000000..e9ce09554159 --- /dev/null +++ b/src/dev/build/lib/integration_tests/fs.test.ts @@ -0,0 +1,358 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { resolve } from 'path'; +import { chmodSync, statSync } from 'fs'; + +import del from 'del'; + +import { mkdirp, write, read, getChildPaths, copyAll, getFileHash, untar, gunzip } from '../fs'; + +const TMP = resolve(__dirname, '../__tmp__'); +const FIXTURES = resolve(__dirname, '../__fixtures__'); +const FOO_TAR_PATH = resolve(FIXTURES, 'foo_dir.tar.gz'); +const FOO_GZIP_PATH = resolve(FIXTURES, 'foo.txt.gz'); +const BAR_TXT_PATH = resolve(FIXTURES, 'foo_dir/bar.txt'); +const WORLD_EXECUTABLE = resolve(FIXTURES, 'bin/world_executable'); + +const isWindows = /^win/.test(process.platform); + +// get the mode of a file as a string, like 777, or 644, +function getCommonMode(path: string) { + return statSync(path).mode.toString(8).slice(-3); +} + +function assertNonAbsoluteError(error: any) { + expect(error).toBeInstanceOf(Error); + expect(error.message).toContain('Please use absolute paths'); +} + +// ensure WORLD_EXECUTABLE is actually executable by all +beforeAll(async () => { + chmodSync(WORLD_EXECUTABLE, 0o777); +}); + +// clean and recreate TMP directory +beforeEach(async () => { + await del(TMP); + await mkdirp(TMP); +}); + +// cleanup TMP directory +afterAll(async () => { + await del(TMP); +}); + +describe('mkdirp()', () => { + it('rejects if path is not absolute', async () => { + try { + await mkdirp('foo/bar'); + throw new Error('Expected mkdirp() to reject'); + } catch (error) { + assertNonAbsoluteError(error); + } + }); + + it('makes directory and necessary parent directories', async () => { + const destination = resolve(TMP, 'a/b/c/d/e/f/g'); + + expect(await mkdirp(destination)).toBe(undefined); + + expect(statSync(destination).isDirectory()).toBe(true); + }); +}); + +describe('write()', () => { + it('rejects if path is not absolute', async () => { + try { + // @ts-expect-error missing content intentional + await write('foo/bar'); + throw new Error('Expected write() to reject'); + } catch (error) { + assertNonAbsoluteError(error); + } + }); + + it('writes content to a file with existing parent directory', async () => { + const destination = resolve(TMP, 'a'); + + expect(await write(destination, 'bar')).toBe(undefined); + expect(await read(destination)).toBe('bar'); + }); + + it('writes content to a file with missing parents', async () => { + const destination = resolve(TMP, 'a/b/c/d/e'); + + expect(await write(destination, 'bar')).toBe(undefined); + expect(await read(destination)).toBe('bar'); + }); +}); + +describe('read()', () => { + it('rejects if path is not absolute', async () => { + try { + await read('foo/bar'); + throw new Error('Expected read() to reject'); + } catch (error) { + assertNonAbsoluteError(error); + } + }); + + it('reads file, resolves with result', async () => { + expect(await read(BAR_TXT_PATH)).toBe('bar\n'); + }); +}); + +describe('getChildPaths()', () => { + it('rejects if path is not absolute', async () => { + try { + await getChildPaths('foo/bar'); + throw new Error('Expected getChildPaths() to reject'); + } catch (error) { + assertNonAbsoluteError(error); + } + }); + + it('resolves with absolute paths to the children of directory', async () => { + const path = resolve(FIXTURES, 'foo_dir'); + expect((await getChildPaths(path)).sort()).toEqual([ + resolve(FIXTURES, 'foo_dir/.bar'), + BAR_TXT_PATH, + resolve(FIXTURES, 'foo_dir/foo'), + ]); + }); + + it('rejects with ENOENT if path does not exist', async () => { + try { + await getChildPaths(resolve(FIXTURES, 'notrealpath')); + throw new Error('Expected getChildPaths() to reject'); + } catch (error) { + expect(error).toHaveProperty('code', 'ENOENT'); + } + }); +}); + +describe('copyAll()', () => { + it('rejects if source path is not absolute', async () => { + try { + await copyAll('foo/bar', __dirname); + throw new Error('Expected copyAll() to reject'); + } catch (error) { + assertNonAbsoluteError(error); + } + }); + + it('rejects if destination path is not absolute', async () => { + try { + await copyAll(__dirname, 'foo/bar'); + throw new Error('Expected copyAll() to reject'); + } catch (error) { + assertNonAbsoluteError(error); + } + }); + + it('rejects if neither path is not absolute', async () => { + try { + await copyAll('foo/bar', 'foo/bar'); + throw new Error('Expected copyAll() to reject'); + } catch (error) { + assertNonAbsoluteError(error); + } + }); + + it('copies files and directories from source to dest, creating dest if necessary, respecting mode', async () => { + const destination = resolve(TMP, 'a/b/c'); + await copyAll(FIXTURES, destination); + + expect((await getChildPaths(resolve(destination, 'foo_dir'))).sort()).toEqual([ + resolve(destination, 'foo_dir/bar.txt'), + resolve(destination, 'foo_dir/foo'), + ]); + + expect(getCommonMode(resolve(destination, 'bin/world_executable'))).toBe( + isWindows ? '666' : '777' + ); + expect(getCommonMode(resolve(destination, 'foo_dir/bar.txt'))).toBe(isWindows ? '666' : '644'); + }); + + it('applies select globs if specified, ignores dot files', async () => { + const destination = resolve(TMP, 'a/b/c/d'); + await copyAll(FIXTURES, destination, { + select: ['**/*bar*'], + }); + + try { + statSync(resolve(destination, 'bin/world_executable')); + throw new Error('expected bin/world_executable to not by copied'); + } catch (error) { + expect(error).toHaveProperty('code', 'ENOENT'); + } + + try { + statSync(resolve(destination, 'foo_dir/.bar')); + throw new Error('expected foo_dir/.bar to not by copied'); + } catch (error) { + expect(error).toHaveProperty('code', 'ENOENT'); + } + + expect(await read(resolve(destination, 'foo_dir/bar.txt'))).toBe('bar\n'); + }); + + it('supports select globs and dot option together', async () => { + const destination = resolve(TMP, 'a/b/c/d'); + await copyAll(FIXTURES, destination, { + select: ['**/*bar*'], + dot: true, + }); + + try { + statSync(resolve(destination, 'bin/world_executable')); + throw new Error('expected bin/world_executable to not by copied'); + } catch (error) { + expect(error).toHaveProperty('code', 'ENOENT'); + } + + expect(await read(resolve(destination, 'foo_dir/bar.txt'))).toBe('bar\n'); + expect(await read(resolve(destination, 'foo_dir/.bar'))).toBe('dotfile\n'); + }); + + it('supports atime and mtime', async () => { + const destination = resolve(TMP, 'a/b/c/d/e'); + const time = new Date(1425298511000); + await copyAll(FIXTURES, destination, { + time, + }); + const barTxt = statSync(resolve(destination, 'foo_dir/bar.txt')); + const fooDir = statSync(resolve(destination, 'foo_dir')); + + // precision is platform specific + const oneDay = 86400000; + expect(Math.abs(barTxt.atimeMs - time.getTime())).toBeLessThan(oneDay); + expect(Math.abs(fooDir.atimeMs - time.getTime())).toBeLessThan(oneDay); + expect(Math.abs(barTxt.mtimeMs - time.getTime())).toBeLessThan(oneDay); + }); +}); + +describe('getFileHash()', () => { + it('rejects if path is not absolute', async () => { + try { + await getFileHash('foo/bar', 'some content'); + throw new Error('Expected getFileHash() to reject'); + } catch (error) { + assertNonAbsoluteError(error); + } + }); + + it('resolves with the sha1 hash of a file', async () => { + expect(await getFileHash(BAR_TXT_PATH, 'sha1')).toBe( + 'e242ed3bffccdf271b7fbaf34ed72d089537b42f' + ); + }); + it('resolves with the sha256 hash of a file', async () => { + expect(await getFileHash(BAR_TXT_PATH, 'sha256')).toBe( + '7d865e959b2466918c9863afca942d0fb89d7c9ac0c99bafc3749504ded97730' + ); + }); + it('resolves with the md5 hash of a file', async () => { + expect(await getFileHash(BAR_TXT_PATH, 'md5')).toBe('c157a79031e1c40f85931829bc5fc552'); + }); +}); + +describe('untar()', () => { + it('rejects if source path is not absolute', async () => { + try { + await untar('foo/bar', '**/*'); + throw new Error('Expected untar() to reject'); + } catch (error) { + assertNonAbsoluteError(error); + } + }); + + it('rejects if destination path is not absolute', async () => { + try { + await untar(__dirname, '**/*'); + throw new Error('Expected untar() to reject'); + } catch (error) { + assertNonAbsoluteError(error); + } + }); + + it('rejects if neither path is not absolute', async () => { + try { + await untar('foo/bar', '**/*'); + throw new Error('Expected untar() to reject'); + } catch (error) { + assertNonAbsoluteError(error); + } + }); + + it('extracts tarbar from source into destination, creating destination if necessary', async () => { + const destination = resolve(TMP, 'a/b/c/d/e/f'); + await untar(FOO_TAR_PATH, destination); + expect(await read(resolve(destination, 'foo_dir/bar.txt'))).toBe('bar\n'); + expect(await read(resolve(destination, 'foo_dir/foo/foo.txt'))).toBe('foo\n'); + }); + + it('passed thrid argument to Extract class, overriding path with destination', async () => { + const destination = resolve(TMP, 'a/b/c'); + + await untar(FOO_TAR_PATH, destination, { + path: '/dev/null', + strip: 1, + }); + + expect(await read(resolve(destination, 'bar.txt'))).toBe('bar\n'); + expect(await read(resolve(destination, 'foo/foo.txt'))).toBe('foo\n'); + }); +}); + +describe('gunzip()', () => { + it('rejects if source path is not absolute', async () => { + try { + await gunzip('foo/bar', '**/*'); + throw new Error('Expected gunzip() to reject'); + } catch (error) { + assertNonAbsoluteError(error); + } + }); + + it('rejects if destination path is not absolute', async () => { + try { + await gunzip(__dirname, '**/*'); + throw new Error('Expected gunzip() to reject'); + } catch (error) { + assertNonAbsoluteError(error); + } + }); + + it('rejects if neither path is not absolute', async () => { + try { + await gunzip('foo/bar', '**/*'); + throw new Error('Expected gunzip() to reject'); + } catch (error) { + assertNonAbsoluteError(error); + } + }); + + it('extracts gzip from source into destination, creating destination if necessary', async () => { + const destination = resolve(TMP, 'z/y/x/v/u/t/foo.txt'); + await gunzip(FOO_GZIP_PATH, destination); + expect(await read(resolve(destination))).toBe('foo\n'); + }); +}); diff --git a/src/dev/build/lib/scan_copy.test.ts b/src/dev/build/lib/integration_tests/scan_copy.test.ts similarity index 94% rename from src/dev/build/lib/scan_copy.test.ts rename to src/dev/build/lib/integration_tests/scan_copy.test.ts index ba693770445d..f81951c57531 100644 --- a/src/dev/build/lib/scan_copy.test.ts +++ b/src/dev/build/lib/integration_tests/scan_copy.test.ts @@ -22,14 +22,13 @@ import { resolve } from 'path'; import del from 'del'; -// @ts-ignore -import { getChildPaths, mkdirp, write } from './fs'; -import { scanCopy } from './scan_copy'; +import { getChildPaths } from '../fs'; +import { scanCopy } from '../scan_copy'; const IS_WINDOWS = process.platform === 'win32'; -const FIXTURES = resolve(__dirname, '__tests__/fixtures'); +const FIXTURES = resolve(__dirname, '../__fixtures__'); +const TMP = resolve(__dirname, '../__tmp__'); const WORLD_EXECUTABLE = resolve(FIXTURES, 'bin/world_executable'); -const TMP = resolve(__dirname, '__tests__/__tmp__'); const getCommonMode = (path: string) => statSync(path).mode.toString(8).slice(-3); diff --git a/src/dev/build/lib/integration_tests/watch_stdio_for_line.test.ts b/src/dev/build/lib/integration_tests/watch_stdio_for_line.test.ts new file mode 100644 index 000000000000..007a3bc631c6 --- /dev/null +++ b/src/dev/build/lib/integration_tests/watch_stdio_for_line.test.ts @@ -0,0 +1,52 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import execa from 'execa'; + +import { watchStdioForLine } from '../watch_stdio_for_line'; + +const onLogLine = jest.fn(); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +it('calls logFn with log lines', async () => { + const proc = execa(process.execPath, ['-e', 'console.log("hi")']); + await watchStdioForLine(proc, onLogLine); + expect(onLogLine.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "hi", + ], + ] + `); +}); + +it('send the proc SIGKILL if it logs a line matching exitAfter regexp', async function () { + const proc = execa(process.execPath, [require.resolve('../__fixtures__/log_on_sigint')]); + await watchStdioForLine(proc, onLogLine, /listening for SIGINT/); + expect(onLogLine.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "listening for SIGINT", + ], + ] + `); +}); diff --git a/src/dev/build/lib/platform.js b/src/dev/build/lib/platform.js deleted file mode 100644 index ab2672615e1c..000000000000 --- a/src/dev/build/lib/platform.js +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -export function createPlatform(name, architecture, buildName) { - return new (class Platform { - getName() { - return name; - } - - getArchitecture() { - return architecture; - } - - getBuildName() { - return buildName; - } - - getNodeArch() { - return `${name}-${architecture}`; - } - - isWindows() { - return name === 'win32'; - } - - isMac() { - return name === 'darwin'; - } - - isLinux() { - return name === 'linux'; - } - })(); -} diff --git a/src/dev/build/lib/platform.test.ts b/src/dev/build/lib/platform.test.ts new file mode 100644 index 000000000000..a93333c57e75 --- /dev/null +++ b/src/dev/build/lib/platform.test.ts @@ -0,0 +1,62 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Platform } from './platform'; + +describe('getName()', () => { + it('returns the name argument', () => { + expect(new Platform('win32', 'x64', 'foo').getName()).toBe('win32'); + }); +}); + +describe('getNodeArch()', () => { + it('returns the node arch for the passed name', () => { + expect(new Platform('win32', 'x64', 'foo').getNodeArch()).toBe('win32-x64'); + }); +}); + +describe('getBuildName()', () => { + it('returns the build name for the passed name', () => { + expect(new Platform('linux', 'arm64', 'linux-aarch64').getBuildName()).toBe('linux-aarch64'); + }); +}); + +describe('isWindows()', () => { + it('returns true if name is win32', () => { + expect(new Platform('win32', 'x64', 'foo').isWindows()).toBe(true); + expect(new Platform('linux', 'x64', 'foo').isWindows()).toBe(false); + expect(new Platform('darwin', 'x64', 'foo').isWindows()).toBe(false); + }); +}); + +describe('isLinux()', () => { + it('returns true if name is linux', () => { + expect(new Platform('win32', 'x64', 'foo').isLinux()).toBe(false); + expect(new Platform('linux', 'x64', 'foo').isLinux()).toBe(true); + expect(new Platform('darwin', 'x64', 'foo').isLinux()).toBe(false); + }); +}); + +describe('isMac()', () => { + it('returns true if name is darwin', () => { + expect(new Platform('win32', 'x64', 'foo').isMac()).toBe(false); + expect(new Platform('linux', 'x64', 'foo').isMac()).toBe(false); + expect(new Platform('darwin', 'x64', 'foo').isMac()).toBe(true); + }); +}); diff --git a/src/dev/build/lib/platform.ts b/src/dev/build/lib/platform.ts new file mode 100644 index 000000000000..f42c7eb7fba5 --- /dev/null +++ b/src/dev/build/lib/platform.ts @@ -0,0 +1,64 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export type PlatformName = 'win32' | 'darwin' | 'linux'; +export type PlatformArchitecture = 'x64' | 'arm64'; + +export class Platform { + constructor( + private name: PlatformName, + private architecture: PlatformArchitecture, + private buildName: string + ) {} + + getName() { + return this.name; + } + + getArchitecture() { + return this.architecture; + } + + getBuildName() { + return this.buildName; + } + + getNodeArch() { + return `${this.name}-${this.architecture}`; + } + + isWindows() { + return this.name === 'win32'; + } + + isMac() { + return this.name === 'darwin'; + } + + isLinux() { + return this.name === 'linux'; + } +} + +export const ALL_PLATFORMS = [ + new Platform('linux', 'x64', 'linux-x86_64'), + new Platform('linux', 'arm64', 'linux-aarch64'), + new Platform('darwin', 'x64', 'darwin-x86_64'), + new Platform('win32', 'x64', 'windows-x86_64'), +]; diff --git a/src/dev/build/lib/runner.test.ts b/src/dev/build/lib/runner.test.ts new file mode 100644 index 000000000000..0e17f2f590e3 --- /dev/null +++ b/src/dev/build/lib/runner.test.ts @@ -0,0 +1,248 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + ToolingLog, + ToolingLogCollectingWriter, + createStripAnsiSerializer, + createRecursiveSerializer, +} from '@kbn/dev-utils'; +import { Config } from './config'; +import { createRunner } from './runner'; +import { Build } from './build'; +import { isErrorLogged, markErrorLogged } from './errors'; + +jest.mock('./version_info'); + +const testWriter = new ToolingLogCollectingWriter(); +const log = new ToolingLog(); +log.setWriters([testWriter]); + +expect.addSnapshotSerializer(createStripAnsiSerializer()); + +const STACK_TRACE = /(\│\s+)at .+ \(.+\)$/; +const isStackTrace = (x: any) => typeof x === 'string' && STACK_TRACE.test(x); + +expect.addSnapshotSerializer( + createRecursiveSerializer( + (v) => Array.isArray(v) && v.some(isStackTrace), + (v) => { + const start = v.findIndex(isStackTrace); + v[start] = v[start].replace(STACK_TRACE, '$1'); + while (isStackTrace(v[start + 1])) v.splice(start + 1, 1); + return v; + } + ) +); + +beforeEach(() => { + testWriter.messages.length = 0; + jest.clearAllMocks(); +}); + +const setup = async (opts: { buildDefaultDist: boolean; buildOssDist: boolean }) => { + const config = await Config.create({ + isRelease: true, + targetAllPlatforms: true, + versionQualifier: '-SNAPSHOT', + }); + + const run = createRunner({ + config, + log, + ...opts, + }); + + return { config, run }; +}; + +describe('buildOssDist = true, buildDefaultDist = true', () => { + it('runs global task once, passing config and log', async () => { + const { config, run } = await setup({ + buildDefaultDist: true, + buildOssDist: true, + }); + + const mock = jest.fn(); + + await run({ + global: true, + description: 'foo', + run: mock, + }); + + expect(mock).toHaveBeenCalledTimes(1); + expect(mock).toHaveBeenLastCalledWith(config, log, [expect.any(Build), expect.any(Build)]); + }); + + it('calls local tasks twice, passing each build', async () => { + const { config, run } = await setup({ + buildDefaultDist: true, + buildOssDist: true, + }); + + const mock = jest.fn(); + + await run({ + description: 'foo', + run: mock, + }); + + expect(mock).toHaveBeenCalledTimes(2); + expect(mock).toHaveBeenCalledWith(config, log, expect.any(Build)); + }); +}); + +describe('just default dist', () => { + it('runs global task once, passing config and log', async () => { + const { config, run } = await setup({ + buildDefaultDist: true, + buildOssDist: false, + }); + + const mock = jest.fn(); + + await run({ + global: true, + description: 'foo', + run: mock, + }); + + expect(mock).toHaveBeenCalledTimes(1); + expect(mock).toHaveBeenLastCalledWith(config, log, [expect.any(Build)]); + }); + + it('calls local tasks once, passing the default build', async () => { + const { config, run } = await setup({ + buildDefaultDist: true, + buildOssDist: false, + }); + + const mock = jest.fn(); + + await run({ + description: 'foo', + run: mock, + }); + + expect(mock).toHaveBeenCalledTimes(1); + expect(mock).toHaveBeenCalledWith(config, log, expect.any(Build)); + const [args] = mock.mock.calls; + const [, , build] = args; + if (build.isOss()) { + throw new Error('expected build to be the default dist, not the oss dist'); + } + }); +}); + +describe('just oss dist', () => { + it('runs global task once, passing config and log', async () => { + const { config, run } = await setup({ + buildDefaultDist: false, + buildOssDist: true, + }); + + const mock = jest.fn(); + + await run({ + global: true, + description: 'foo', + run: mock, + }); + + expect(mock).toHaveBeenCalledTimes(1); + expect(mock).toHaveBeenLastCalledWith(config, log, [expect.any(Build)]); + }); + + it('calls local tasks once, passing the oss build', async () => { + const { config, run } = await setup({ + buildDefaultDist: false, + buildOssDist: true, + }); + + const mock = jest.fn(); + + await run({ + description: 'foo', + run: mock, + }); + + expect(mock).toHaveBeenCalledTimes(1); + expect(mock).toHaveBeenCalledWith(config, log, expect.any(Build)); + const [args] = mock.mock.calls; + const [, , build] = args; + if (!build.isOss()) { + throw new Error('expected build to be the oss dist, not the default dist'); + } + }); +}); + +describe('task rejection', () => { + it('rejects, logs error, and marks error logged', async () => { + const { run } = await setup({ + buildDefaultDist: true, + buildOssDist: false, + }); + + const error = new Error('FOO'); + expect(isErrorLogged(error)).toBe(false); + + const promise = run({ + description: 'foo', + async run() { + throw error; + }, + }); + + await expect(promise).rejects.toThrowErrorMatchingInlineSnapshot(`"FOO"`); + expect(testWriter.messages).toMatchInlineSnapshot(` + Array [ + " info [ kibana ] foo", + " │ERROR failure 0 sec", + " │ERROR Error: FOO", + " │ ", + "", + ] + `); + expect(isErrorLogged(error)).toBe(true); + }); + + it('just rethrows errors that have already been logged', async () => { + const { run } = await setup({ + buildDefaultDist: true, + buildOssDist: false, + }); + + const error = markErrorLogged(new Error('FOO')); + const promise = run({ + description: 'foo', + async run() { + throw error; + }, + }); + + await expect(promise).rejects.toThrowErrorMatchingInlineSnapshot(`"FOO"`); + expect(testWriter.messages).toMatchInlineSnapshot(` + Array [ + " info [ kibana ] foo", + "", + ] + `); + }); +}); diff --git a/src/dev/build/lib/runner.js b/src/dev/build/lib/runner.ts similarity index 72% rename from src/dev/build/lib/runner.js rename to src/dev/build/lib/runner.ts index 363cfbe97afa..6b7d175bb229 100644 --- a/src/dev/build/lib/runner.js +++ b/src/dev/build/lib/runner.ts @@ -18,13 +18,33 @@ */ import chalk from 'chalk'; +import { ToolingLog } from '@kbn/dev-utils'; import { isErrorLogged, markErrorLogged } from './errors'; +import { Build } from './build'; +import { Config } from './config'; -import { createBuild } from './build'; +interface Options { + config: Config; + log: ToolingLog; + buildOssDist: boolean; + buildDefaultDist: boolean; +} + +export interface GlobalTask { + global: true; + description: string; + run(config: Config, log: ToolingLog, builds: Build[]): Promise; +} + +export interface Task { + global?: false; + description: string; + run(config: Config, log: ToolingLog, build: Build): Promise; +} -export function createRunner({ config, log, buildOssDist, buildDefaultDist }) { - async function execTask(desc, task, ...args) { +export function createRunner({ config, log, buildOssDist, buildDefaultDist }: Options) { + async function execTask(desc: string, task: Task | GlobalTask, lastArg: any) { log.info(desc); log.indent(4); @@ -37,11 +57,11 @@ export function createRunner({ config, log, buildOssDist, buildDefaultDist }) { }; try { - await task.run(config, log, ...args); + await task.run(config, log, lastArg); log.success(chalk.green('✓'), time()); } catch (error) { if (!isErrorLogged(error)) { - log.error('failure', time()); + log.error(`failure ${time()}`); log.error(error); markErrorLogged(error); } @@ -53,22 +73,12 @@ export function createRunner({ config, log, buildOssDist, buildDefaultDist }) { } } - const builds = []; + const builds: Build[] = []; if (buildDefaultDist) { - builds.push( - createBuild({ - config, - oss: false, - }) - ); + builds.push(new Build(config, false)); } if (buildOssDist) { - builds.push( - createBuild({ - config, - oss: true, - }) - ); + builds.push(new Build(config, true)); } /** @@ -76,11 +86,8 @@ export function createRunner({ config, log, buildOssDist, buildDefaultDist }) { * `config`: an object with methods for determining top-level config values, see `./config.js` * `log`: an instance of the `ToolingLog`, see `../../tooling_log/tooling_log.js` * `builds?`: If task does is not defined as `global: true` then it is called for each build and passed each one here. - * - * @param {Task} task - * @return {Promise} */ - return async function run(task) { + return async function run(task: Task | GlobalTask) { if (task.global) { await execTask(chalk`{dim [ global ]} ${task.description}`, task, builds); } else { diff --git a/src/dev/build/lib/version_info.test.ts b/src/dev/build/lib/version_info.test.ts new file mode 100644 index 000000000000..1b0c71bf9220 --- /dev/null +++ b/src/dev/build/lib/version_info.test.ts @@ -0,0 +1,62 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import pkg from '../../../../package.json'; +import { getVersionInfo } from './version_info'; + +describe('isRelease = true', () => { + it('returns unchanged package.version, build sha, and build number', async () => { + const versionInfo = await getVersionInfo({ + isRelease: true, + pkg, + }); + + expect(versionInfo).toHaveProperty('buildVersion', pkg.version); + expect(versionInfo).toHaveProperty('buildSha', expect.stringMatching(/^[0-9a-f]{40}$/)); + expect(versionInfo).toHaveProperty('buildNumber'); + expect(versionInfo.buildNumber).toBeGreaterThan(1000); + }); +}); + +describe('isRelease = false', () => { + it('returns snapshot version, build sha, and build number', async () => { + const versionInfo = await getVersionInfo({ + isRelease: false, + pkg, + }); + + expect(versionInfo).toHaveProperty('buildVersion', expect.stringContaining(pkg.version)); + expect(versionInfo).toHaveProperty('buildVersion', expect.stringMatching(/-SNAPSHOT$/)); + expect(versionInfo).toHaveProperty('buildSha', expect.stringMatching(/^[0-9a-f]{40}$/)); + expect(versionInfo).toHaveProperty('buildNumber'); + expect(versionInfo.buildNumber).toBeGreaterThan(1000); + }); +}); + +describe('versionQualifier', () => { + it('appends a version qualifier', async () => { + const versionInfo = await getVersionInfo({ + isRelease: true, + versionQualifier: 'beta55', + pkg, + }); + + expect(versionInfo).toHaveProperty('buildVersion', pkg.version + '-beta55'); + }); +}); diff --git a/src/dev/build/lib/version_info.js b/src/dev/build/lib/version_info.ts similarity index 84% rename from src/dev/build/lib/version_info.js rename to src/dev/build/lib/version_info.ts index 3a053afdbff8..958112c524ba 100644 --- a/src/dev/build/lib/version_info.js +++ b/src/dev/build/lib/version_info.ts @@ -34,7 +34,19 @@ async function getBuildNumber() { return parseFloat(wc.stdout.trim()); } -export async function getVersionInfo({ isRelease, versionQualifier, pkg }) { +interface Options { + isRelease: boolean; + versionQualifier?: string; + pkg: { + version: string; + }; +} + +type ResolvedType> = T extends Promise ? X : never; + +export type VersionInfo = ResolvedType>; + +export async function getVersionInfo({ isRelease, versionQualifier, pkg }: Options) { const buildVersion = pkg.version.concat( versionQualifier ? `-${versionQualifier}` : '', isRelease ? '' : '-SNAPSHOT' diff --git a/src/legacy/utils/watch_stdio_for_line.js b/src/dev/build/lib/watch_stdio_for_line.ts similarity index 83% rename from src/legacy/utils/watch_stdio_for_line.js rename to src/dev/build/lib/watch_stdio_for_line.ts index 01323b4d4e96..2322d017abc6 100644 --- a/src/legacy/utils/watch_stdio_for_line.js +++ b/src/dev/build/lib/watch_stdio_for_line.ts @@ -18,8 +18,13 @@ */ import { Transform } from 'stream'; +import { ExecaChildProcess } from 'execa'; -import { createPromiseFromStreams, createSplitStream, createMapStream } from './streams'; +import { + createPromiseFromStreams, + createSplitStream, + createMapStream, +} from '../../../legacy/utils/streams'; // creates a stream that skips empty lines unless they are followed by // another line, preventing the empty lines produced by splitStream @@ -27,7 +32,7 @@ function skipLastEmptyLineStream() { let skippedEmptyLine = false; return new Transform({ objectMode: true, - transform(line, enc, cb) { + transform(line, _, cb) { if (skippedEmptyLine) { this.push(''); skippedEmptyLine = false; @@ -37,14 +42,18 @@ function skipLastEmptyLineStream() { skippedEmptyLine = true; return cb(); } else { - return cb(null, line); + return cb(undefined, line); } }, }); } -export async function watchStdioForLine(proc, logFn, exitAfter) { - function onLogLine(line) { +export async function watchStdioForLine( + proc: ExecaChildProcess, + logFn: (line: string) => void, + exitAfter?: RegExp +) { + function onLogLine(line: string) { logFn(line); if (exitAfter && exitAfter.test(line)) { diff --git a/src/dev/build/tasks/bin/copy_bin_scripts_task.js b/src/dev/build/tasks/bin/copy_bin_scripts_task.ts similarity index 92% rename from src/dev/build/tasks/bin/copy_bin_scripts_task.js rename to src/dev/build/tasks/bin/copy_bin_scripts_task.ts index f620f12b17d8..d0ef0a58eebd 100644 --- a/src/dev/build/tasks/bin/copy_bin_scripts_task.js +++ b/src/dev/build/tasks/bin/copy_bin_scripts_task.ts @@ -17,9 +17,9 @@ * under the License. */ -import { copyAll } from '../../lib'; +import { copyAll, Task } from '../../lib'; -export const CopyBinScriptsTask = { +export const CopyBinScripts: Task = { description: 'Copying bin scripts into platform-generic build directory', async run(config, log, build) { diff --git a/src/dev/build/tasks/bin/index.js b/src/dev/build/tasks/bin/index.ts similarity index 92% rename from src/dev/build/tasks/bin/index.js rename to src/dev/build/tasks/bin/index.ts index e970ac5ec044..dc30f626decc 100644 --- a/src/dev/build/tasks/bin/index.js +++ b/src/dev/build/tasks/bin/index.ts @@ -17,4 +17,4 @@ * under the License. */ -export { CopyBinScriptsTask } from './copy_bin_scripts_task'; +export * from './copy_bin_scripts_task'; diff --git a/src/dev/build/tasks/build_kibana_platform_plugins.js b/src/dev/build/tasks/build_kibana_platform_plugins.ts similarity index 92% rename from src/dev/build/tasks/build_kibana_platform_plugins.js rename to src/dev/build/tasks/build_kibana_platform_plugins.ts index 153a3120f896..08637677fcfb 100644 --- a/src/dev/build/tasks/build_kibana_platform_plugins.js +++ b/src/dev/build/tasks/build_kibana_platform_plugins.ts @@ -25,9 +25,11 @@ import { reportOptimizerStats, } from '@kbn/optimizer'; -export const BuildKibanaPlatformPluginsTask = { +import { Task } from '../lib'; + +export const BuildKibanaPlatformPlugins: Task = { description: 'Building distributable versions of Kibana platform plugins', - async run(_, log, build) { + async run(config, log, build) { const optimizerConfig = OptimizerConfig.create({ repoRoot: build.resolvePath(), cache: false, diff --git a/src/dev/build/tasks/build_packages_task.js b/src/dev/build/tasks/build_packages_task.ts similarity index 97% rename from src/dev/build/tasks/build_packages_task.js rename to src/dev/build/tasks/build_packages_task.ts index b31855aa42da..dd4e88f9c2b7 100644 --- a/src/dev/build/tasks/build_packages_task.js +++ b/src/dev/build/tasks/build_packages_task.ts @@ -18,7 +18,8 @@ */ import { buildProductionProjects } from '@kbn/pm'; -import { mkdirp } from '../lib'; + +import { mkdirp, Task } from '../lib'; /** * High-level overview of how we enable shared packages in production: @@ -66,8 +67,7 @@ import { mkdirp } from '../lib'; * in some way by Kibana itself in production, as it won't otherwise be * included in the production build. */ - -export const BuildPackagesTask = { +export const BuildPackages: Task = { description: 'Building distributable versions of packages', async run(config, log, build) { await mkdirp(config.resolveFromRepo('target')); diff --git a/src/dev/build/tasks/clean_tasks.js b/src/dev/build/tasks/clean_tasks.ts similarity index 92% rename from src/dev/build/tasks/clean_tasks.js rename to src/dev/build/tasks/clean_tasks.ts index ff5c3b3a73dd..b519b17e591a 100644 --- a/src/dev/build/tasks/clean_tasks.js +++ b/src/dev/build/tasks/clean_tasks.ts @@ -19,9 +19,9 @@ import minimatch from 'minimatch'; -import { deleteAll, deleteEmptyFolders, scanDelete } from '../lib'; +import { deleteAll, deleteEmptyFolders, scanDelete, Task, GlobalTask } from '../lib'; -export const CleanTask = { +export const Clean: GlobalTask = { global: true, description: 'Cleaning artifacts from previous builds', @@ -37,7 +37,7 @@ export const CleanTask = { }, }; -export const CleanPackagesTask = { +export const CleanPackages: Task = { description: 'Cleaning source for packages that are now installed in node_modules', async run(config, log, build) { @@ -45,7 +45,7 @@ export const CleanPackagesTask = { }, }; -export const CleanTypescriptTask = { +export const CleanTypescript: Task = { description: 'Cleaning typescript source files that have been transpiled to JS', async run(config, log, build) { @@ -59,11 +59,11 @@ export const CleanTypescriptTask = { }, }; -export const CleanExtraFilesFromModulesTask = { +export const CleanExtraFilesFromModules: Task = { description: 'Cleaning tests, examples, docs, etc. from node_modules', async run(config, log, build) { - const makeRegexps = (patterns) => + const makeRegexps = (patterns: string[]) => patterns.map((pattern) => minimatch.makeRe(pattern, { nocase: true })); const regularExpressions = makeRegexps([ @@ -181,7 +181,7 @@ export const CleanExtraFilesFromModulesTask = { }, }; -export const CleanExtraBinScriptsTask = { +export const CleanExtraBinScripts: Task = { description: 'Cleaning extra bin/* scripts from platform-specific builds', async run(config, log, build) { @@ -201,7 +201,7 @@ export const CleanExtraBinScriptsTask = { }, }; -export const CleanEmptyFoldersTask = { +export const CleanEmptyFolders: Task = { description: 'Cleaning all empty folders recursively', async run(config, log, build) { diff --git a/src/dev/build/tasks/copy_source_task.js b/src/dev/build/tasks/copy_source_task.ts similarity index 95% rename from src/dev/build/tasks/copy_source_task.js rename to src/dev/build/tasks/copy_source_task.ts index 52809449ba33..221c9162bd2a 100644 --- a/src/dev/build/tasks/copy_source_task.js +++ b/src/dev/build/tasks/copy_source_task.ts @@ -17,9 +17,9 @@ * under the License. */ -import { copyAll } from '../lib'; +import { copyAll, Task } from '../lib'; -export const CopySourceTask = { +export const CopySource: Task = { description: 'Copying source into platform-generic build directory', async run(config, log, build) { diff --git a/src/dev/build/tasks/create_archives_sources_task.js b/src/dev/build/tasks/create_archives_sources_task.ts similarity index 95% rename from src/dev/build/tasks/create_archives_sources_task.js rename to src/dev/build/tasks/create_archives_sources_task.ts index 76f08bd3d2e4..72f875b43193 100644 --- a/src/dev/build/tasks/create_archives_sources_task.js +++ b/src/dev/build/tasks/create_archives_sources_task.ts @@ -17,10 +17,10 @@ * under the License. */ -import { scanCopy } from '../lib'; +import { scanCopy, Task } from '../lib'; import { getNodeDownloadInfo } from './nodejs'; -export const CreateArchivesSourcesTask = { +export const CreateArchivesSources: Task = { description: 'Creating platform-specific archive source directories', async run(config, log, build) { await Promise.all( diff --git a/src/dev/build/tasks/create_archives_task.js b/src/dev/build/tasks/create_archives_task.ts similarity index 80% rename from src/dev/build/tasks/create_archives_task.js rename to src/dev/build/tasks/create_archives_task.ts index 541b9551dbc9..3ffb1afef746 100644 --- a/src/dev/build/tasks/create_archives_task.js +++ b/src/dev/build/tasks/create_archives_task.ts @@ -23,11 +23,11 @@ import { promisify } from 'util'; import { CiStatsReporter } from '@kbn/dev-utils'; -import { mkdirp, compress } from '../lib'; +import { mkdirp, compressTar, compressZip, Task } from '../lib'; const asyncStat = promisify(Fs.stat); -export const CreateArchivesTask = { +export const CreateArchives: Task = { description: 'Creating the archives for each platform', async run(config, log, build) { @@ -49,19 +49,16 @@ export const CreateArchivesTask = { path: destination, }); - await compress( - 'zip', - { - archiverOptions: { - zlib: { - level: 9, - }, + await compressZip({ + source, + destination, + archiverOptions: { + zlib: { + level: 9, }, - createRootDirectory: true, }, - source, - destination - ); + createRootDirectory: true, + }); break; case '.gz': @@ -70,20 +67,17 @@ export const CreateArchivesTask = { path: destination, }); - await compress( - 'tar', - { - archiverOptions: { - gzip: true, - gzipOptions: { - level: 9, - }, + await compressTar({ + source, + destination, + archiverOptions: { + gzip: true, + gzipOptions: { + level: 9, }, - createRootDirectory: true, }, - source, - destination - ); + createRootDirectory: true, + }); break; default: diff --git a/src/dev/build/tasks/create_empty_dirs_and_files_task.js b/src/dev/build/tasks/create_empty_dirs_and_files_task.ts similarity index 92% rename from src/dev/build/tasks/create_empty_dirs_and_files_task.js rename to src/dev/build/tasks/create_empty_dirs_and_files_task.ts index 6bf059ca9519..a72c6a459833 100644 --- a/src/dev/build/tasks/create_empty_dirs_and_files_task.js +++ b/src/dev/build/tasks/create_empty_dirs_and_files_task.ts @@ -17,9 +17,9 @@ * under the License. */ -import { mkdirp, write } from '../lib'; +import { mkdirp, write, Task } from '../lib'; -export const CreateEmptyDirsAndFilesTask = { +export const CreateEmptyDirsAndFiles: Task = { description: 'Creating some empty directories and files to prevent file-permission issues', async run(config, log, build) { diff --git a/src/dev/build/tasks/create_package_json_task.js b/src/dev/build/tasks/create_package_json_task.ts similarity index 92% rename from src/dev/build/tasks/create_package_json_task.js rename to src/dev/build/tasks/create_package_json_task.ts index e7a410b4c635..5d7fdb9eae2f 100644 --- a/src/dev/build/tasks/create_package_json_task.js +++ b/src/dev/build/tasks/create_package_json_task.ts @@ -19,9 +19,9 @@ import { copyWorkspacePackages } from '@kbn/pm'; -import { read, write } from '../lib'; +import { read, write, Task } from '../lib'; -export const CreatePackageJsonTask = { +export const CreatePackageJson: Task = { description: 'Creating build-ready version of package.json', async run(config, log, build) { @@ -38,7 +38,7 @@ export const CreatePackageJsonTask = { number: config.getBuildNumber(), sha: config.getBuildSha(), distributable: true, - release: config.isRelease(), + release: config.isRelease, }, repository: pkg.repository, engines: { @@ -59,7 +59,7 @@ export const CreatePackageJsonTask = { }, }; -export const RemovePackageJsonDepsTask = { +export const RemovePackageJsonDeps: Task = { description: 'Removing dependencies from package.json', async run(config, log, build) { @@ -74,7 +74,7 @@ export const RemovePackageJsonDepsTask = { }, }; -export const RemoveWorkspacesTask = { +export const RemoveWorkspaces: Task = { description: 'Remove workspace artifacts', async run(config, log, build) { diff --git a/src/dev/build/tasks/create_readme_task.js b/src/dev/build/tasks/create_readme_task.ts similarity index 93% rename from src/dev/build/tasks/create_readme_task.js rename to src/dev/build/tasks/create_readme_task.ts index 8d60dad9b563..379ca45f43e2 100644 --- a/src/dev/build/tasks/create_readme_task.js +++ b/src/dev/build/tasks/create_readme_task.ts @@ -17,9 +17,9 @@ * under the License. */ -import { write, read } from '../lib'; +import { write, read, Task } from '../lib'; -export const CreateReadmeTask = { +export const CreateReadme: Task = { description: 'Creating README.md file', async run(config, log, build) { diff --git a/src/dev/build/tasks/index.js b/src/dev/build/tasks/index.ts similarity index 92% rename from src/dev/build/tasks/index.js rename to src/dev/build/tasks/index.ts index 0a3a67313d6a..4c00e56faee6 100644 --- a/src/dev/build/tasks/index.js +++ b/src/dev/build/tasks/index.ts @@ -27,7 +27,6 @@ export * from './create_archives_task'; export * from './create_empty_dirs_and_files_task'; export * from './create_package_json_task'; export * from './create_readme_task'; -export * from './install_chromium'; export * from './install_dependencies_task'; export * from './license_file_task'; export * from './nodejs'; @@ -41,3 +40,6 @@ export * from './transpile_scss_task'; export * from './uuid_verification_task'; export * from './verify_env_task'; export * from './write_sha_sums_task'; + +// @ts-expect-error this module can't be TS because it ends up pulling x-pack into Kibana +export { InstallChromium } from './install_chromium'; diff --git a/src/dev/build/tasks/install_chromium.js b/src/dev/build/tasks/install_chromium.js index c5878b23d43a..3ae36d1615cc 100644 --- a/src/dev/build/tasks/install_chromium.js +++ b/src/dev/build/tasks/install_chromium.js @@ -17,11 +17,12 @@ * under the License. */ +import { first } from 'rxjs/operators'; + // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { installBrowser } from '../../../../x-pack/plugins/reporting/server/browsers/install'; -import { first } from 'rxjs/operators'; -export const InstallChromiumTask = { +export const InstallChromium = { description: 'Installing Chromium', async run(config, log, build) { @@ -32,6 +33,7 @@ export const InstallChromiumTask = { log.info(`Installing Chromium for ${platform.getName()}-${platform.getArchitecture()}`); const { binaryPath$ } = installBrowser( + // TODO: https://github.com/elastic/kibana/issues/72496 log, build.resolvePathForPlatform(platform, 'x-pack/plugins/reporting/chromium'), platform.getName(), diff --git a/src/dev/build/tasks/install_dependencies_task.js b/src/dev/build/tasks/install_dependencies_task.ts similarity index 94% rename from src/dev/build/tasks/install_dependencies_task.js rename to src/dev/build/tasks/install_dependencies_task.ts index 5191899cd94d..32fd23859456 100644 --- a/src/dev/build/tasks/install_dependencies_task.js +++ b/src/dev/build/tasks/install_dependencies_task.ts @@ -19,7 +19,9 @@ import { Project } from '@kbn/pm'; -export const InstallDependenciesTask = { +import { Task } from '../lib'; + +export const InstallDependencies: Task = { description: 'Installing node_modules, including production builds of packages', async run(config, log, build) { diff --git a/src/dev/build/tasks/license_file_task.js b/src/dev/build/tasks/license_file_task.ts similarity index 94% rename from src/dev/build/tasks/license_file_task.js rename to src/dev/build/tasks/license_file_task.ts index 1a7c70738aa4..f1b65501d076 100644 --- a/src/dev/build/tasks/license_file_task.js +++ b/src/dev/build/tasks/license_file_task.ts @@ -17,9 +17,9 @@ * under the License. */ -import { write, read } from '../lib'; +import { write, read, Task } from '../lib'; -export const UpdateLicenseFileTask = { +export const UpdateLicenseFile: Task = { description: 'Updating LICENSE.txt file', async run(config, log, build) { diff --git a/src/dev/build/tasks/nodejs/__tests__/download_node_builds_task.js b/src/dev/build/tasks/nodejs/__tests__/download_node_builds_task.js deleted file mode 100644 index c1764d06b43b..000000000000 --- a/src/dev/build/tasks/nodejs/__tests__/download_node_builds_task.js +++ /dev/null @@ -1,97 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import sinon from 'sinon'; -import expect from '@kbn/expect'; - -import * as NodeShasumsNS from '../node_shasums'; -import * as NodeDownloadInfoNS from '../node_download_info'; -import * as DownloadNS from '../../../lib/download'; // sinon can't stub '../../../lib' properly -import { DownloadNodeBuildsTask } from '../download_node_builds_task'; - -describe('src/dev/build/tasks/nodejs/download_node_builds_task', () => { - const sandbox = sinon.createSandbox(); - afterEach(() => { - sandbox.restore(); - }); - - function setup({ failOnUrl } = {}) { - const platforms = [{ getName: () => 'foo' }, { getName: () => 'bar' }]; - - const log = {}; - const config = { - getNodePlatforms: () => platforms, - getNodeVersion: () => 'nodeVersion', - }; - - sandbox.stub(NodeDownloadInfoNS, 'getNodeDownloadInfo').callsFake((config, platform) => { - return { - url: `${platform.getName()}:url`, - downloadPath: `${platform.getName()}:downloadPath`, - downloadName: `${platform.getName()}:downloadName`, - }; - }); - - sandbox.stub(NodeShasumsNS, 'getNodeShasums').returns({ - 'foo:downloadName': 'foo:sha256', - 'bar:downloadName': 'bar:sha256', - }); - - sandbox.stub(DownloadNS, 'download').callsFake(({ url }) => { - if (url === failOnUrl) { - throw new Error('Download failed for reasons'); - } - }); - - return { log, config }; - } - - it('downloads node builds for each platform', async () => { - const { log, config } = setup(); - - await DownloadNodeBuildsTask.run(config, log); - - sinon.assert.calledTwice(DownloadNS.download); - sinon.assert.calledWithExactly(DownloadNS.download, { - log, - url: 'foo:url', - sha256: 'foo:sha256', - destination: 'foo:downloadPath', - retries: 3, - }); - sinon.assert.calledWithExactly(DownloadNS.download, { - log, - url: 'bar:url', - sha256: 'bar:sha256', - destination: 'bar:downloadPath', - retries: 3, - }); - }); - - it('rejects if any download fails', async () => { - const { config, log } = setup({ failOnUrl: 'foo:url' }); - - try { - await DownloadNodeBuildsTask.run(config, log); - throw new Error('Expected DownloadNodeBuildsTask to reject'); - } catch (error) { - expect(error).to.have.property('message').be('Download failed for reasons'); - } - }); -}); diff --git a/src/dev/build/tasks/nodejs/__tests__/extract_node_builds_task.js b/src/dev/build/tasks/nodejs/__tests__/extract_node_builds_task.js deleted file mode 100644 index efb7aaa3a220..000000000000 --- a/src/dev/build/tasks/nodejs/__tests__/extract_node_builds_task.js +++ /dev/null @@ -1,93 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import sinon from 'sinon'; -import { resolve } from 'path'; -import * as NodeDownloadInfoNS from '../node_download_info'; -import * as FsNS from '../../../lib/fs'; -import { ExtractNodeBuildsTask } from '../extract_node_builds_task'; - -describe('src/dev/build/tasks/node_extract_node_builds_task', () => { - const sandbox = sinon.createSandbox(); - afterEach(() => { - sandbox.restore(); - }); - - it('copies downloadPath to extractDir/node.exe for windows platform', async () => { - sandbox.stub(NodeDownloadInfoNS, 'getNodeDownloadInfo').returns({ - downloadPath: 'downloadPath', - extractDir: 'extractDir', - }); - - sandbox.stub(ExtractNodeBuildsTask, 'copyWindows'); - sandbox.stub(FsNS, 'untar'); - - const platform = { - isWindows: () => true, - }; - - const config = { - getNodePlatforms: () => [platform], - }; - - await ExtractNodeBuildsTask.run(config); - - sinon.assert.calledOnce(NodeDownloadInfoNS.getNodeDownloadInfo); - sinon.assert.calledWithExactly(NodeDownloadInfoNS.getNodeDownloadInfo, config, platform); - - sinon.assert.calledOnce(ExtractNodeBuildsTask.copyWindows); - sinon.assert.calledWithExactly( - ExtractNodeBuildsTask.copyWindows, - 'downloadPath', - resolve('extractDir/node.exe') - ); - - sinon.assert.notCalled(FsNS.untar); - }); - - it('untars downloadPath to extractDir, stripping the top level of the archive, for non-windows platforms', async () => { - sandbox.stub(NodeDownloadInfoNS, 'getNodeDownloadInfo').returns({ - downloadPath: 'downloadPath', - extractDir: 'extractDir', - }); - - sandbox.stub(ExtractNodeBuildsTask, 'copyWindows'); - sandbox.stub(FsNS, 'untar'); - - const platform = { - isWindows: () => false, - }; - - const config = { - getNodePlatforms: () => [platform], - }; - - await ExtractNodeBuildsTask.run(config); - - sinon.assert.calledOnce(NodeDownloadInfoNS.getNodeDownloadInfo); - sinon.assert.calledWithExactly(NodeDownloadInfoNS.getNodeDownloadInfo, config, platform); - - sinon.assert.notCalled(ExtractNodeBuildsTask.copyWindows); - - sinon.assert.calledOnce(FsNS.untar); - sinon.assert.calledWithExactly(FsNS.untar, 'downloadPath', 'extractDir', { - strip: 1, - }); - }); -}); diff --git a/src/dev/build/tasks/nodejs/__tests__/verify_existing_node_builds_task.js b/src/dev/build/tasks/nodejs/__tests__/verify_existing_node_builds_task.js deleted file mode 100644 index a8f732a869d2..000000000000 --- a/src/dev/build/tasks/nodejs/__tests__/verify_existing_node_builds_task.js +++ /dev/null @@ -1,106 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import sinon from 'sinon'; -import expect from '@kbn/expect'; - -import * as NodeShasumsNS from '../node_shasums'; -import * as NodeDownloadInfoNS from '../node_download_info'; -import * as FsNS from '../../../lib/fs'; -import { VerifyExistingNodeBuildsTask } from '../verify_existing_node_builds_task'; - -describe('src/dev/build/tasks/nodejs/verify_existing_node_builds_task', () => { - const sandbox = sinon.createSandbox(); - afterEach(() => { - sandbox.restore(); - }); - - function setup({ nodeShasums } = {}) { - const platforms = [ - { getName: () => 'foo', getNodeArch: () => 'foo:nodeArch' }, - { getName: () => 'bar', getNodeArch: () => 'bar:nodeArch' }, - ]; - - const log = { success: sinon.stub() }; - const config = { - getNodePlatforms: () => platforms, - getNodeVersion: () => 'nodeVersion', - }; - - sandbox.stub(NodeDownloadInfoNS, 'getNodeDownloadInfo').callsFake((config, platform) => { - return { - url: `${platform.getName()}:url`, - downloadPath: `${platform.getName()}:downloadPath`, - downloadName: `${platform.getName()}:downloadName`, - }; - }); - - sandbox.stub(NodeShasumsNS, 'getNodeShasums').returns( - nodeShasums || { - 'foo:downloadName': 'foo:sha256', - 'bar:downloadName': 'bar:sha256', - } - ); - - sandbox.stub(FsNS, 'getFileHash').callsFake((path) => { - switch (path) { - case 'foo:downloadPath': - return 'foo:sha256'; - case 'bar:downloadPath': - return 'bar:sha256'; - } - }); - - return { log, config, platforms }; - } - - it('downloads node builds for each platform', async () => { - const { log, config, platforms } = setup(); - - await VerifyExistingNodeBuildsTask.run(config, log); - - sinon.assert.calledOnce(NodeShasumsNS.getNodeShasums); - - sinon.assert.calledTwice(NodeDownloadInfoNS.getNodeDownloadInfo); - sinon.assert.calledWithExactly(NodeDownloadInfoNS.getNodeDownloadInfo, config, platforms[0]); - sinon.assert.calledWithExactly(NodeDownloadInfoNS.getNodeDownloadInfo, config, platforms[1]); - - sinon.assert.calledTwice(FsNS.getFileHash); - sinon.assert.calledWithExactly(FsNS.getFileHash, 'foo:downloadPath', 'sha256'); - sinon.assert.calledWithExactly(FsNS.getFileHash, 'bar:downloadPath', 'sha256'); - }); - - it('rejects if any download has an incorrect sha256', async () => { - const { config, log } = setup({ - nodeShasums: { - 'foo:downloadName': 'foo:sha256', - 'bar:downloadName': 'bar:invalid', - }, - }); - - try { - await VerifyExistingNodeBuildsTask.run(config, log); - throw new Error('Expected VerifyExistingNodeBuildsTask to reject'); - } catch (error) { - expect(error) - .to.have.property('message') - .be('Download at bar:downloadPath does not match expected checksum bar:sha256'); - } - }); -}); diff --git a/src/dev/build/tasks/nodejs/clean_node_builds_task.js b/src/dev/build/tasks/nodejs/clean_node_builds_task.ts similarity index 93% rename from src/dev/build/tasks/nodejs/clean_node_builds_task.js rename to src/dev/build/tasks/nodejs/clean_node_builds_task.ts index a34e65a39411..9deeb9f73de2 100644 --- a/src/dev/build/tasks/nodejs/clean_node_builds_task.js +++ b/src/dev/build/tasks/nodejs/clean_node_builds_task.ts @@ -17,9 +17,9 @@ * under the License. */ -import { deleteAll } from '../../lib'; +import { deleteAll, Task } from '../../lib'; -export const CleanNodeBuildsTask = { +export const CleanNodeBuilds: Task = { description: 'Cleaning npm from node', async run(config, log, build) { diff --git a/src/dev/build/tasks/nodejs/download_node_builds_task.test.ts b/src/dev/build/tasks/nodejs/download_node_builds_task.test.ts new file mode 100644 index 000000000000..6f08c8aa6975 --- /dev/null +++ b/src/dev/build/tasks/nodejs/download_node_builds_task.test.ts @@ -0,0 +1,136 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + ToolingLog, + ToolingLogCollectingWriter, + createAnyInstanceSerializer, +} from '@kbn/dev-utils'; + +import { Config, Platform } from '../../lib'; +import { DownloadNodeBuilds } from './download_node_builds_task'; + +// import * as NodeShasumsNS from '../node_shasums'; +// import * as NodeDownloadInfoNS from '../node_download_info'; +// import * as DownloadNS from '../../../lib/download'; +// import { DownloadNodeBuilds } from '../download_node_builds_task'; +jest.mock('./node_shasums'); +jest.mock('./node_download_info'); +jest.mock('../../lib/download'); + +expect.addSnapshotSerializer(createAnyInstanceSerializer(ToolingLog)); + +const { getNodeDownloadInfo } = jest.requireMock('./node_download_info'); +const { getNodeShasums } = jest.requireMock('./node_shasums'); +const { download } = jest.requireMock('../../lib/download'); + +const log = new ToolingLog(); +const testWriter = new ToolingLogCollectingWriter(); +log.setWriters([testWriter]); + +beforeEach(() => { + testWriter.messages.length = 0; + jest.clearAllMocks(); +}); + +async function setup({ failOnUrl }: { failOnUrl?: string } = {}) { + const config = await Config.create({ + isRelease: true, + targetAllPlatforms: true, + }); + + getNodeDownloadInfo.mockImplementation((_: Config, platform: Platform) => { + return { + url: `${platform.getName()}:url`, + downloadPath: `${platform.getName()}:downloadPath`, + downloadName: `${platform.getName()}:downloadName`, + }; + }); + + getNodeShasums.mockReturnValue({ + 'linux:downloadName': 'linux:sha256', + 'darwin:downloadName': 'darwin:sha256', + 'win32:downloadName': 'win32:sha256', + }); + + download.mockImplementation(({ url }: any) => { + if (url === failOnUrl) { + throw new Error('Download failed for reasons'); + } + }); + + return { config }; +} + +it('downloads node builds for each platform', async () => { + const { config } = await setup(); + + await DownloadNodeBuilds.run(config, log, []); + + expect(download.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + Object { + "destination": "linux:downloadPath", + "log": , + "retries": 3, + "sha256": "linux:sha256", + "url": "linux:url", + }, + ], + Array [ + Object { + "destination": "linux:downloadPath", + "log": , + "retries": 3, + "sha256": "linux:sha256", + "url": "linux:url", + }, + ], + Array [ + Object { + "destination": "darwin:downloadPath", + "log": , + "retries": 3, + "sha256": "darwin:sha256", + "url": "darwin:url", + }, + ], + Array [ + Object { + "destination": "win32:downloadPath", + "log": , + "retries": 3, + "sha256": "win32:sha256", + "url": "win32:url", + }, + ], + ] + `); + expect(testWriter.messages).toMatchInlineSnapshot(`Array []`); +}); + +it('rejects if any download fails', async () => { + const { config } = await setup({ failOnUrl: 'linux:url' }); + + await expect(DownloadNodeBuilds.run(config, log, [])).rejects.toMatchInlineSnapshot( + `[Error: Download failed for reasons]` + ); + expect(testWriter.messages).toMatchInlineSnapshot(`Array []`); +}); diff --git a/src/dev/build/tasks/nodejs/download_node_builds_task.js b/src/dev/build/tasks/nodejs/download_node_builds_task.ts similarity index 93% rename from src/dev/build/tasks/nodejs/download_node_builds_task.js rename to src/dev/build/tasks/nodejs/download_node_builds_task.ts index c0907e6c42a9..ad42ea11436f 100644 --- a/src/dev/build/tasks/nodejs/download_node_builds_task.js +++ b/src/dev/build/tasks/nodejs/download_node_builds_task.ts @@ -17,11 +17,11 @@ * under the License. */ -import { download } from '../../lib'; +import { download, GlobalTask } from '../../lib'; import { getNodeShasums } from './node_shasums'; import { getNodeDownloadInfo } from './node_download_info'; -export const DownloadNodeBuildsTask = { +export const DownloadNodeBuilds: GlobalTask = { global: true, description: 'Downloading node.js builds for all platforms', async run(config, log) { diff --git a/src/dev/build/tasks/nodejs/extract_node_builds_task.test.ts b/src/dev/build/tasks/nodejs/extract_node_builds_task.test.ts new file mode 100644 index 000000000000..94c421f7c9a6 --- /dev/null +++ b/src/dev/build/tasks/nodejs/extract_node_builds_task.test.ts @@ -0,0 +1,108 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + ToolingLog, + ToolingLogCollectingWriter, + createAbsolutePathSerializer, +} from '@kbn/dev-utils'; + +import { Config } from '../../lib'; +import { ExtractNodeBuilds } from './extract_node_builds_task'; + +jest.mock('../../lib/fs'); + +const Fs = jest.requireMock('../../lib/fs'); + +const log = new ToolingLog(); +const testWriter = new ToolingLogCollectingWriter(); +log.setWriters([testWriter]); + +expect.addSnapshotSerializer(createAbsolutePathSerializer()); + +async function setup() { + const config = await Config.create({ + isRelease: true, + targetAllPlatforms: true, + }); + + return { config }; +} + +beforeEach(() => { + testWriter.messages.length = 0; + jest.clearAllMocks(); +}); + +it('runs expected fs operations', async () => { + const { config } = await setup(); + + await ExtractNodeBuilds.run(config, log, []); + + const usedMethods = Object.fromEntries( + Object.entries(Fs) + .filter((entry): entry is [string, jest.Mock] => { + const [, mock] = entry; + + if (typeof mock !== 'function') { + return false; + } + + return (mock as jest.Mock).mock.calls.length > 0; + }) + .map(([name, mock]) => [name, mock.mock.calls]) + ); + + expect(usedMethods).toMatchInlineSnapshot(` + Object { + "copy": Array [ + Array [ + /.node_binaries/10.21.0/node.exe, + /.node_binaries/10.21.0/win32-x64/node.exe, + Object { + "clone": true, + }, + ], + ], + "untar": Array [ + Array [ + /.node_binaries/10.21.0/node-v10.21.0-linux-x64.tar.gz, + /.node_binaries/10.21.0/linux-x64, + Object { + "strip": 1, + }, + ], + Array [ + /.node_binaries/10.21.0/node-v10.21.0-linux-arm64.tar.gz, + /.node_binaries/10.21.0/linux-arm64, + Object { + "strip": 1, + }, + ], + Array [ + /.node_binaries/10.21.0/node-v10.21.0-darwin-x64.tar.gz, + /.node_binaries/10.21.0/darwin-x64, + Object { + "strip": 1, + }, + ], + ], + } + `); +}); diff --git a/src/dev/build/tasks/nodejs/extract_node_builds_task.js b/src/dev/build/tasks/nodejs/extract_node_builds_task.ts similarity index 56% rename from src/dev/build/tasks/nodejs/extract_node_builds_task.js rename to src/dev/build/tasks/nodejs/extract_node_builds_task.ts index caf0a389b4cc..aaa3312c8ba3 100644 --- a/src/dev/build/tasks/nodejs/extract_node_builds_task.js +++ b/src/dev/build/tasks/nodejs/extract_node_builds_task.ts @@ -17,39 +17,27 @@ * under the License. */ -import { dirname, resolve } from 'path'; -import fs from 'fs'; -import { promisify } from 'util'; +import Path from 'path'; -import { untar, mkdirp } from '../../lib'; +import { untar, GlobalTask, copy } from '../../lib'; import { getNodeDownloadInfo } from './node_download_info'; -const statAsync = promisify(fs.stat); -const copyFileAsync = promisify(fs.copyFile); - -export const ExtractNodeBuildsTask = { +export const ExtractNodeBuilds: GlobalTask = { global: true, description: 'Extracting node.js builds for all platforms', async run(config) { await Promise.all( config.getNodePlatforms().map(async (platform) => { const { downloadPath, extractDir } = getNodeDownloadInfo(config, platform); - // windows executable is not extractable, it's just an .exe file if (platform.isWindows()) { - const destination = resolve(extractDir, 'node.exe'); - return this.copyWindows(downloadPath, destination); + // windows executable is not extractable, it's just an .exe file + await copy(downloadPath, Path.resolve(extractDir, 'node.exe'), { + clone: true, + }); + } else { + await untar(downloadPath, extractDir, { strip: 1 }); } - - // all other downloads are tarballs - return untar(downloadPath, extractDir, { strip: 1 }); }) ); }, - async copyWindows(source, destination) { - // ensure source exists before creating destination directory - await statAsync(source); - await mkdirp(dirname(destination)); - // for performance reasons, do a copy-on-write by using the fs.constants.COPYFILE_FICLONE flag - return await copyFileAsync(source, destination, fs.constants.COPYFILE_FICLONE); - }, }; diff --git a/src/dev/build/tasks/nodejs/index.js b/src/dev/build/tasks/nodejs/index.js deleted file mode 100644 index e52dba73e4a9..000000000000 --- a/src/dev/build/tasks/nodejs/index.js +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -export { getNodeDownloadInfo } from './node_download_info'; - -export { DownloadNodeBuildsTask } from './download_node_builds_task'; -export { ExtractNodeBuildsTask } from './extract_node_builds_task'; -export { VerifyExistingNodeBuildsTask } from './verify_existing_node_builds_task'; -export { CleanNodeBuildsTask } from './clean_node_builds_task'; diff --git a/src/dev/build/tasks/nodejs/index.ts b/src/dev/build/tasks/nodejs/index.ts new file mode 100644 index 000000000000..8dd65418fb44 --- /dev/null +++ b/src/dev/build/tasks/nodejs/index.ts @@ -0,0 +1,24 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export * from './node_download_info'; +export * from './download_node_builds_task'; +export * from './extract_node_builds_task'; +export * from './verify_existing_node_builds_task'; +export * from './clean_node_builds_task'; diff --git a/src/dev/build/tasks/nodejs/node_download_info.js b/src/dev/build/tasks/nodejs/node_download_info.ts similarity index 92% rename from src/dev/build/tasks/nodejs/node_download_info.js rename to src/dev/build/tasks/nodejs/node_download_info.ts index 33ffd042d85a..b2c62d6667fd 100644 --- a/src/dev/build/tasks/nodejs/node_download_info.js +++ b/src/dev/build/tasks/nodejs/node_download_info.ts @@ -19,7 +19,9 @@ import { basename } from 'path'; -export function getNodeDownloadInfo(config, platform) { +import { Config, Platform } from '../../lib'; + +export function getNodeDownloadInfo(config: Config, platform: Platform) { const version = config.getNodeVersion(); const arch = platform.getNodeArch(); diff --git a/src/dev/build/tasks/nodejs/verify_existing_node_builds_task.test.ts b/src/dev/build/tasks/nodejs/verify_existing_node_builds_task.test.ts new file mode 100644 index 000000000000..f24b7ffc59c1 --- /dev/null +++ b/src/dev/build/tasks/nodejs/verify_existing_node_builds_task.test.ts @@ -0,0 +1,225 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + ToolingLog, + ToolingLogCollectingWriter, + createAnyInstanceSerializer, +} from '@kbn/dev-utils'; + +import { Config, Platform } from '../../lib'; +import { VerifyExistingNodeBuilds } from './verify_existing_node_builds_task'; + +jest.mock('./node_shasums'); +jest.mock('./node_download_info'); +jest.mock('../../lib/fs'); + +const { getNodeShasums } = jest.requireMock('./node_shasums'); +const { getNodeDownloadInfo } = jest.requireMock('./node_download_info'); +const { getFileHash } = jest.requireMock('../../lib/fs'); + +const log = new ToolingLog(); +const testWriter = new ToolingLogCollectingWriter(); +log.setWriters([testWriter]); + +expect.addSnapshotSerializer(createAnyInstanceSerializer(Config)); + +async function setup(actualShaSums?: Record) { + const config = await Config.create({ + isRelease: true, + targetAllPlatforms: true, + }); + + getNodeShasums.mockReturnValue( + Object.fromEntries( + config.getTargetPlatforms().map((platform) => { + return [`${platform.getName()}:${platform.getNodeArch()}:downloadName`, 'valid shasum']; + }) + ) + ); + + getNodeDownloadInfo.mockImplementation((_: Config, platform: Platform) => { + return { + downloadPath: `${platform.getName()}:${platform.getNodeArch()}:downloadPath`, + downloadName: `${platform.getName()}:${platform.getNodeArch()}:downloadName`, + }; + }); + + getFileHash.mockImplementation((downloadPath: string) => { + if (actualShaSums?.[downloadPath]) { + return actualShaSums[downloadPath]; + } + + return 'valid shasum'; + }); + + return { config }; +} + +beforeEach(() => { + testWriter.messages.length = 0; + jest.clearAllMocks(); +}); + +it('checks shasums for each downloaded node build', async () => { + const { config } = await setup(); + + await VerifyExistingNodeBuilds.run(config, log, []); + + expect(getNodeShasums).toMatchInlineSnapshot(` + [MockFunction] { + "calls": Array [ + Array [ + "10.21.0", + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Object { + "darwin:darwin-x64:downloadName": "valid shasum", + "linux:linux-arm64:downloadName": "valid shasum", + "linux:linux-x64:downloadName": "valid shasum", + "win32:win32-x64:downloadName": "valid shasum", + }, + }, + ], + } + `); + expect(getNodeDownloadInfo).toMatchInlineSnapshot(` + [MockFunction] { + "calls": Array [ + Array [ + , + Platform { + "architecture": "x64", + "buildName": "linux-x86_64", + "name": "linux", + }, + ], + Array [ + , + Platform { + "architecture": "arm64", + "buildName": "linux-aarch64", + "name": "linux", + }, + ], + Array [ + , + Platform { + "architecture": "x64", + "buildName": "darwin-x86_64", + "name": "darwin", + }, + ], + Array [ + , + Platform { + "architecture": "x64", + "buildName": "windows-x86_64", + "name": "win32", + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": Object { + "downloadName": "linux:linux-x64:downloadName", + "downloadPath": "linux:linux-x64:downloadPath", + }, + }, + Object { + "type": "return", + "value": Object { + "downloadName": "linux:linux-arm64:downloadName", + "downloadPath": "linux:linux-arm64:downloadPath", + }, + }, + Object { + "type": "return", + "value": Object { + "downloadName": "darwin:darwin-x64:downloadName", + "downloadPath": "darwin:darwin-x64:downloadPath", + }, + }, + Object { + "type": "return", + "value": Object { + "downloadName": "win32:win32-x64:downloadName", + "downloadPath": "win32:win32-x64:downloadPath", + }, + }, + ], + } + `); + expect(getFileHash).toMatchInlineSnapshot(` + [MockFunction] { + "calls": Array [ + Array [ + "linux:linux-x64:downloadPath", + "sha256", + ], + Array [ + "linux:linux-arm64:downloadPath", + "sha256", + ], + Array [ + "darwin:darwin-x64:downloadPath", + "sha256", + ], + Array [ + "win32:win32-x64:downloadPath", + "sha256", + ], + ], + "results": Array [ + Object { + "type": "return", + "value": "valid shasum", + }, + Object { + "type": "return", + "value": "valid shasum", + }, + Object { + "type": "return", + "value": "valid shasum", + }, + Object { + "type": "return", + "value": "valid shasum", + }, + ], + } + `); +}); + +it('rejects if any download has an incorrect sha256', async () => { + const { config } = await setup({ + 'linux:linux-arm64:downloadPath': 'invalid shasum', + }); + + await expect( + VerifyExistingNodeBuilds.run(config, log, []) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Download at linux:linux-arm64:downloadPath does not match expected checksum invalid shasum"` + ); +}); diff --git a/src/dev/build/tasks/nodejs/verify_existing_node_builds_task.js b/src/dev/build/tasks/nodejs/verify_existing_node_builds_task.ts similarity index 93% rename from src/dev/build/tasks/nodejs/verify_existing_node_builds_task.js rename to src/dev/build/tasks/nodejs/verify_existing_node_builds_task.ts index b320471fda33..9ce0778d2d1f 100644 --- a/src/dev/build/tasks/nodejs/verify_existing_node_builds_task.js +++ b/src/dev/build/tasks/nodejs/verify_existing_node_builds_task.ts @@ -17,11 +17,11 @@ * under the License. */ -import { getFileHash } from '../../lib'; +import { getFileHash, GlobalTask } from '../../lib'; import { getNodeDownloadInfo } from './node_download_info'; import { getNodeShasums } from './node_shasums'; -export const VerifyExistingNodeBuildsTask = { +export const VerifyExistingNodeBuilds: GlobalTask = { global: true, description: 'Verifying previously downloaded node.js build for all platforms', async run(config, log) { diff --git a/src/dev/build/tasks/notice_file_task.js b/src/dev/build/tasks/notice_file_task.ts similarity index 95% rename from src/dev/build/tasks/notice_file_task.js rename to src/dev/build/tasks/notice_file_task.ts index 59369c7cb5a3..6edb76d506bc 100644 --- a/src/dev/build/tasks/notice_file_task.js +++ b/src/dev/build/tasks/notice_file_task.ts @@ -20,11 +20,11 @@ import { getInstalledPackages } from '../../npm'; import { LICENSE_OVERRIDES } from '../../license_checker'; -import { write } from '../lib'; +import { write, Task } from '../lib'; import { getNodeDownloadInfo } from './nodejs'; import { generateNoticeFromSource, generateBuildNoticeText } from '../../notice'; -export const CreateNoticeFileTask = { +export const CreateNoticeFile: Task = { description: 'Generating NOTICE.txt file', async run(config, log, build) { @@ -40,7 +40,7 @@ export const CreateNoticeFileTask = { log.info('Discovering installed packages'); const packages = await getInstalledPackages({ directory: build.resolvePath(), - dev: false, + includeDev: false, licenseOverrides: LICENSE_OVERRIDES, }); diff --git a/src/dev/build/tasks/optimize_task.js b/src/dev/build/tasks/optimize_task.ts similarity index 95% rename from src/dev/build/tasks/optimize_task.js rename to src/dev/build/tasks/optimize_task.ts index 16a7537b8ac9..98979f376eac 100644 --- a/src/dev/build/tasks/optimize_task.js +++ b/src/dev/build/tasks/optimize_task.ts @@ -17,10 +17,10 @@ * under the License. */ -import { deleteAll, copyAll, exec } from '../lib'; +import { deleteAll, copyAll, exec, Task } from '../lib'; import { getNodeDownloadInfo } from './nodejs'; -export const OptimizeBuildTask = { +export const OptimizeBuild: Task = { description: 'Running optimizer', async run(config, log, build) { diff --git a/src/dev/build/tasks/os_packages/create_os_package_tasks.js b/src/dev/build/tasks/os_packages/create_os_package_tasks.ts similarity index 89% rename from src/dev/build/tasks/os_packages/create_os_package_tasks.js rename to src/dev/build/tasks/os_packages/create_os_package_tasks.ts index 6a00e681ab0e..4580b95423d3 100644 --- a/src/dev/build/tasks/os_packages/create_os_package_tasks.js +++ b/src/dev/build/tasks/os_packages/create_os_package_tasks.ts @@ -17,10 +17,11 @@ * under the License. */ +import { Task } from '../../lib'; import { runFpm } from './run_fpm'; import { runDockerGenerator, runDockerGeneratorForUBI } from './docker_generator'; -export const CreateDebPackageTask = { +export const CreateDebPackage: Task = { description: 'Creating deb package', async run(config, log, build) { @@ -33,7 +34,7 @@ export const CreateDebPackageTask = { }, }; -export const CreateRpmPackageTask = { +export const CreateRpmPackage: Task = { description: 'Creating rpm package', async run(config, log, build) { @@ -41,7 +42,7 @@ export const CreateRpmPackageTask = { }, }; -export const CreateDockerPackageTask = { +export const CreateDockerPackage: Task = { description: 'Creating docker package', async run(config, log, build) { @@ -50,7 +51,7 @@ export const CreateDockerPackageTask = { }, }; -export const CreateDockerUbiPackageTask = { +export const CreateDockerUbiPackage: Task = { description: 'Creating docker ubi package', async run(config, log, build) { diff --git a/src/dev/build/tasks/os_packages/docker_generator/bundle_dockerfiles.js b/src/dev/build/tasks/os_packages/docker_generator/bundle_dockerfiles.js index bbcb6dfeeb10..3f34a8405766 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/bundle_dockerfiles.js +++ b/src/dev/build/tasks/os_packages/docker_generator/bundle_dockerfiles.js @@ -18,7 +18,7 @@ */ import { resolve } from 'path'; -import { compress, copyAll, mkdirp, write } from '../../../lib'; +import { compressTar, copyAll, mkdirp, write } from '../../../lib'; import { dockerfileTemplate } from './templates'; export async function bundleDockerFiles(config, log, build, scope) { @@ -50,8 +50,7 @@ export async function bundleDockerFiles(config, log, build, scope) { // Compress dockerfiles dir created inside // docker build dir as output it as a target // on targets folder - await compress( - 'tar', + await compressTar( { archiverOptions: { gzip: true, diff --git a/src/dev/build/tasks/os_packages/index.js b/src/dev/build/tasks/os_packages/docker_generator/index.ts similarity index 84% rename from src/dev/build/tasks/os_packages/index.js rename to src/dev/build/tasks/os_packages/docker_generator/index.ts index 82626c47b608..78d2b197dc7b 100644 --- a/src/dev/build/tasks/os_packages/index.js +++ b/src/dev/build/tasks/os_packages/docker_generator/index.ts @@ -17,9 +17,5 @@ * under the License. */ -export { - CreateRpmPackageTask, - CreateDebPackageTask, - CreateDockerPackageTask, - CreateDockerUbiPackageTask, -} from './create_os_package_tasks'; +// @ts-expect-error not ts yet +export { runDockerGenerator, runDockerGeneratorForUBI } from './run'; diff --git a/src/dev/build/tasks/os_packages/index.ts b/src/dev/build/tasks/os_packages/index.ts new file mode 100644 index 000000000000..439fde71d255 --- /dev/null +++ b/src/dev/build/tasks/os_packages/index.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export * from './create_os_package_tasks'; diff --git a/src/dev/build/tasks/os_packages/run_fpm.js b/src/dev/build/tasks/os_packages/run_fpm.ts similarity index 91% rename from src/dev/build/tasks/os_packages/run_fpm.js rename to src/dev/build/tasks/os_packages/run_fpm.ts index eb77da0e7017..b5169ec3d43b 100644 --- a/src/dev/build/tasks/os_packages/run_fpm.js +++ b/src/dev/build/tasks/os_packages/run_fpm.ts @@ -19,15 +19,23 @@ import { resolve } from 'path'; -import { exec } from '../../lib'; +import { ToolingLog } from '@kbn/dev-utils'; -export async function runFpm(config, log, build, type, pkgSpecificFlags) { +import { exec, Config, Build } from '../../lib'; + +export async function runFpm( + config: Config, + log: ToolingLog, + build: Build, + type: 'rpm' | 'deb', + pkgSpecificFlags: string[] +) { const linux = config.getPlatform('linux', 'x64'); const version = config.getBuildVersion(); - const resolveWithTrailingSlash = (...paths) => `${resolve(...paths)}/`; + const resolveWithTrailingSlash = (...paths: string[]) => `${resolve(...paths)}/`; - const fromBuild = (...paths) => build.resolvePathForPlatform(linux, ...paths); + const fromBuild = (...paths: string[]) => build.resolvePathForPlatform(linux, ...paths); const pickLicense = () => { if (build.isOss()) { diff --git a/src/dev/build/tasks/patch_native_modules_task.js b/src/dev/build/tasks/patch_native_modules_task.ts similarity index 82% rename from src/dev/build/tasks/patch_native_modules_task.js rename to src/dev/build/tasks/patch_native_modules_task.ts index c30d1fd774b5..b56d01b61646 100644 --- a/src/dev/build/tasks/patch_native_modules_task.js +++ b/src/dev/build/tasks/patch_native_modules_task.ts @@ -16,14 +16,30 @@ * specific language governing permissions and limitations * under the License. */ -import fs from 'fs'; + import path from 'path'; -import util from 'util'; -import { deleteAll, download, gunzip, untar } from '../lib'; + +import { ToolingLog } from '@kbn/dev-utils'; + +import { deleteAll, download, gunzip, untar, Task, Config, Build, Platform, read } from '../lib'; const DOWNLOAD_DIRECTORY = '.native_modules'; -const packages = [ +interface Package { + name: string; + version: string; + destinationPath: string; + extractMethod: string; + archives: Record< + string, + { + url: string; + sha256: string; + } + >; +} + +const packages: Package[] = [ { name: 're2', version: '1.15.4', @@ -46,16 +62,22 @@ const packages = [ }, ]; -async function getInstalledVersion(config, packageName) { +async function getInstalledVersion(config: Config, packageName: string) { const packageJSONPath = config.resolveFromRepo( path.join('node_modules', packageName, 'package.json') ); - const buffer = await util.promisify(fs.readFile)(packageJSONPath); - const packageJSON = JSON.parse(buffer); + const json = await read(packageJSONPath); + const packageJSON = JSON.parse(json); return packageJSON.version; } -async function patchModule(config, log, build, platform, pkg) { +async function patchModule( + config: Config, + log: ToolingLog, + build: Build, + platform: Platform, + pkg: Package +) { const installedVersion = await getInstalledVersion(config, pkg.name); if (installedVersion !== pkg.version) { throw new Error( @@ -89,7 +111,7 @@ async function patchModule(config, log, build, platform, pkg) { } } -export const PatchNativeModulesTask = { +export const PatchNativeModules: Task = { description: 'Patching platform-specific native modules', async run(config, log, build) { for (const pkg of packages) { diff --git a/src/dev/build/tasks/path_length_task.js b/src/dev/build/tasks/path_length_task.ts similarity index 95% rename from src/dev/build/tasks/path_length_task.js rename to src/dev/build/tasks/path_length_task.ts index 29ab9ce5a249..d639217adc53 100644 --- a/src/dev/build/tasks/path_length_task.js +++ b/src/dev/build/tasks/path_length_task.ts @@ -21,9 +21,9 @@ import { relative } from 'path'; import { tap, filter, map, toArray } from 'rxjs/operators'; -import { scan$ } from '../lib/scan'; +import { scan$, Task } from '../lib'; -export const PathLengthTask = { +export const PathLength: Task = { description: 'Checking Windows for paths > 200 characters', async run(config, log, build) { diff --git a/src/dev/build/tasks/transpile_babel_task.js b/src/dev/build/tasks/transpile_babel_task.ts similarity index 80% rename from src/dev/build/tasks/transpile_babel_task.js rename to src/dev/build/tasks/transpile_babel_task.ts index f476ead9183f..a1e994587ce9 100644 --- a/src/dev/build/tasks/transpile_babel_task.js +++ b/src/dev/build/tasks/transpile_babel_task.ts @@ -17,15 +17,21 @@ * under the License. */ +import { pipeline } from 'stream'; +import { promisify } from 'util'; + +// @ts-expect-error @types/gulp-babel is outdated and doesn't work for gulp-babel v8 import gulpBabel from 'gulp-babel'; import vfs from 'vinyl-fs'; -import { createPromiseFromStreams } from '../../../legacy/utils'; +import { Task, Build } from '../lib'; + +const asyncPipeline = promisify(pipeline); -const transpileWithBabel = async (srcGlobs, build, presets) => { +const transpileWithBabel = async (srcGlobs: string[], build: Build, presets: string[]) => { const buildRoot = build.resolvePath(); - await createPromiseFromStreams([ + await asyncPipeline( vfs.src( srcGlobs.concat([ '!**/*.d.ts', @@ -44,11 +50,11 @@ const transpileWithBabel = async (srcGlobs, build, presets) => { presets, }), - vfs.dest(buildRoot), - ]); + vfs.dest(buildRoot) + ); }; -export const TranspileBabelTask = { +export const TranspileBabel: Task = { description: 'Transpiling sources with babel', async run(config, log, build) { diff --git a/src/dev/build/tasks/transpile_scss_task.js b/src/dev/build/tasks/transpile_scss_task.ts similarity index 89% rename from src/dev/build/tasks/transpile_scss_task.js rename to src/dev/build/tasks/transpile_scss_task.ts index d1c76d97c885..e1b0bd0171c9 100644 --- a/src/dev/build/tasks/transpile_scss_task.js +++ b/src/dev/build/tasks/transpile_scss_task.ts @@ -17,9 +17,12 @@ * under the License. */ +import { Task } from '../lib'; + +// @ts-expect-error buildSass isn't TS yet import { buildSass } from '../../sass'; -export const TranspileScssTask = { +export const TranspileScss: Task = { description: 'Transpiling SCSS to CSS', async run(config, log, build) { await buildSass({ diff --git a/src/dev/build/tasks/uuid_verification_task.js b/src/dev/build/tasks/uuid_verification_task.ts similarity index 94% rename from src/dev/build/tasks/uuid_verification_task.js rename to src/dev/build/tasks/uuid_verification_task.ts index 32c9e73dba98..b65096690b68 100644 --- a/src/dev/build/tasks/uuid_verification_task.js +++ b/src/dev/build/tasks/uuid_verification_task.ts @@ -17,9 +17,9 @@ * under the License. */ -import { read } from '../lib'; +import { read, Task } from '../lib'; -export const UuidVerificationTask = { +export const UuidVerification: Task = { description: 'Verify that no UUID file is baked into the build', async run(config, log, build) { diff --git a/src/dev/build/tasks/verify_env_task.js b/src/dev/build/tasks/verify_env_task.ts similarity index 93% rename from src/dev/build/tasks/verify_env_task.js rename to src/dev/build/tasks/verify_env_task.ts index eb679411d7e3..975a620c1c54 100644 --- a/src/dev/build/tasks/verify_env_task.js +++ b/src/dev/build/tasks/verify_env_task.ts @@ -17,7 +17,9 @@ * under the License. */ -export const VerifyEnvTask = { +import { GlobalTask } from '../lib'; + +export const VerifyEnv: GlobalTask = { global: true, description: 'Verifying environment meets requirements', diff --git a/src/dev/build/tasks/write_sha_sums_task.js b/src/dev/build/tasks/write_sha_sums_task.ts similarity index 92% rename from src/dev/build/tasks/write_sha_sums_task.js rename to src/dev/build/tasks/write_sha_sums_task.ts index c44924bb9ce0..abf938cd150a 100644 --- a/src/dev/build/tasks/write_sha_sums_task.js +++ b/src/dev/build/tasks/write_sha_sums_task.ts @@ -19,9 +19,9 @@ import globby from 'globby'; -import { getFileHash, write } from '../lib'; +import { getFileHash, write, GlobalTask } from '../lib'; -export const WriteShaSumsTask = { +export const WriteShaSums: GlobalTask = { global: true, description: 'Writing sha1sums of archives and packages in target directory', diff --git a/src/legacy/utils/__tests__/watch_stdio_for_line.js b/src/legacy/utils/__tests__/watch_stdio_for_line.js deleted file mode 100644 index 32d61658c111..000000000000 --- a/src/legacy/utils/__tests__/watch_stdio_for_line.js +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import execa from 'execa'; -import stripAnsi from 'strip-ansi'; -import sinon from 'sinon'; - -import { watchStdioForLine } from '../watch_stdio_for_line'; - -describe('src/legacy/utils/watch_stdio_for_line', function () { - const sandbox = sinon.sandbox.create(); - afterEach(() => sandbox.reset()); - - const onLogLine = sandbox.stub(); - const logFn = (line) => onLogLine(stripAnsi(line)); - - it('calls logFn with log lines', async () => { - const proc = execa(process.execPath, ['-e', 'console.log("hi")']); - - await watchStdioForLine(proc, logFn); - - // log output of the process - sinon.assert.calledWithExactly(onLogLine, sinon.match(/hi/)); - }); - - it('send the proc SIGKILL if it logs a line matching exitAfter regexp', async function () { - // fixture proc will exit after 10 seconds if sigint not received, but the test won't fail - // unless we see the log line `SIGINT not received`, so we let the test take up to 30 seconds - // for potentially huge delays here and there - this.timeout(30000); - - const proc = execa(process.execPath, [require.resolve('./fixtures/log_on_sigint')]); - - await watchStdioForLine(proc, logFn, /listening for SIGINT/); - - sinon.assert.calledWithExactly(onLogLine, sinon.match(/listening for SIGINT/)); - sinon.assert.neverCalledWith(onLogLine, sinon.match(/SIGINT not received/)); - }); -}); diff --git a/src/legacy/utils/index.js b/src/legacy/utils/index.js index a4c0cdf958fc..4274fb2e4901 100644 --- a/src/legacy/utils/index.js +++ b/src/legacy/utils/index.js @@ -21,7 +21,6 @@ export { BinderBase } from './binder'; export { BinderFor } from './binder_for'; export { deepCloneWithBuffers } from './deep_clone_with_buffers'; export { unset } from './unset'; -export { watchStdioForLine } from './watch_stdio_for_line'; export { IS_KIBANA_DISTRIBUTABLE } from './artifact_type'; export { IS_KIBANA_RELEASE } from './artifact_type'; diff --git a/src/legacy/utils/streams/index.d.ts b/src/legacy/utils/streams/index.d.ts index 5ef39b292c68..470b5d9fa350 100644 --- a/src/legacy/utils/streams/index.d.ts +++ b/src/legacy/utils/streams/index.d.ts @@ -17,7 +17,7 @@ * under the License. */ -import { Readable, Transform, Writable, TransformOptions } from 'stream'; +import { Readable, Writable, Transform, TransformOptions } from 'stream'; export function concatStreamProviders( sourceProviders: Array<() => Readable>, diff --git a/x-pack/package.json b/x-pack/package.json index 1de009ae1232..39bdb76ac7a7 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -49,7 +49,7 @@ "@testing-library/react-hooks": "^3.2.1", "@testing-library/jest-dom": "^5.8.0", "@types/angular": "^1.6.56", - "@types/archiver": "^3.0.0", + "@types/archiver": "^3.1.0", "@types/base64-js": "^1.2.5", "@types/boom": "^7.2.0", "@types/cheerio": "^0.22.10", diff --git a/yarn.lock b/yarn.lock index 4cc802e328ab..1bb8fab0372a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4625,10 +4625,10 @@ resolved "https://registry.yarnpkg.com/@types/anymatch/-/anymatch-1.3.1.tgz#336badc1beecb9dacc38bea2cf32adf627a8421a" integrity sha512-/+CRPXpBDpo2RK9C68N3b2cOvO0Cf5B9aPijHsoDQTHivnGSObdOF2BRQOYjojWTDy6nQvMjmqRXIxH55VjxxA== -"@types/archiver@^3.0.0": - version "3.0.0" - resolved "https://registry.yarnpkg.com/@types/archiver/-/archiver-3.0.0.tgz#c0a53e0ed3b7aef626ce683d081d7821d8c638b4" - integrity sha512-orghAMOF+//wSg4ru2znk6jt0eIPvKTtMVLH7XcYcjbcRyAXRClDlh27QVdqnAvVM37yu9xDP6Nh7egRhNr8tQ== +"@types/archiver@^3.1.0": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@types/archiver/-/archiver-3.1.0.tgz#0d5bd922ba5cf06e137cd6793db7942439b1805e" + integrity sha512-nTvHwgWONL+iXG+9CX+gnQ/tTOV+qucAjwpXqeUn4OCRMxP42T29FFP/7XaOo0EqqO3TlENhObeZEe7RUJAriw== dependencies: "@types/glob" "*" From 085631b93ad034316b35e16f8cd880ffac0340bf Mon Sep 17 00:00:00 2001 From: Brent Kimmel Date: Thu, 23 Jul 2020 14:04:52 -0400 Subject: [PATCH 112/142] Resolver node cube click should == button click (#73085) * Resolver node cube click == button click --- .../public/resolver/view/process_event_dot.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx b/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx index 05f2e0cbfcfa..aed292e4a39d 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx @@ -313,6 +313,14 @@ const UnstyledProcessEventDot = React.memo( { + handleFocus(); + handleClick(); + } /* a11y note: this is strictly an alternate to the button, so no tabindex is necessary*/ + } + role="img" + aria-labelledby={labelHTMLID} style={{ display: 'block', width: '100%', @@ -320,6 +328,8 @@ const UnstyledProcessEventDot = React.memo( position: 'absolute', top: '0', left: '0', + outline: 'transparent', + border: 'none', }} > From cec09af7370f88f6669f537cc3940cd3248a0e0a Mon Sep 17 00:00:00 2001 From: Brent Kimmel Date: Thu, 23 Jul 2020 14:05:40 -0400 Subject: [PATCH 113/142] Remove 'not' on ref check so menu pans with scene (#72976) --- .../plugins/security_solution/public/resolver/view/submenu.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/security_solution/public/resolver/view/submenu.tsx b/x-pack/plugins/security_solution/public/resolver/view/submenu.tsx index 2499a451b9c8..6a9ab184e9ba 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/submenu.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/submenu.tsx @@ -190,7 +190,7 @@ const NodeSubMenuComponents = React.memo( * then force the popover to reposition itself. */ popoverRef.current && - !projectionMatrixAtLastRender.current && + projectionMatrixAtLastRender.current && projectionMatrixAtLastRender.current !== projectionMatrix ) { popoverRef.current.positionPopoverFixed(); From f4bc846a0323f04086b1f3555659b39c034c62ab Mon Sep 17 00:00:00 2001 From: Marta Bondyra Date: Thu, 23 Jul 2020 20:38:45 +0200 Subject: [PATCH 114/142] unskip test to make sure if it passes (#73014) Co-authored-by: Elastic Machine --- test/functional/apps/dashboard/dashboard_filtering.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/test/functional/apps/dashboard/dashboard_filtering.js b/test/functional/apps/dashboard/dashboard_filtering.js index cd80f915775c..0be4fbbebe7c 100644 --- a/test/functional/apps/dashboard/dashboard_filtering.js +++ b/test/functional/apps/dashboard/dashboard_filtering.js @@ -183,9 +183,6 @@ export default function ({ getService, getPageObjects }) { }); describe('disabling a filter unfilters the data on', function () { - // Flaky test - // https://github.com/elastic/kibana/issues/41087 - this.tags('skipFirefox'); before(async () => { await filterBar.toggleFilterEnabled('bytes'); await PageObjects.header.waitUntilLoadingHasFinished(); From 398e3372c8631f17dfe082a7a7b7cf91d64d07bd Mon Sep 17 00:00:00 2001 From: Andrew Stucki Date: Thu, 23 Jul 2020 15:47:29 -0400 Subject: [PATCH 115/142] [Ingest Manager] Add contains handlebar helper for conditional blocks in yaml (#72698) * Add contains handlebar helper for conditionally adding blocks in ingest manager yaml * Split into two tests and sandbox handlebars runtime * Make helper a little bit more robust and the any explicit * Add this to function signature with explicit any type * Update x-pack/plugins/ingest_manager/server/services/epm/agent/agent.ts Co-authored-by: Nicolas Chaulet Co-authored-by: Nicolas Chaulet Co-authored-by: Elastic Machine --- .../server/services/epm/agent/agent.test.ts | 61 +++++++++++++++++++ .../server/services/epm/agent/agent.ts | 14 ++++- 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/ingest_manager/server/services/epm/agent/agent.test.ts b/x-pack/plugins/ingest_manager/server/services/epm/agent/agent.test.ts index 635dce93f002..54b40400bb4e 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/agent/agent.test.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/agent/agent.test.ts @@ -84,6 +84,67 @@ foo: bar }); }); + describe('contains blocks', () => { + const streamTemplate = ` +input: log +paths: +{{#each paths}} + - {{this}} +{{/each}} +exclude_files: [".gz$"] +tags: +{{#each tags}} + - {{this}} +{{/each}} +{{#contains "forwarded" tags}} +publisher_pipeline.disable_host: true +{{/contains}} +processors: + - add_locale: ~ +password: {{password}} +{{#if password}} +hidden_password: {{password}} +{{/if}} + `; + + it('should support when a value is not contained in the array', () => { + const vars = { + paths: { value: ['/usr/local/var/log/nginx/access.log'] }, + password: { type: 'password', value: '' }, + tags: { value: ['foo', 'bar', 'forwarded'] }, + }; + + const output = createStream(vars, streamTemplate); + expect(output).toEqual({ + input: 'log', + paths: ['/usr/local/var/log/nginx/access.log'], + exclude_files: ['.gz$'], + processors: [{ add_locale: null }], + password: '', + 'publisher_pipeline.disable_host': true, + tags: ['foo', 'bar', 'forwarded'], + }); + }); + + it('should support when a value is contained in the array', () => { + const vars = { + paths: { value: ['/usr/local/var/log/nginx/access.log'] }, + password: { type: 'password', value: '' }, + tags: { value: ['foo', 'bar'] }, + }; + + const output = createStream(vars, streamTemplate); + expect(output).toEqual({ + input: 'log', + paths: ['/usr/local/var/log/nginx/access.log'], + exclude_files: ['.gz$'], + processors: [{ add_locale: null }], + password: '', + tags: ['foo', 'bar'], + }); + }); + }); + it('should support optional yaml values at root level', () => { const streamTemplate = ` input: logs diff --git a/x-pack/plugins/ingest_manager/server/services/epm/agent/agent.ts b/x-pack/plugins/ingest_manager/server/services/epm/agent/agent.ts index d697ad057639..88c54d213554 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/agent/agent.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/agent/agent.ts @@ -8,10 +8,12 @@ import Handlebars from 'handlebars'; import { safeLoad, safeDump } from 'js-yaml'; import { PackageConfigConfigRecord } from '../../../../common'; +const handlebars = Handlebars.create(); + export function createStream(variables: PackageConfigConfigRecord, streamTemplate: string) { const { vars, yamlValues } = buildTemplateVariables(variables, streamTemplate); - const template = Handlebars.compile(streamTemplate, { noEscape: true }); + const template = handlebars.compile(streamTemplate, { noEscape: true }); let stream = template(vars); stream = replaceRootLevelYamlVariables(yamlValues, stream); @@ -87,6 +89,16 @@ function buildTemplateVariables(variables: PackageConfigConfigRecord, streamTemp return { vars, yamlValues }; } +function containsHelper(this: any, item: string, list: string[], options: any) { + if (Array.isArray(list) && list.includes(item)) { + if (options && options.fn) { + return options.fn(this); + } + } + return ''; +} +handlebars.registerHelper('contains', containsHelper); + function replaceRootLevelYamlVariables(yamlVariables: { [k: string]: any }, yamlTemplate: string) { if (Object.keys(yamlVariables).length === 0 || !yamlTemplate) { return yamlTemplate; From d9e11bae41e05183d192c82b5a4bac0e6f84256f Mon Sep 17 00:00:00 2001 From: Kevin Logan <56395104+kevinlog@users.noreply.github.com> Date: Thu, 23 Jul 2020 16:06:46 -0400 Subject: [PATCH 116/142] [SECURITY_SOLUTION] remove redundant package name from Policy version column (#72482) --- .../public/management/pages/policy/view/policy_list.tsx | 3 +-- .../security_solution_endpoint/apps/endpoint/policy_list.ts | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.tsx index 20b6534f7664..667aacd9df3b 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.tsx @@ -322,9 +322,8 @@ export const PolicyList = React.memo(() => { }), render(pkg: Immutable) { return i18n.translate('xpack.securitySolution.endpoint.policyList.versionField', { - defaultMessage: '{title} v{version}', + defaultMessage: 'v{version}', values: { - title: pkg.title, version: pkg.version, }, }); diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/policy_list.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/policy_list.ts index 57321ab4cd91..a4b3a51c4951 100644 --- a/x-pack/test/security_solution_endpoint/apps/endpoint/policy_list.ts +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/policy_list.ts @@ -78,7 +78,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { 'Protect East Coastrev. 1', 'elastic', 'elastic', - `${policyInfo.packageConfig.package?.title} v${policyInfo.packageConfig.package?.version}`, + `v${policyInfo.packageConfig.package?.version}`, '', ]); [policyRow[2], policyRow[4]].forEach((relativeDate) => { From 4c2dc8d165ab7a53ff27b0c19b5d2305f8f40ddd Mon Sep 17 00:00:00 2001 From: Michael Olorunnisola Date: Thu, 23 Jul 2020 16:27:44 -0400 Subject: [PATCH 117/142] [Security Solution] Show full elapsed time milliseconds (#72972) * fix elapsed time description * added millisecond tests * shows 0 as less than 1 ms * prevent elapsed time clipping Co-authored-by: Elastic Machine --- .../public/resolver/lib/date.test.ts | 14 ++++++++++ .../public/resolver/lib/date.ts | 26 +++++++++++-------- .../public/resolver/types.ts | 2 +- .../public/resolver/view/edge_line.tsx | 2 +- 4 files changed, 31 insertions(+), 13 deletions(-) diff --git a/x-pack/plugins/security_solution/public/resolver/lib/date.test.ts b/x-pack/plugins/security_solution/public/resolver/lib/date.test.ts index 0cc116a85fa5..7a48245fcfc4 100644 --- a/x-pack/plugins/security_solution/public/resolver/lib/date.test.ts +++ b/x-pack/plugins/security_solution/public/resolver/lib/date.test.ts @@ -17,6 +17,7 @@ describe('date', () => { const initialTime = new Date('6/1/2020').getTime(); + const oneMillisecond = new Date(initialTime + 1).getTime(); const oneSecond = new Date(initialTime + 1 * second).getTime(); const oneMinute = new Date(initialTime + 1 * minute).getTime(); const oneHour = new Date(initialTime + 1 * hour).getTime(); @@ -25,6 +26,7 @@ describe('date', () => { const oneMonth = new Date(initialTime + 1 * month).getTime(); const oneYear = new Date(initialTime + 1 * year).getTime(); + const almostASecond = new Date(initialTime + 999).getTime(); const almostAMinute = new Date(initialTime + 59.9 * second).getTime(); const almostAnHour = new Date(initialTime + 59.9 * minute).getTime(); const almostADay = new Date(initialTime + 23.9 * hour).getTime(); @@ -34,6 +36,14 @@ describe('date', () => { const threeYears = new Date(initialTime + 3 * year).getTime(); it('should return the correct singular relative time', () => { + expect(getFriendlyElapsedTime(initialTime, initialTime)).toEqual({ + duration: '<1', + durationType: 'millisecond', + }); + expect(getFriendlyElapsedTime(initialTime, oneMillisecond)).toEqual({ + duration: 1, + durationType: 'millisecond', + }); expect(getFriendlyElapsedTime(initialTime, oneSecond)).toEqual({ duration: 1, durationType: 'second', @@ -65,6 +75,10 @@ describe('date', () => { }); it('should return the correct pluralized relative time', () => { + expect(getFriendlyElapsedTime(initialTime, almostASecond)).toEqual({ + duration: 999, + durationType: 'milliseconds', + }); expect(getFriendlyElapsedTime(initialTime, almostAMinute)).toEqual({ duration: 59, durationType: 'seconds', diff --git a/x-pack/plugins/security_solution/public/resolver/lib/date.ts b/x-pack/plugins/security_solution/public/resolver/lib/date.ts index de0f9dcd7efb..a5e07e6a02a8 100644 --- a/x-pack/plugins/security_solution/public/resolver/lib/date.ts +++ b/x-pack/plugins/security_solution/public/resolver/lib/date.ts @@ -18,7 +18,6 @@ export const getFriendlyElapsedTime = ( const startTime = typeof from === 'number' ? from : parseInt(from, 10); const endTime = typeof to === 'number' ? to : parseInt(to, 10); const elapsedTimeInMs = endTime - startTime; - if (Number.isNaN(elapsedTimeInMs)) { return null; } @@ -31,45 +30,50 @@ export const getFriendlyElapsedTime = ( const month = day * 30; const year = day * 365; - let duration: number; + let duration: DurationDetails['duration']; let singularType: DurationTypes; let pluralType: DurationTypes; switch (true) { case elapsedTimeInMs >= year: - duration = elapsedTimeInMs / year; + duration = Math.floor(elapsedTimeInMs / year); singularType = 'year'; pluralType = 'years'; break; case elapsedTimeInMs >= month: - duration = elapsedTimeInMs / month; + duration = Math.floor(elapsedTimeInMs / month); singularType = 'month'; pluralType = 'months'; break; case elapsedTimeInMs >= week: - duration = elapsedTimeInMs / week; + duration = Math.floor(elapsedTimeInMs / week); singularType = 'week'; pluralType = 'weeks'; break; case elapsedTimeInMs >= day: - duration = elapsedTimeInMs / day; + duration = Math.floor(elapsedTimeInMs / day); singularType = 'day'; pluralType = 'days'; break; case elapsedTimeInMs >= hour: - duration = elapsedTimeInMs / hour; + duration = Math.floor(elapsedTimeInMs / hour); singularType = 'hour'; pluralType = 'hours'; break; case elapsedTimeInMs >= minute: - duration = elapsedTimeInMs / minute; + duration = Math.floor(elapsedTimeInMs / minute); singularType = 'minute'; pluralType = 'minutes'; break; case elapsedTimeInMs >= second: - duration = elapsedTimeInMs / second; + duration = Math.floor(elapsedTimeInMs / second); singularType = 'second'; pluralType = 'seconds'; break; + case elapsedTimeInMs === 0: + duration = '<1'; + singularType = 'millisecond'; + pluralType = 'millisecond'; // Would never show + break; default: duration = elapsedTimeInMs; singularType = 'millisecond'; @@ -77,6 +81,6 @@ export const getFriendlyElapsedTime = ( break; } - const durationType = duration > 1 ? pluralType : singularType; - return { duration: Math.floor(duration), durationType }; + const durationType = duration === 1 ? singularType : pluralType; + return { duration, durationType }; }; diff --git a/x-pack/plugins/security_solution/public/resolver/types.ts b/x-pack/plugins/security_solution/public/resolver/types.ts index 856ae2d6240e..02a890ca13ee 100644 --- a/x-pack/plugins/security_solution/public/resolver/types.ts +++ b/x-pack/plugins/security_solution/public/resolver/types.ts @@ -310,7 +310,7 @@ export type DurationTypes = * duration value and description string */ export interface DurationDetails { - duration: number; + duration: number | '<1'; durationType: DurationTypes; } /** diff --git a/x-pack/plugins/security_solution/public/resolver/view/edge_line.tsx b/x-pack/plugins/security_solution/public/resolver/view/edge_line.tsx index 65c70f94432c..9f310bb1cc0d 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/edge_line.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/edge_line.tsx @@ -45,7 +45,7 @@ const StyledElapsedTime = styled.div` left: ${(props) => `${props.leftPct}%`}; padding: 6px 8px; border-radius: 999px; // generate pill shape - transform: translate(-50%, -50%) rotateX(35deg); + transform: translate(-50%, -50%); user-select: none; `; From f95951acfbf1f4ebc8a13ca5db2cd343bd0d01db Mon Sep 17 00:00:00 2001 From: Davis Plumlee <56367316+dplumlee@users.noreply.github.com> Date: Thu, 23 Jul 2020 16:49:55 -0400 Subject: [PATCH 118/142] [Security Solution][Detections] Fixes exception modal bugs (#73119) --- .../exceptions/add_exception_modal/index.tsx | 10 ++-- .../common/components/exceptions/helpers.tsx | 52 ++++++++----------- .../alerts_table/default_config.tsx | 1 + 3 files changed, 30 insertions(+), 33 deletions(-) diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx index 0d93a1ea8871..d2fec1f34755 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx @@ -296,9 +296,13 @@ export const AddExceptionModal = memo(function AddExceptionModal({

{i18n.ADD_EXCEPTION_FETCH_ERROR}

)} - {fetchOrCreateListError === false && isLoadingExceptionList === true && ( - - )} + {fetchOrCreateListError === false && + (isLoadingExceptionList || + isIndexPatternLoading || + isSignalIndexLoading || + isSignalIndexPatternLoading) && ( + + )} {fetchOrCreateListError === false && !isSignalIndexLoading && !isSignalIndexPatternLoading && diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx index 384badefc34a..a54f20f56d56 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx @@ -383,6 +383,7 @@ export const defaultEndpointExceptionItems = ( fieldName: 'file.Ext.code_signature.trusted', }); const [sha1Hash] = getMappedNonEcsValue({ data: alertData, fieldName: 'file.hash.sha1' }); + const [eventCode] = getMappedNonEcsValue({ data: alertData, fieldName: 'event.code' }); const namespaceType = 'agnostic'; return [ @@ -390,49 +391,40 @@ export const defaultEndpointExceptionItems = ( ...getNewExceptionItem({ listType, listId, namespaceType, ruleName }), entries: [ { - field: 'file.path', - operator: 'included', - type: 'match', - value: filePath ?? '', - }, - ], - }, - { - ...getNewExceptionItem({ listType, listId, namespaceType, ruleName }), - entries: [ - { - field: 'file.Ext.code_signature.subject_name', - operator: 'included', - type: 'match', - value: signatureSigner ?? '', + field: 'file.Ext.code_signature', + type: 'nested', + entries: [ + { + field: 'subject_name', + operator: 'included', + type: 'match', + value: signatureSigner ?? '', + }, + { + field: 'trusted', + operator: 'included', + type: 'match', + value: signatureTrusted ?? '', + }, + ], }, { - field: 'file.Ext.code_signature.trusted', + field: 'file.path', operator: 'included', type: 'match', - value: signatureTrusted ?? '', + value: filePath ?? '', }, - ], - }, - { - ...getNewExceptionItem({ listType, listId, namespaceType, ruleName }), - entries: [ { field: 'file.hash.sha1', operator: 'included', type: 'match', value: sha1Hash ?? '', }, - ], - }, - { - ...getNewExceptionItem({ listType, listId, namespaceType, ruleName }), - entries: [ { - field: 'event.category', + field: 'event.code', operator: 'included', - type: 'match_any', - value: getMappedNonEcsValue({ data: alertData, fieldName: 'event.category' }), + type: 'match', + value: eventCode ?? '', }, ], }, diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx index a4ce6c0200eb..010129d2d459 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx @@ -204,6 +204,7 @@ export const requiredFieldsForActions = [ 'file.Ext.code_signature.trusted', 'file.hash.sha1', 'host.os.family', + 'event.code', ]; interface AlertActionArgs { From 75beedbadde23bdca3385fc8115f93aa5ab47315 Mon Sep 17 00:00:00 2001 From: Clint Andrew Hall Date: Thu, 23 Jul 2020 17:10:38 -0400 Subject: [PATCH 119/142] [Canvas] Provide service stubs (#72708) Co-authored-by: Elastic Machine --- x-pack/plugins/canvas/public/application.tsx | 21 ++--- .../canvas/public/components/app/index.js | 6 +- .../components/app/track_route_change.js | 23 ----- .../components/element_content/index.js | 8 +- .../components/embeddable_flyout/flyout.tsx | 85 +++++++++---------- .../components/embeddable_flyout/index.tsx | 14 +-- .../public/components/expression/index.js | 6 +- .../render_with_fn/render_with_fn.tsx | 5 +- .../components/saved_elements_modal/index.ts | 13 ++- .../public/components/var_config/index.tsx | 21 ++--- .../workpad_header/element_menu/index.tsx | 2 - .../workpad_header/share_menu/index.ts | 23 ++--- .../workpad_header/view_menu/index.ts | 2 - .../public/components/workpad_loader/index.js | 20 ++--- .../components/workpad_templates/index.tsx | 9 +- .../plugins/canvas/public/lib/breadcrumbs.ts | 3 +- .../public/lib/custom_element_service.ts | 2 +- .../canvas/public/lib/documentation_links.ts | 16 ++-- .../plugins/canvas/public/lib/es_service.ts | 6 +- .../canvas/public/lib/template_service.ts | 2 +- .../canvas/public/lib/workpad_service.js | 7 +- .../canvas/public/services/context.tsx | 58 +++++++++++++ .../canvas/public/services/embeddables.ts | 21 +++++ .../canvas/public/services/expressions.ts | 1 - .../plugins/canvas/public/services/index.ts | 19 ++++- .../canvas/public/services/nav_link.ts | 8 +- .../plugins/canvas/public/services/notify.ts | 2 +- .../canvas/public/services/platform.ts | 53 +++++++++--- .../public/services/stubs/embeddables.ts | 12 +++ .../public/services/stubs/expressions.ts | 27 ++++++ .../canvas/public/services/stubs/index.ts | 28 ++++++ .../canvas/public/services/stubs/nav_link.ts | 13 +++ .../canvas/public/services/stubs/notify.ts | 16 ++++ .../canvas/public/services/stubs/platform.ts | 23 +++++ .../canvas/public/state/initial_state.js | 4 +- .../canvas/public/state/reducers/workpad.js | 6 +- x-pack/plugins/canvas/storybook/config.js | 2 + 37 files changed, 394 insertions(+), 193 deletions(-) delete mode 100644 x-pack/plugins/canvas/public/components/app/track_route_change.js create mode 100644 x-pack/plugins/canvas/public/services/context.tsx create mode 100644 x-pack/plugins/canvas/public/services/embeddables.ts create mode 100644 x-pack/plugins/canvas/public/services/stubs/embeddables.ts create mode 100644 x-pack/plugins/canvas/public/services/stubs/expressions.ts create mode 100644 x-pack/plugins/canvas/public/services/stubs/index.ts create mode 100644 x-pack/plugins/canvas/public/services/stubs/nav_link.ts create mode 100644 x-pack/plugins/canvas/public/services/stubs/notify.ts create mode 100644 x-pack/plugins/canvas/public/services/stubs/platform.ts diff --git a/x-pack/plugins/canvas/public/application.tsx b/x-pack/plugins/canvas/public/application.tsx index b2c836fe4805..0bbf449ce11f 100644 --- a/x-pack/plugins/canvas/public/application.tsx +++ b/x-pack/plugins/canvas/public/application.tsx @@ -31,7 +31,7 @@ import { init as initStatsReporter } from './lib/ui_metric'; import { CapabilitiesStrings } from '../i18n'; -import { startServices, services } from './services'; +import { startServices, services, ServicesProvider } from './services'; // @ts-expect-error untyped local import { createHistory, destroyHistory } from './lib/history_provider'; // @ts-expect-error untyped local @@ -52,19 +52,16 @@ export const renderApp = ( ) => { element.classList.add('canvas'); element.classList.add('canvasContainerWrapper'); - const canvasServices = Object.entries(services).reduce((reduction, [key, provider]) => { - reduction[key] = provider.getService(); - - return reduction; - }, {} as Record); ReactDOM.render( - - - - - - + + + + + + + + , element ); diff --git a/x-pack/plugins/canvas/public/components/app/index.js b/x-pack/plugins/canvas/public/components/app/index.js index a1e3b9c09554..9a6e8719e7f4 100644 --- a/x-pack/plugins/canvas/public/components/app/index.js +++ b/x-pack/plugins/canvas/public/components/app/index.js @@ -8,7 +8,7 @@ import { connect } from 'react-redux'; import { compose, withProps } from 'recompose'; import { getAppReady, getBasePath } from '../../state/selectors/app'; import { appReady, appError } from '../../state/actions/app'; -import { withKibana } from '../../../../../../src/plugins/kibana_react/public'; +import { withServices } from '../../services'; import { App as Component } from './app'; @@ -45,8 +45,8 @@ const mergeProps = (stateProps, dispatchProps, ownProps) => { export const App = compose( connect(mapStateToProps, mapDispatchToProps, mergeProps), - withKibana, + withServices, withProps((props) => ({ - onRouteChange: props.kibana.services.canvas.navLink.updatePath, + onRouteChange: props.services.navLink.updatePath, })) )(Component); diff --git a/x-pack/plugins/canvas/public/components/app/track_route_change.js b/x-pack/plugins/canvas/public/components/app/track_route_change.js deleted file mode 100644 index 2886aa868eb9..000000000000 --- a/x-pack/plugins/canvas/public/components/app/track_route_change.js +++ /dev/null @@ -1,23 +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 { get } from 'lodash'; -import { getWindow } from '../../lib/get_window'; -import { CANVAS_APP } from '../../../common/lib/constants'; -import { platformService } from '../../services'; - -export function trackRouteChange() { - const basePath = platformService.getService().coreStart.http.basePath.get(); - - platformService - .getService() - .startPlugins.__LEGACY.trackSubUrlForApp( - CANVAS_APP, - platformService - .getService() - .startPlugins.__LEGACY.absoluteToParsedUrl(get(getWindow(), 'location.href'), basePath) - ); -} diff --git a/x-pack/plugins/canvas/public/components/element_content/index.js b/x-pack/plugins/canvas/public/components/element_content/index.js index a138c3acb8ec..63ece6ac3281 100644 --- a/x-pack/plugins/canvas/public/components/element_content/index.js +++ b/x-pack/plugins/canvas/public/components/element_content/index.js @@ -8,8 +8,8 @@ import PropTypes from 'prop-types'; import { connect } from 'react-redux'; import { compose, withProps } from 'recompose'; import { get } from 'lodash'; +import { withServices } from '../../services'; import { getSelectedPage, getPageById } from '../../state/selectors/workpad'; -import { withKibana } from '../../../../../../src/plugins/kibana_react/public'; import { ElementContent as Component } from './element_content'; const mapStateToProps = (state) => ({ @@ -18,9 +18,9 @@ const mapStateToProps = (state) => ({ export const ElementContent = compose( connect(mapStateToProps), - withKibana, - withProps(({ renderable, kibana }) => ({ - renderFunction: kibana.services.expressions.getRenderer(get(renderable, 'as')), + withServices, + withProps(({ renderable, services }) => ({ + renderFunction: services.expressions.getRenderer(get(renderable, 'as')), })) )(Component); diff --git a/x-pack/plugins/canvas/public/components/embeddable_flyout/flyout.tsx b/x-pack/plugins/canvas/public/components/embeddable_flyout/flyout.tsx index df9dad3e7f67..0b5bd8adf8cb 100644 --- a/x-pack/plugins/canvas/public/components/embeddable_flyout/flyout.tsx +++ b/x-pack/plugins/canvas/public/components/embeddable_flyout/flyout.tsx @@ -4,15 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { FC } from 'react'; import { EuiFlyout, EuiFlyoutHeader, EuiFlyoutBody, EuiTitle } from '@elastic/eui'; import { SavedObjectFinderUi, SavedObjectMetaData, } from '../../../../../../src/plugins/saved_objects/public/'; import { ComponentStrings } from '../../../i18n'; -import { CoreStart } from '../../../../../../src/core/public'; -import { CanvasStartDeps } from '../../plugin'; +import { useServices } from '../../services'; const { AddEmbeddableFlyout: strings } = ComponentStrings; @@ -20,14 +19,16 @@ export interface Props { onClose: () => void; onSelect: (id: string, embeddableType: string) => void; availableEmbeddables: string[]; - savedObjects: CoreStart['savedObjects']; - uiSettings: CoreStart['uiSettings']; - getEmbeddableFactories: CanvasStartDeps['embeddable']['getEmbeddableFactories']; } -export class AddEmbeddableFlyout extends React.Component { - onAddPanel = (id: string, savedObjectType: string, name: string) => { - const embeddableFactories = this.props.getEmbeddableFactories(); +export const AddEmbeddableFlyout: FC = ({ onSelect, availableEmbeddables, onClose }) => { + const services = useServices(); + const { embeddables, platform } = services; + const { getEmbeddableFactories } = embeddables; + const { getSavedObjects, getUISettings } = platform; + + const onAddPanel = (id: string, savedObjectType: string, name: string) => { + const embeddableFactories = getEmbeddableFactories(); // Find the embeddable type from the saved object type const found = Array.from(embeddableFactories).find((embeddableFactory) => { @@ -39,41 +40,39 @@ export class AddEmbeddableFlyout extends React.Component { const foundEmbeddableType = found ? found.type : 'unknown'; - this.props.onSelect(id, foundEmbeddableType); + onSelect(id, foundEmbeddableType); }; - render() { - const embeddableFactories = this.props.getEmbeddableFactories(); + const embeddableFactories = getEmbeddableFactories(); - const availableSavedObjects = Array.from(embeddableFactories) - .filter((factory) => { - return this.props.availableEmbeddables.includes(factory.type); - }) - .map((factory) => factory.savedObjectMetaData) - .filter>(function ( - maybeSavedObjectMetaData - ): maybeSavedObjectMetaData is SavedObjectMetaData<{}> { - return maybeSavedObjectMetaData !== undefined; - }); + const availableSavedObjects = Array.from(embeddableFactories) + .filter((factory) => { + return availableEmbeddables.includes(factory.type); + }) + .map((factory) => factory.savedObjectMetaData) + .filter>(function ( + maybeSavedObjectMetaData + ): maybeSavedObjectMetaData is SavedObjectMetaData<{}> { + return maybeSavedObjectMetaData !== undefined; + }); - return ( - - - -

{strings.getTitleText()}

-
-
- - - -
- ); - } -} + return ( + + + +

{strings.getTitleText()}

+
+
+ + + +
+ ); +}; diff --git a/x-pack/plugins/canvas/public/components/embeddable_flyout/index.tsx b/x-pack/plugins/canvas/public/components/embeddable_flyout/index.tsx index 9462ba0411de..62a073daf4c5 100644 --- a/x-pack/plugins/canvas/public/components/embeddable_flyout/index.tsx +++ b/x-pack/plugins/canvas/public/components/embeddable_flyout/index.tsx @@ -14,8 +14,6 @@ import { AddEmbeddableFlyout, Props } from './flyout'; import { addElement } from '../../state/actions/elements'; import { getSelectedPage } from '../../state/selectors/workpad'; import { EmbeddableTypes } from '../../../canvas_plugin_src/expression_types/embeddable'; -import { WithKibanaProps } from '../../index'; -import { withKibana } from '../../../../../../src/plugins/kibana_react/public'; const allowedEmbeddables = { [EmbeddableTypes.map]: (id: string) => { @@ -74,10 +72,10 @@ const mergeProps = ( }; }; -export class EmbeddableFlyoutPortal extends React.Component { +export class EmbeddableFlyoutPortal extends React.Component { el?: HTMLElement; - constructor(props: Props & WithKibanaProps) { + constructor(props: Props) { super(props); this.el = document.createElement('div'); @@ -103,9 +101,6 @@ export class EmbeddableFlyoutPortal extends React.Component, this.el ); @@ -113,7 +108,6 @@ export class EmbeddableFlyoutPortal extends React.Component void }>( - connect(mapStateToProps, mapDispatchToProps, mergeProps), - withKibana +export const AddEmbeddablePanel = compose void }>( + connect(mapStateToProps, mapDispatchToProps, mergeProps) )(EmbeddableFlyoutPortal); diff --git a/x-pack/plugins/canvas/public/components/expression/index.js b/x-pack/plugins/canvas/public/components/expression/index.js index 4480169dd037..146acbcd6c6e 100644 --- a/x-pack/plugins/canvas/public/components/expression/index.js +++ b/x-pack/plugins/canvas/public/components/expression/index.js @@ -15,7 +15,7 @@ import { renderComponent, } from 'recompose'; import { fromExpression } from '@kbn/interpreter/common'; -import { withKibana } from '../../../../../../src/plugins/kibana_react/public'; +import { withServices } from '../../services'; import { getSelectedPage, getSelectedElement } from '../../state/selectors/workpad'; import { setExpression, flushContext } from '../../state/actions/elements'; import { ElementNotSelected } from './element_not_selected'; @@ -46,7 +46,7 @@ const mergeProps = (stateProps, dispatchProps, ownProps) => { const { expression } = element; - const functions = Object.values(allProps.kibana.services.expressions.getFunctions()); + const functions = Object.values(allProps.services.expressions.getFunctions()); return { ...allProps, @@ -71,7 +71,7 @@ const expressionLifecycle = lifecycle({ }); export const Expression = compose( - withKibana, + withServices, connect(mapStateToProps, mapDispatchToProps, mergeProps), withState('formState', 'setFormState', ({ expression }) => ({ expression, diff --git a/x-pack/plugins/canvas/public/components/render_with_fn/render_with_fn.tsx b/x-pack/plugins/canvas/public/components/render_with_fn/render_with_fn.tsx index bc51128cf0c8..7939c1d04631 100644 --- a/x-pack/plugins/canvas/public/components/render_with_fn/render_with_fn.tsx +++ b/x-pack/plugins/canvas/public/components/render_with_fn/render_with_fn.tsx @@ -7,7 +7,7 @@ import React, { useState, useEffect, useRef, FC, useCallback } from 'react'; import { useDebounce } from 'react-use'; -import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; +import { useNotifyService } from '../../services'; import { RenderToDom } from '../render_to_dom'; import { ErrorStrings } from '../../../i18n'; import { RendererHandlers } from '../../../types'; @@ -39,8 +39,7 @@ export const RenderWithFn: FC = ({ width, height, }) => { - const { services } = useKibana(); - const onError = services.canvas.notify.error; + const { error: onError } = useNotifyService(); const [domNode, setDomNode] = useState(null); diff --git a/x-pack/plugins/canvas/public/components/saved_elements_modal/index.ts b/x-pack/plugins/canvas/public/components/saved_elements_modal/index.ts index c5c1dbc2fdd6..da2955c14619 100644 --- a/x-pack/plugins/canvas/public/components/saved_elements_modal/index.ts +++ b/x-pack/plugins/canvas/public/components/saved_elements_modal/index.ts @@ -10,8 +10,7 @@ import { compose, withState } from 'recompose'; import { camelCase } from 'lodash'; import { cloneSubgraphs } from '../../lib/clone_subgraphs'; import * as customElementService from '../../lib/custom_element_service'; -import { withKibana } from '../../../../../../src/plugins/kibana_react/public'; -import { WithKibanaProps } from '../../'; +import { withServices, WithServicesProps } from '../../services'; // @ts-expect-error untyped local import { selectToplevelNodes } from '../../state/actions/transient'; // @ts-expect-error untyped local @@ -63,7 +62,7 @@ const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({ const mergeProps = ( stateProps: StateProps, dispatchProps: DispatchProps, - ownProps: OwnPropsWithState & WithKibanaProps + ownProps: OwnPropsWithState & WithServicesProps ): ComponentProps => { const { pageId } = stateProps; const { onClose, search, setCustomElements } = ownProps; @@ -91,7 +90,7 @@ const mergeProps = ( try { await findCustomElements(); } catch (err) { - ownProps.kibana.services.canvas.notify.error(err, { + ownProps.services.notify.error(err, { title: `Couldn't find custom elements`, }); } @@ -102,7 +101,7 @@ const mergeProps = ( await customElementService.remove(id); await findCustomElements(); } catch (err) { - ownProps.kibana.services.canvas.notify.error(err, { + ownProps.services.notify.error(err, { title: `Couldn't delete custom elements`, }); } @@ -118,7 +117,7 @@ const mergeProps = ( }); await findCustomElements(); } catch (err) { - ownProps.kibana.services.canvas.notify.error(err, { + ownProps.services.notify.error(err, { title: `Couldn't update custom elements`, }); } @@ -127,7 +126,7 @@ const mergeProps = ( }; export const SavedElementsModal = compose( - withKibana, + withServices, withState('search', 'setSearch', ''), withState('customElements', 'setCustomElements', []), connect(mapStateToProps, mapDispatchToProps, mergeProps) diff --git a/x-pack/plugins/canvas/public/components/var_config/index.tsx b/x-pack/plugins/canvas/public/components/var_config/index.tsx index 526037b79e0e..ca40bd07877f 100644 --- a/x-pack/plugins/canvas/public/components/var_config/index.tsx +++ b/x-pack/plugins/canvas/public/components/var_config/index.tsx @@ -7,27 +7,19 @@ import React, { FC } from 'react'; import copy from 'copy-to-clipboard'; import { VarConfig as ChildComponent } from './var_config'; -import { - withKibana, - KibanaReactContextValue, - KibanaServices, -} from '../../../../../../src/plugins/kibana_react/public'; -import { CanvasServices } from '../../services'; - +import { useNotifyService } from '../../services'; import { ComponentStrings } from '../../../i18n'; - import { CanvasVariable } from '../../../types'; const { VarConfig: strings } = ComponentStrings; interface Props { - kibana: KibanaReactContextValue<{ canvas: CanvasServices } & KibanaServices>; - variables: CanvasVariable[]; setVariables: (variables: CanvasVariable[]) => void; } -const WrappedComponent: FC = ({ kibana, variables, setVariables }) => { +export const VarConfig: FC = ({ variables, setVariables }) => { + const { success } = useNotifyService(); const onDeleteVar = (v: CanvasVariable) => { const index = variables.findIndex((targetVar: CanvasVariable) => { return targetVar.name === v.name; @@ -36,15 +28,14 @@ const WrappedComponent: FC = ({ kibana, variables, setVariables }) => { const newVars = [...variables]; newVars.splice(index, 1); setVariables(newVars); - - kibana.services.canvas.notify.success(strings.getDeleteNotificationDescription()); + success(strings.getDeleteNotificationDescription()); } }; const onCopyVar = (v: CanvasVariable) => { const snippetStr = `{var "${v.name}"}`; copy(snippetStr, { debug: true }); - kibana.services.canvas.notify.success(strings.getCopyNotificationDescription()); + success(strings.getCopyNotificationDescription()); }; const onAddVar = (v: CanvasVariable) => { @@ -62,5 +53,3 @@ const WrappedComponent: FC = ({ kibana, variables, setVariables }) => { return ; }; - -export const VarConfig = withKibana(WrappedComponent); diff --git a/x-pack/plugins/canvas/public/components/workpad_header/element_menu/index.tsx b/x-pack/plugins/canvas/public/components/workpad_header/element_menu/index.tsx index 13b2cace13a4..264873fc994d 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/element_menu/index.tsx +++ b/x-pack/plugins/canvas/public/components/workpad_header/element_menu/index.tsx @@ -8,7 +8,6 @@ import React from 'react'; import { connect } from 'react-redux'; import { compose, withProps } from 'recompose'; import { Dispatch } from 'redux'; -import { withKibana } from '../../../../../../../src/plugins/kibana_react/public/'; import { State, ElementSpec } from '../../../../types'; // @ts-expect-error untyped local import { elementsRegistry } from '../../../lib/elements_registry'; @@ -44,6 +43,5 @@ const mergeProps = (stateProps: StateProps, dispatchProps: DispatchProps) => ({ export const ElementMenu = compose( connect(mapStateToProps, mapDispatchToProps, mergeProps), - withKibana, withProps(() => ({ elements: elementsRegistry.toJS() })) )(Component); diff --git a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/index.ts b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/index.ts index 17fcc50334a8..01bcfebc0dba 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/share_menu/index.ts +++ b/x-pack/plugins/canvas/public/components/workpad_header/share_menu/index.ts @@ -13,8 +13,7 @@ import { downloadWorkpad } from '../../../lib/download_workpad'; import { ShareMenu as Component, Props as ComponentProps } from './share_menu'; import { getPdfUrl, createPdf } from './utils'; import { State, CanvasWorkpad } from '../../../../types'; -import { withKibana } from '../../../../../../../src/plugins/kibana_react/public/'; -import { WithKibanaProps } from '../../../index'; +import { withServices, WithServicesProps } from '../../../services'; import { ComponentStrings } from '../../../../i18n'; @@ -43,12 +42,16 @@ interface Props { export const ShareMenu = compose( connect(mapStateToProps), - withKibana, + withServices, withProps( - ({ workpad, pageCount, kibana }: Props & WithKibanaProps): ComponentProps => ({ + ({ workpad, pageCount, services }: Props & WithServicesProps): ComponentProps => ({ getExportUrl: (type) => { if (type === 'pdf') { - const pdfUrl = getPdfUrl(workpad, { pageCount }, kibana.services.http.basePath); + const pdfUrl = getPdfUrl( + workpad, + { pageCount }, + services.platform.getBasePathInterface() + ); return getAbsoluteUrl(pdfUrl); } @@ -57,10 +60,10 @@ export const ShareMenu = compose( onCopy: (type) => { switch (type) { case 'pdf': - kibana.services.canvas.notify.info(strings.getCopyPDFMessage()); + services.notify.info(strings.getCopyPDFMessage()); break; case 'reportingConfig': - kibana.services.canvas.notify.info(strings.getCopyReportingConfigMessage()); + services.notify.info(strings.getCopyReportingConfigMessage()); break; default: throw new Error(strings.getUnknownExportErrorMessage(type)); @@ -69,9 +72,9 @@ export const ShareMenu = compose( onExport: (type) => { switch (type) { case 'pdf': - return createPdf(workpad, { pageCount }, kibana.services.http.basePath) + return createPdf(workpad, { pageCount }, services.platform.getBasePathInterface()) .then(({ data }: { data: { job: { id: string } } }) => { - kibana.services.canvas.notify.info(strings.getExportPDFMessage(), { + services.notify.info(strings.getExportPDFMessage(), { title: strings.getExportPDFTitle(workpad.name), }); @@ -79,7 +82,7 @@ export const ShareMenu = compose( jobCompletionNotifications.add(data.job.id); }) .catch((err: Error) => { - kibana.services.canvas.notify.error(err, { + services.notify.error(err, { title: strings.getExportPDFErrorTitle(workpad.name), }); }); diff --git a/x-pack/plugins/canvas/public/components/workpad_header/view_menu/index.ts b/x-pack/plugins/canvas/public/components/workpad_header/view_menu/index.ts index ddf1a12775ca..e2a05d13b017 100644 --- a/x-pack/plugins/canvas/public/components/workpad_header/view_menu/index.ts +++ b/x-pack/plugins/canvas/public/components/workpad_header/view_menu/index.ts @@ -7,7 +7,6 @@ import { connect } from 'react-redux'; import { compose, withHandlers } from 'recompose'; import { Dispatch } from 'redux'; -import { withKibana } from '../../../../../../../src/plugins/kibana_react/public/'; import { zoomHandlerCreators } from '../../../lib/app_handler_creators'; import { State, CanvasWorkpadBoundingBox } from '../../../../types'; // @ts-expect-error untyped local @@ -97,6 +96,5 @@ const mergeProps = ( export const ViewMenu = compose( connect(mapStateToProps, mapDispatchToProps, mergeProps), - withKibana, withHandlers(zoomHandlerCreators) )(Component); diff --git a/x-pack/plugins/canvas/public/components/workpad_loader/index.js b/x-pack/plugins/canvas/public/components/workpad_loader/index.js index ab07d5d72240..f747cb677a57 100644 --- a/x-pack/plugins/canvas/public/components/workpad_loader/index.js +++ b/x-pack/plugins/canvas/public/components/workpad_loader/index.js @@ -14,7 +14,7 @@ import { getWorkpad } from '../../state/selectors/workpad'; import { getId } from '../../lib/get_id'; import { downloadWorkpad } from '../../lib/download_workpad'; import { ComponentStrings, ErrorStrings } from '../../../i18n'; -import { withKibana } from '../../../../../../src/plugins/kibana_react/public'; +import { withServices } from '../../services'; import { WorkpadLoader as Component } from './workpad_loader'; const { WorkpadLoader: strings } = ComponentStrings; @@ -31,11 +31,11 @@ export const WorkpadLoader = compose( }), connect(mapStateToProps), withState('workpads', 'setWorkpads', null), - withKibana, - withProps(({ kibana }) => ({ - notify: kibana.services.canvas.notify, + withServices, + withProps(({ services }) => ({ + notify: services.notify, })), - withHandlers(({ kibana }) => ({ + withHandlers(({ services }) => ({ // Workpad creation via navigation createWorkpad: (props) => async (workpad) => { // workpad data uploaded, create and load it @@ -44,7 +44,7 @@ export const WorkpadLoader = compose( await workpadService.create(workpad); props.router.navigateTo('loadWorkpad', { id: workpad.id, page: 1 }); } catch (err) { - kibana.services.canvas.notify.error(err, { + services.notify.error(err, { title: errors.getUploadFailureErrorMessage(), }); } @@ -60,7 +60,7 @@ export const WorkpadLoader = compose( const workpads = await workpadService.find(text); setWorkpads(workpads); } catch (err) { - kibana.services.canvas.notify.error(err, { title: errors.getFindFailureErrorMessage() }); + services.notify.error(err, { title: errors.getFindFailureErrorMessage() }); } }, @@ -76,7 +76,7 @@ export const WorkpadLoader = compose( await workpadService.create(workpad); props.router.navigateTo('loadWorkpad', { id: workpad.id, page: 1 }); } catch (err) { - kibana.services.canvas.notify.error(err, { title: errors.getCloneFailureErrorMessage() }); + services.notify.error(err, { title: errors.getCloneFailureErrorMessage() }); } }, @@ -122,7 +122,7 @@ export const WorkpadLoader = compose( }; if (errored.length > 0) { - kibana.services.canvas.notify.error(errors.getDeleteFailureErrorMessage()); + services.notify.error(errors.getDeleteFailureErrorMessage()); } setWorkpads(workpadState); @@ -137,7 +137,7 @@ export const WorkpadLoader = compose( })), withProps((props) => ({ formatDate: (date) => { - const dateFormat = props.kibana.services.uiSettings.get('dateFormat'); + const dateFormat = props.services.platform.getUISetting('dateFormat'); return date && moment(date).format(dateFormat); }, })) diff --git a/x-pack/plugins/canvas/public/components/workpad_templates/index.tsx b/x-pack/plugins/canvas/public/components/workpad_templates/index.tsx index f35bba3fd598..35b0e2bb19e3 100644 --- a/x-pack/plugins/canvas/public/components/workpad_templates/index.tsx +++ b/x-pack/plugins/canvas/public/components/workpad_templates/index.tsx @@ -10,12 +10,11 @@ import { RouterContext } from '../router'; import { ComponentStrings } from '../../../i18n/components'; // @ts-expect-error import * as workpadService from '../../lib/workpad_service'; -import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; import { WorkpadTemplates as Component } from './workpad_templates'; import { CanvasTemplate } from '../../../types'; -import { UseKibanaProps } from '../../'; import { list } from '../../lib/template_service'; import { applyTemplateStrings } from '../../../i18n/templates/apply_strings'; +import { useNotifyService } from '../../services'; interface WorkpadTemplatesProps { onClose: () => void; @@ -33,7 +32,7 @@ export const WorkpadTemplates: FunctionComponent = ({ onC const [creatingFromTemplateName, setCreatingFromTemplateName] = useState( undefined ); - const kibana = useKibana(); + const { error } = useNotifyService(); useEffect(() => { if (!templates) { @@ -60,9 +59,9 @@ export const WorkpadTemplates: FunctionComponent = ({ onC if (router) { router.navigateTo('loadWorkpad', { id: result.data.id, page: 1 }); } - } catch (error) { + } catch (e) { setCreatingFromTemplateName(undefined); - kibana.services.canvas.notify.error(error, { + error(e, { title: `Couldn't create workpad from template`, }); } diff --git a/x-pack/plugins/canvas/public/lib/breadcrumbs.ts b/x-pack/plugins/canvas/public/lib/breadcrumbs.ts index 96412ef50c79..b613bb7fcdaf 100644 --- a/x-pack/plugins/canvas/public/lib/breadcrumbs.ts +++ b/x-pack/plugins/canvas/public/lib/breadcrumbs.ts @@ -24,6 +24,5 @@ export const getWorkpadBreadcrumb = ({ }; export const setBreadcrumb = (paths: ChromeBreadcrumb | ChromeBreadcrumb[]) => { - const setBreadCrumbs = platformService.getService().coreStart.chrome.setBreadcrumbs; - setBreadCrumbs(Array.isArray(paths) ? paths : [paths]); + platformService.getService().setBreadcrumbs(Array.isArray(paths) ? paths : [paths]); }; diff --git a/x-pack/plugins/canvas/public/lib/custom_element_service.ts b/x-pack/plugins/canvas/public/lib/custom_element_service.ts index 25c3b78a2746..f240df93d038 100644 --- a/x-pack/plugins/canvas/public/lib/custom_element_service.ts +++ b/x-pack/plugins/canvas/public/lib/custom_element_service.ts @@ -11,7 +11,7 @@ import { CustomElement } from '../../types'; import { platformService } from '../services'; const getApiPath = function () { - const basePath = platformService.getService().coreStart.http.basePath.get(); + const basePath = platformService.getService().getBasePath(); return `${basePath}${API_ROUTE_CUSTOM_ELEMENT}`; }; diff --git a/x-pack/plugins/canvas/public/lib/documentation_links.ts b/x-pack/plugins/canvas/public/lib/documentation_links.ts index 6430f7d87d4f..cb1938929102 100644 --- a/x-pack/plugins/canvas/public/lib/documentation_links.ts +++ b/x-pack/plugins/canvas/public/lib/documentation_links.ts @@ -7,10 +7,14 @@ import { platformService } from '../services'; export const getDocumentationLinks = () => ({ - canvas: `${platformService.getService().coreStart.docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${ - platformService.getService().coreStart.docLinks.DOC_LINK_VERSION - }/canvas.html`, - numeral: `${platformService.getService().coreStart.docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${ - platformService.getService().coreStart.docLinks.DOC_LINK_VERSION - }/guide/numeral.html`, + canvas: `${platformService + .getService() + .getElasticWebsiteUrl()}guide/en/kibana/${platformService + .getService() + .getDocLinkVersion()}/canvas.html`, + numeral: `${platformService + .getService() + .getElasticWebsiteUrl()}guide/en/kibana/${platformService + .getService() + .getDocLinkVersion()}/guide/numeral.html`, }); diff --git a/x-pack/plugins/canvas/public/lib/es_service.ts b/x-pack/plugins/canvas/public/lib/es_service.ts index 5c1131d5fbe3..fee66c71636c 100644 --- a/x-pack/plugins/canvas/public/lib/es_service.ts +++ b/x-pack/plugins/canvas/public/lib/es_service.ts @@ -15,16 +15,16 @@ import { platformService } from '../services'; const { esService: strings } = ErrorStrings; const getApiPath = function () { - const basePath = platformService.getService().coreStart.http.basePath.get(); + const basePath = platformService.getService().getBasePath(); return basePath + API_ROUTE; }; const getSavedObjectsClient = function () { - return platformService.getService().coreStart.savedObjects.client; + return platformService.getService().getSavedObjectsClient(); }; const getAdvancedSettings = function () { - return platformService.getService().coreStart.uiSettings; + return platformService.getService().getUISettings(); }; export const getFields = (index = '_all') => { diff --git a/x-pack/plugins/canvas/public/lib/template_service.ts b/x-pack/plugins/canvas/public/lib/template_service.ts index 98d582c854e3..185b2ec37ba9 100644 --- a/x-pack/plugins/canvas/public/lib/template_service.ts +++ b/x-pack/plugins/canvas/public/lib/template_service.ts @@ -10,7 +10,7 @@ import { platformService } from '../services'; import { CanvasTemplate } from '../../types'; const getApiPath = function () { - const basePath = platformService.getService().coreStart.http.basePath.get(); + const basePath = platformService.getService().getBasePath(); return `${basePath}${API_ROUTE_TEMPLATES}`; }; diff --git a/x-pack/plugins/canvas/public/lib/workpad_service.js b/x-pack/plugins/canvas/public/lib/workpad_service.js index 2047e20424ac..27efe25405fd 100644 --- a/x-pack/plugins/canvas/public/lib/workpad_service.js +++ b/x-pack/plugins/canvas/public/lib/workpad_service.js @@ -12,6 +12,7 @@ import { } from '../../common/lib/constants'; import { fetch } from '../../common/lib/fetch'; import { platformService } from '../services'; + /* Remove any top level keys from the workpad which will be rejected by validation */ @@ -44,17 +45,17 @@ const sanitizeWorkpad = function (workpad) { }; const getApiPath = function () { - const basePath = platformService.getService().coreStart.http.basePath.get(); + const basePath = platformService.getService().getBasePath(); return `${basePath}${API_ROUTE_WORKPAD}`; }; const getApiPathStructures = function () { - const basePath = platformService.getService().coreStart.http.basePath.get(); + const basePath = platformService.getService().getBasePath(); return `${basePath}${API_ROUTE_WORKPAD_STRUCTURES}`; }; const getApiPathAssets = function () { - const basePath = platformService.getService().coreStart.http.basePath.get(); + const basePath = platformService.getService().getBasePath(); return `${basePath}${API_ROUTE_WORKPAD_ASSETS}`; }; diff --git a/x-pack/plugins/canvas/public/services/context.tsx b/x-pack/plugins/canvas/public/services/context.tsx new file mode 100644 index 000000000000..9bd86ef98f1e --- /dev/null +++ b/x-pack/plugins/canvas/public/services/context.tsx @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { + useContext, + createElement, + createContext, + ComponentType, + FC, + ReactElement, +} from 'react'; +import { CanvasServices, CanvasServiceProviders } from '.'; + +export interface WithServicesProps { + services: CanvasServices; +} + +const defaultContextValue = { + embeddables: {}, + expressions: {}, + notify: {}, + platform: {}, + navLink: {}, +}; + +const context = createContext(defaultContextValue as CanvasServices); + +export const useServices = () => useContext(context); +export const usePlatformService = () => useServices().platform; +export const useEmbeddablesService = () => useServices().embeddables; +export const useExpressionsService = () => useServices().expressions; +export const useNotifyService = () => useServices().notify; +export const useNavLinkService = () => useServices().navLink; + +export const withServices = (type: ComponentType) => { + const EnhancedType: FC = (props) => { + const services = useServices(); + return createElement(type, { ...props, services }); + }; + return EnhancedType; +}; + +export const ServicesProvider: FC<{ + providers: CanvasServiceProviders; + children: ReactElement; +}> = ({ providers, children }) => { + const value = { + embeddables: providers.embeddables.getService(), + expressions: providers.expressions.getService(), + notify: providers.notify.getService(), + platform: providers.platform.getService(), + navLink: providers.navLink.getService(), + }; + return {children}; +}; diff --git a/x-pack/plugins/canvas/public/services/embeddables.ts b/x-pack/plugins/canvas/public/services/embeddables.ts new file mode 100644 index 000000000000..13e308effcdb --- /dev/null +++ b/x-pack/plugins/canvas/public/services/embeddables.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EmbeddableFactory } from '../../../../../src/plugins/embeddable/public'; +import { CanvasServiceFactory } from '.'; + +export interface EmbeddablesService { + getEmbeddableFactories: () => IterableIterator; +} + +export const embeddablesServiceFactory: CanvasServiceFactory = async ( + _coreSetup, + _coreStart, + _setupPlugins, + startPlugins +) => ({ + getEmbeddableFactories: startPlugins.embeddable.getEmbeddableFactories, +}); diff --git a/x-pack/plugins/canvas/public/services/expressions.ts b/x-pack/plugins/canvas/public/services/expressions.ts index 16f939a9c97f..1376aab0ca8b 100644 --- a/x-pack/plugins/canvas/public/services/expressions.ts +++ b/x-pack/plugins/canvas/public/services/expressions.ts @@ -14,6 +14,5 @@ export const expressionsServiceFactory: CanvasServiceFactory startPlugins ) => { await setupPlugins.expressions.__LEGACY.loadLegacyServerFunctionWrappers(); - return setupPlugins.expressions.fork(); }; diff --git a/x-pack/plugins/canvas/public/services/index.ts b/x-pack/plugins/canvas/public/services/index.ts index a929b4639d3e..700d874d4507 100644 --- a/x-pack/plugins/canvas/public/services/index.ts +++ b/x-pack/plugins/canvas/public/services/index.ts @@ -10,8 +10,16 @@ import { CanvasSetupDeps, CanvasStartDeps } from '../plugin'; import { notifyServiceFactory } from './notify'; import { platformServiceFactory } from './platform'; import { navLinkServiceFactory } from './nav_link'; +import { embeddablesServiceFactory } from './embeddables'; import { expressionsServiceFactory } from './expressions'; +export { NotifyService } from './notify'; +export { PlatformService } from './platform'; +export { NavLinkService } from './nav_link'; +export { EmbeddablesService } from './embeddables'; +export { ExpressionsService } from '../../../../../src/plugins/expressions/common'; +export * from './context'; + export type CanvasServiceFactory = ( coreSetup: CoreSetup, coreStart: CoreStart, @@ -28,6 +36,10 @@ class CanvasServiceProvider { this.factory = factory; } + setService(service: Service) { + this.service = service; + } + async start( coreSetup: CoreSetup, coreStart: CoreStart, @@ -60,13 +72,17 @@ class CanvasServiceProvider { export type ServiceFromProvider

= P extends CanvasServiceProvider ? T : never; export const services = { + embeddables: new CanvasServiceProvider(embeddablesServiceFactory), expressions: new CanvasServiceProvider(expressionsServiceFactory), notify: new CanvasServiceProvider(notifyServiceFactory), platform: new CanvasServiceProvider(platformServiceFactory), navLink: new CanvasServiceProvider(navLinkServiceFactory), }; +export type CanvasServiceProviders = typeof services; + export interface CanvasServices { + embeddables: ServiceFromProvider; expressions: ServiceFromProvider; notify: ServiceFromProvider; platform: ServiceFromProvider; @@ -88,10 +104,11 @@ export const startServices = async ( }; export const stopServices = () => { - Object.entries(services).forEach(([key, provider]) => provider.stop()); + Object.values(services).forEach((provider) => provider.stop()); }; export const { + embeddables: embeddableService, notify: notifyService, platform: platformService, navLink: navLinkService, diff --git a/x-pack/plugins/canvas/public/services/nav_link.ts b/x-pack/plugins/canvas/public/services/nav_link.ts index 68d685242351..532b5264ee9e 100644 --- a/x-pack/plugins/canvas/public/services/nav_link.ts +++ b/x-pack/plugins/canvas/public/services/nav_link.ts @@ -8,15 +8,15 @@ import { CanvasServiceFactory } from '.'; import { SESSIONSTORAGE_LASTPATH } from '../../common/lib/constants'; import { getSessionStorage } from '../lib/storage'; -interface NavLinkService { +export interface NavLinkService { updatePath: (path: string) => void; } export const navLinkServiceFactory: CanvasServiceFactory = ( coreSetup, - coreStart, - setupPlugins, - startPlugins, + _coreStart, + _setupPlugins, + _startPlugins, appUpdater ) => { return { diff --git a/x-pack/plugins/canvas/public/services/notify.ts b/x-pack/plugins/canvas/public/services/notify.ts index 5454a0f87c3f..819525c8fa92 100644 --- a/x-pack/plugins/canvas/public/services/notify.ts +++ b/x-pack/plugins/canvas/public/services/notify.ts @@ -26,7 +26,7 @@ const getToast = (err: Error | string, opts: ToastInputFields = {}) => { }; }; -interface NotifyService { +export interface NotifyService { error: (err: string | Error, opts?: ToastInputFields) => void; warning: (err: string | Error, opts?: ToastInputFields) => void; info: (err: string | Error, opts?: ToastInputFields) => void; diff --git a/x-pack/plugins/canvas/public/services/platform.ts b/x-pack/plugins/canvas/public/services/platform.ts index 440e9523044c..92c378e9aa59 100644 --- a/x-pack/plugins/canvas/public/services/platform.ts +++ b/x-pack/plugins/canvas/public/services/platform.ts @@ -4,21 +4,52 @@ * you may not use this file except in compliance with the Elastic License. */ +import { + SavedObjectsStart, + SavedObjectsClientContract, + IUiSettingsClient, + ChromeBreadcrumb, + IBasePath, +} from '../../../../../src/core/public'; import { CanvasServiceFactory } from '.'; -import { CoreStart, CoreSetup, CanvasSetupDeps, CanvasStartDeps } from '../plugin'; -interface PlatformService { - coreSetup: CoreSetup; - coreStart: CoreStart; - setupPlugins: CanvasSetupDeps; - startPlugins: CanvasStartDeps; +export interface PlatformService { + getBasePath: () => string; + getBasePathInterface: () => IBasePath; + getDocLinkVersion: () => string; + getElasticWebsiteUrl: () => string; + getHasWriteAccess: () => boolean; + getUISetting: (key: string, defaultValue?: any) => any; + setBreadcrumbs: (newBreadcrumbs: ChromeBreadcrumb[]) => void; + setRecentlyAccessed: (link: string, label: string, id: string) => void; + + // TODO: these should go away. We want thin accessors, not entire objects. + // Entire objects are hard to mock, and hide our dependency on the external service. + getSavedObjects: () => SavedObjectsStart; + getSavedObjectsClient: () => SavedObjectsClientContract; + getUISettings: () => IUiSettingsClient; } export const platformServiceFactory: CanvasServiceFactory = ( - coreSetup, - coreStart, - setupPlugins, - startPlugins + _coreSetup, + coreStart ) => { - return { coreSetup, coreStart, setupPlugins, startPlugins }; + return { + getBasePath: coreStart.http.basePath.get, + getBasePathInterface: () => coreStart.http.basePath, + getElasticWebsiteUrl: () => coreStart.docLinks.ELASTIC_WEBSITE_URL, + getDocLinkVersion: () => coreStart.docLinks.DOC_LINK_VERSION, + // TODO: is there a better type for this? The capabilities type allows for a Record, + // though we don't do this. So this cast may be the best option. + getHasWriteAccess: () => coreStart.application.capabilities.canvas.save as boolean, + getUISetting: coreStart.uiSettings.get.bind(coreStart.uiSettings), + setBreadcrumbs: coreStart.chrome.setBreadcrumbs, + setRecentlyAccessed: coreStart.chrome.recentlyAccessed.add, + + // TODO: these should go away. We want thin accessors, not entire objects. + // Entire objects are hard to mock, and hide our dependency on the external service. + getSavedObjects: () => coreStart.savedObjects, + getSavedObjectsClient: () => coreStart.savedObjects.client, + getUISettings: () => coreStart.uiSettings, + }; }; diff --git a/x-pack/plugins/canvas/public/services/stubs/embeddables.ts b/x-pack/plugins/canvas/public/services/stubs/embeddables.ts new file mode 100644 index 000000000000..48100da462dd --- /dev/null +++ b/x-pack/plugins/canvas/public/services/stubs/embeddables.ts @@ -0,0 +1,12 @@ +/* + * 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 { EmbeddablesService } from '../embeddables'; + +const noop = (..._args: any[]): any => {}; + +export const embeddablesService: EmbeddablesService = { + getEmbeddableFactories: noop, +}; diff --git a/x-pack/plugins/canvas/public/services/stubs/expressions.ts b/x-pack/plugins/canvas/public/services/stubs/expressions.ts new file mode 100644 index 000000000000..26a90670106d --- /dev/null +++ b/x-pack/plugins/canvas/public/services/stubs/expressions.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ExpressionsService } from '../'; +import { + plugin, + ExpressionRenderDefinition, +} from '../../../../../../src/plugins/expressions/public'; +import { functions as functionDefinitions } from '../../../canvas_plugin_src/functions/common'; +// @ts-expect-error untyped local +import { renderFunctions } from '../../../canvas_plugin_src/renderers/core'; + +const placeholder = {} as any; +const expressionsPlugin = plugin(placeholder); +const setup = expressionsPlugin.setup(placeholder, { + inspector: {}, +} as any); + +export const expressionsService: ExpressionsService = setup.fork(); + +functionDefinitions.forEach((fn) => expressionsService.registerFunction(fn)); +renderFunctions.forEach((fn: ExpressionRenderDefinition) => + expressionsService.registerRenderer(fn) +); diff --git a/x-pack/plugins/canvas/public/services/stubs/index.ts b/x-pack/plugins/canvas/public/services/stubs/index.ts new file mode 100644 index 000000000000..b4e440f204cc --- /dev/null +++ b/x-pack/plugins/canvas/public/services/stubs/index.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CanvasServices, services } from '../'; +import { embeddablesService } from './embeddables'; +import { expressionsService } from './expressions'; +import { navLinkService } from './nav_link'; +import { notifyService } from './notify'; +import { platformService } from './platform'; + +export const stubs: CanvasServices = { + embeddables: embeddablesService, + expressions: expressionsService, + navLink: navLinkService, + notify: notifyService, + platform: platformService, +}; + +export const startServices = async (providedServices: Partial = {}) => { + Object.entries(services).forEach(([key, provider]) => { + // @ts-expect-error Object.entries isn't strongly typed + const stub = providedServices[key] || stubs[key]; + provider.setService(stub); + }); +}; diff --git a/x-pack/plugins/canvas/public/services/stubs/nav_link.ts b/x-pack/plugins/canvas/public/services/stubs/nav_link.ts new file mode 100644 index 000000000000..3b40eeb3e84f --- /dev/null +++ b/x-pack/plugins/canvas/public/services/stubs/nav_link.ts @@ -0,0 +1,13 @@ +/* + * 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 { NavLinkService } from '../nav_link'; + +const noop = (..._args: any[]): any => {}; + +export const navLinkService: NavLinkService = { + updatePath: noop, +}; diff --git a/x-pack/plugins/canvas/public/services/stubs/notify.ts b/x-pack/plugins/canvas/public/services/stubs/notify.ts new file mode 100644 index 000000000000..38eac2a5813e --- /dev/null +++ b/x-pack/plugins/canvas/public/services/stubs/notify.ts @@ -0,0 +1,16 @@ +/* + * 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 { NotifyService } from '../notify'; + +const noop = (..._args: any[]): any => {}; + +export const notifyService: NotifyService = { + error: noop, + info: noop, + success: noop, + warning: noop, +}; diff --git a/x-pack/plugins/canvas/public/services/stubs/platform.ts b/x-pack/plugins/canvas/public/services/stubs/platform.ts new file mode 100644 index 000000000000..9ada57957350 --- /dev/null +++ b/x-pack/plugins/canvas/public/services/stubs/platform.ts @@ -0,0 +1,23 @@ +/* + * 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 { PlatformService } from '../platform'; + +const noop = (..._args: any[]): any => {}; + +export const platformService: PlatformService = { + getBasePath: () => '/base/path', + getBasePathInterface: noop, + getDocLinkVersion: () => 'dockLinkVersion', + getElasticWebsiteUrl: () => 'https://elastic.co', + getHasWriteAccess: () => true, + getUISetting: noop, + setBreadcrumbs: noop, + setRecentlyAccessed: noop, + getSavedObjects: noop, + getSavedObjectsClient: noop, + getUISettings: noop, +}; diff --git a/x-pack/plugins/canvas/public/state/initial_state.js b/x-pack/plugins/canvas/public/state/initial_state.js index 13021893e72e..f9b02d33d611 100644 --- a/x-pack/plugins/canvas/public/state/initial_state.js +++ b/x-pack/plugins/canvas/public/state/initial_state.js @@ -9,11 +9,13 @@ import { platformService } from '../services'; import { getDefaultWorkpad } from './defaults'; export const getInitialState = (path) => { + const { getHasWriteAccess } = platformService.getService(); + const state = { app: {}, // Kibana stuff in here assets: {}, // assets end up here transient: { - canUserWrite: platformService.getService().coreStart.application.capabilities.canvas.save, + canUserWrite: getHasWriteAccess(), zoomScale: 1, elementStats: { total: 0, diff --git a/x-pack/plugins/canvas/public/state/reducers/workpad.js b/x-pack/plugins/canvas/public/state/reducers/workpad.js index 9a0c30bdf133..fffcb69c451e 100644 --- a/x-pack/plugins/canvas/public/state/reducers/workpad.js +++ b/x-pack/plugins/canvas/public/state/reducers/workpad.js @@ -25,11 +25,7 @@ export const workpadReducer = handleActions( [setWorkpad]: (workpadState, { payload }) => { platformService .getService() - .coreStart.chrome.recentlyAccessed.add( - `${APP_ROUTE_WORKPAD}/${payload.id}`, - payload.name, - payload.id - ); + .setRecentlyAccessed(`${APP_ROUTE_WORKPAD}/${payload.id}`, payload.name, payload.id); return payload; }, diff --git a/x-pack/plugins/canvas/storybook/config.js b/x-pack/plugins/canvas/storybook/config.js index f349f9b7ccf9..dc16d6c46084 100644 --- a/x-pack/plugins/canvas/storybook/config.js +++ b/x-pack/plugins/canvas/storybook/config.js @@ -8,6 +8,7 @@ import { configure, addDecorator, addParameters } from '@storybook/react'; import { withInfo } from '@storybook/addon-info'; import { create } from '@storybook/theming'; +import { startServices } from '../public/services/stubs'; import { addDecorators } from './decorators'; // If we're running Storyshots, be sure to register the require context hook. @@ -32,6 +33,7 @@ if (process.env.NODE_ENV === 'test') { } addDecorators(); +startServices(); function loadStories() { require('./dll_contexts'); From e359c9ae38a0a074c52c4b806bef9c0eef9296cf Mon Sep 17 00:00:00 2001 From: Rashmi Kulkarni Date: Thu, 23 Jul 2020 14:16:56 -0700 Subject: [PATCH 120/142] changes for upgrade assistant functional test to incorporate test user (#70071) * changes for upgrade assistant functional test to incorporate test user * changes to toggle on/off * upgrade_assistant role * upgrade assistant * more debug statements to check on cloud * commented the sleeps to check toggle button * reduced the sleep to 2 seconds to test on cloud Co-authored-by: Elastic Machine --- .../upgrade_assistant/upgrade_assistant.ts | 20 +++++++++++++++++-- x-pack/test/functional/config.js | 14 +++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/x-pack/test/functional/apps/upgrade_assistant/upgrade_assistant.ts b/x-pack/test/functional/apps/upgrade_assistant/upgrade_assistant.ts index 85ad98727cea..57b8fb23613b 100644 --- a/x-pack/test/functional/apps/upgrade_assistant/upgrade_assistant.ts +++ b/x-pack/test/functional/apps/upgrade_assistant/upgrade_assistant.ts @@ -11,14 +11,22 @@ export default function upgradeAssistantFunctionalTests({ getPageObjects, }: FtrProviderContext) { const esArchiver = getService('esArchiver'); - const PageObjects = getPageObjects(['upgradeAssistant']); + const PageObjects = getPageObjects(['upgradeAssistant', 'common']); + const security = getService('security'); + const log = getService('log'); describe('Upgrade Checkup', function () { this.tags('includeFirefox'); - before(async () => await esArchiver.load('empty_kibana')); + + before(async () => { + await esArchiver.load('empty_kibana'); + await security.testUser.setRoles(['global_upgrade_assistant_role']); + }); + after(async () => { await PageObjects.upgradeAssistant.expectTelemetryHasFinish(); await esArchiver.unload('empty_kibana'); + await security.testUser.restoreDefaults(); }); it('allows user to navigate to upgrade checkup', async () => { @@ -28,9 +36,17 @@ export default function upgradeAssistantFunctionalTests({ it('allows user to toggle deprecation logging', async () => { await PageObjects.upgradeAssistant.navigateToPage(); + log.debug('expect initial state to be ON'); await PageObjects.upgradeAssistant.expectDeprecationLoggingLabel('On'); + log.debug('Now toggle to off'); await PageObjects.upgradeAssistant.toggleDeprecationLogging(); + await PageObjects.common.sleep(2000); + log.debug('expect state to be OFF after toggle'); await PageObjects.upgradeAssistant.expectDeprecationLoggingLabel('Off'); + await PageObjects.upgradeAssistant.toggleDeprecationLogging(); + await PageObjects.common.sleep(2000); + log.debug('expect state to be ON after toggle'); + await PageObjects.upgradeAssistant.expectDeprecationLoggingLabel('On'); }); it('allows user to open cluster tab', async () => { diff --git a/x-pack/test/functional/config.js b/x-pack/test/functional/config.js index 5c13e430ae2c..fdd694e73394 100644 --- a/x-pack/test/functional/config.js +++ b/x-pack/test/functional/config.js @@ -284,6 +284,20 @@ export default async function ({ readConfigFile }) { ], }, + global_upgrade_assistant_role: { + elasticsearch: { + cluster: ['manage'], + }, + kibana: [ + { + feature: { + discover: ['read'], + }, + spaces: ['*'], + }, + ], + }, + global_ccr_role: { elasticsearch: { cluster: ['manage', 'manage_ccr'], From 849bbfdcd51ccb1134277f7515ab7dfb7ff541d4 Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Thu, 23 Jul 2020 15:22:54 -0600 Subject: [PATCH 121/142] [maps][docs] add trouble shooting for index not listed (#73066) * [maps][docs] add troubeshooting for index not listed * Update docs/maps/trouble-shooting.asciidoc Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Update docs/maps/trouble-shooting.asciidoc Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Update docs/maps/trouble-shooting.asciidoc Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Update docs/maps/trouble-shooting.asciidoc Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Update docs/maps/trouble-shooting.asciidoc Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> --- docs/maps/trouble-shooting.asciidoc | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/maps/trouble-shooting.asciidoc b/docs/maps/trouble-shooting.asciidoc index cfc47cf6f0e4..1c53fbd55ea4 100644 --- a/docs/maps/trouble-shooting.asciidoc +++ b/docs/maps/trouble-shooting.asciidoc @@ -20,6 +20,20 @@ image::maps/images/inspector.png[] [float] === Solutions to common problems +[float] +==== Index not listed when adding layer + +* Verify your geospatial data is correctly mapped as {ref}/geo-point.html[geo_point] or {ref}/geo-shape.html[geo_shape]. + ** Run `GET myIndexPatternTitle/_field_caps?fields=myGeoFieldName` in <>, replacing `myIndexPatternTitle` and `myGeoFieldName` with your index pattern title and geospatial field name. + ** Ensure response specifies `type` as `geo_point` or `geo_shape`. +* Verify your geospatial data is correctly mapped in your <>. + ** Open your index pattern in <>. + ** Ensure your geospatial field type is `geo_point` or `geo_shape`. + ** Ensure your geospatial field is searchable and aggregatable. + ** If your geospatial field type does not match your Elasticsearch mapping, click the *Refresh* button to refresh the field list from Elasticsearch. +* Index patterns with thousands of fields can exceed the default maximum payload size. +Increase <> for large index patterns. + [float] ==== Features are not displayed From bb646660512b51c0570491150ed05b649b74ecb6 Mon Sep 17 00:00:00 2001 From: Robert Austin Date: Thu, 23 Jul 2020 17:45:07 -0400 Subject: [PATCH 122/142] [Resolver] Handle duplicate process events (#73123) In the case that the process creation (or already running) events for a node have been duplicated in ES, this uses the last one (in response order.) --- .../resolver/store/data/selectors.test.ts | 26 +++++++ .../public/resolver/store/data/selectors.ts | 10 ++- .../resolver/store/mocks/resolver_tree.ts | 78 +++++++++++++++++++ 3 files changed, 113 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.test.ts b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.test.ts index 683f8f1a5f84..9e1c396723a2 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.test.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.test.ts @@ -12,6 +12,7 @@ import { createStore } from 'redux'; import { mockTreeWithNoAncestorsAnd2Children, mockTreeWith2AncestorsAndNoChildren, + mockTreeWith1AncestorAnd2ChildrenAndAllNodesHave2GraphableEvents, } from '../mocks/resolver_tree'; import { uniquePidForProcess } from '../../models/process_event'; import { EndpointEvent } from '../../../../common/endpoint/types'; @@ -353,4 +354,29 @@ describe('data state', () => { } }); }); + describe('with a tree with 1 ancestor and 2 children, where all nodes have 2 graphable events', () => { + const ancestorID = 'b'; + const originID = 'c'; + const firstChildID = 'd'; + const secondChildID = 'e'; + beforeEach(() => { + const tree = mockTreeWith1AncestorAnd2ChildrenAndAllNodesHave2GraphableEvents({ + ancestorID, + originID, + firstChildID, + secondChildID, + }); + actions.push({ + type: 'serverReturnedResolverData', + payload: { + result: tree, + // this value doesn't matter + databaseDocumentID: '', + }, + }); + }); + it('should have 4 graphable processes', () => { + expect(selectors.graphableProcesses(state()).length).toBe(4); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts index 40138d3f2fd3..1d65b406306a 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts @@ -109,8 +109,16 @@ export const terminatedProcesses = createSelector(resolverTreeResponse, function * Process events that will be graphed. */ export const graphableProcesses = createSelector(resolverTreeResponse, function (tree?) { + // Keep track of the last process event (in array order) for each entity ID + const events: Map = new Map(); if (tree) { - return resolverTreeModel.lifecycleEvents(tree).filter(isGraphableProcess); + for (const event of resolverTreeModel.lifecycleEvents(tree)) { + if (isGraphableProcess(event)) { + const entityID = uniquePidForProcess(event); + events.set(entityID, event); + } + } + return [...events.values()]; } else { return []; } diff --git a/x-pack/plugins/security_solution/public/resolver/store/mocks/resolver_tree.ts b/x-pack/plugins/security_solution/public/resolver/store/mocks/resolver_tree.ts index 862cf47f7394..2860eec5a6ab 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/mocks/resolver_tree.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/mocks/resolver_tree.ts @@ -85,3 +85,81 @@ export function mockTreeWithNoAncestorsAnd2Children({ lifecycle: [origin], } as unknown) as ResolverTree; } + +/** + * Creates a mock tree w/ 2 'graphable' events per node. This simulates the scenario where data has been duplicated in the response from the server. + */ +export function mockTreeWith1AncestorAnd2ChildrenAndAllNodesHave2GraphableEvents({ + ancestorID, + originID, + firstChildID, + secondChildID, +}: { + ancestorID: string; + originID: string; + firstChildID: string; + secondChildID: string; +}): ResolverTree { + const ancestor: ResolverEvent = mockEndpointEvent({ + entityID: ancestorID, + name: ancestorID, + timestamp: 1, + parentEntityId: undefined, + }); + const ancestorClone: ResolverEvent = mockEndpointEvent({ + entityID: ancestorID, + name: ancestorID, + timestamp: 1, + parentEntityId: undefined, + }); + const origin: ResolverEvent = mockEndpointEvent({ + entityID: originID, + name: originID, + parentEntityId: ancestorID, + timestamp: 0, + }); + const originClone: ResolverEvent = mockEndpointEvent({ + entityID: originID, + name: originID, + parentEntityId: ancestorID, + timestamp: 0, + }); + const firstChild: ResolverEvent = mockEndpointEvent({ + entityID: firstChildID, + name: firstChildID, + parentEntityId: originID, + timestamp: 1, + }); + const firstChildClone: ResolverEvent = mockEndpointEvent({ + entityID: firstChildID, + name: firstChildID, + parentEntityId: originID, + timestamp: 1, + }); + const secondChild: ResolverEvent = mockEndpointEvent({ + entityID: secondChildID, + name: secondChildID, + parentEntityId: originID, + timestamp: 2, + }); + const secondChildClone: ResolverEvent = mockEndpointEvent({ + entityID: secondChildID, + name: secondChildID, + parentEntityId: originID, + timestamp: 2, + }); + + return ({ + entityID: originID, + children: { + childNodes: [ + { lifecycle: [firstChild, firstChildClone] }, + { lifecycle: [secondChild, secondChildClone] }, + ], + }, + ancestry: { + ancestors: [{ lifecycle: [ancestor, ancestorClone] }], + }, + lifecycle: [origin, originClone], + } as unknown) as ResolverTree; +} From 19127c287efd44718c9fe48459c5db7489c00506 Mon Sep 17 00:00:00 2001 From: Kevin Qualters <56408403+kqualters-elastic@users.noreply.github.com> Date: Thu, 23 Jul 2020 18:15:38 -0400 Subject: [PATCH 123/142] [Security Solution][Endpoint] Clean up resolver query params on component dismount (#72902) --- .../security_solution/public/resolver/view/map.tsx | 6 ++++++ .../public/resolver/view/use_resolver_query_params.ts | 11 +++++++++++ 2 files changed, 17 insertions(+) diff --git a/x-pack/plugins/security_solution/public/resolver/view/map.tsx b/x-pack/plugins/security_solution/public/resolver/view/map.tsx index 69ff9c8e2351..30aa4b63a138 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/map.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/map.tsx @@ -10,6 +10,7 @@ import React, { useContext } from 'react'; import { useSelector } from 'react-redux'; +import { useEffectOnce } from 'react-use'; import { EuiLoadingSpinner } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import * as selectors from '../store/selectors'; @@ -19,6 +20,7 @@ import { ProcessEventDot } from './process_event_dot'; import { useCamera } from './use_camera'; import { SymbolDefinitions, useResolverTheme } from './assets'; import { useStateSyncingActions } from './use_state_syncing_actions'; +import { useResolverQueryParams } from './use_resolver_query_params'; import { StyledMapContainer, StyledPanel, GraphContainer } from './styles'; import { entityId } from '../../../common/endpoint/models/event'; import { SideEffectContext } from './side_effect_context'; @@ -66,6 +68,10 @@ export const ResolverMap = React.memo(function ({ const hasError = useSelector(selectors.hasError); const activeDescendantId = useSelector(selectors.ariaActiveDescendant); const { colorMap } = useResolverTheme(); + const { cleanUpQueryParams } = useResolverQueryParams(); + useEffectOnce(() => { + return () => cleanUpQueryParams(); + }); return ( diff --git a/x-pack/plugins/security_solution/public/resolver/view/use_resolver_query_params.ts b/x-pack/plugins/security_solution/public/resolver/view/use_resolver_query_params.ts index 3c342ae575aa..84d954de6ef2 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/use_resolver_query_params.ts +++ b/x-pack/plugins/security_solution/public/resolver/view/use_resolver_query_params.ts @@ -63,8 +63,19 @@ export function useResolverQueryParams() { }; }, [urlSearch, uniqueCrumbIdKey, uniqueCrumbEventKey]); + const cleanUpQueryParams = () => { + const crumbsToPass = { + ...querystring.parse(urlSearch.slice(1)), + }; + delete crumbsToPass[uniqueCrumbIdKey]; + delete crumbsToPass[uniqueCrumbEventKey]; + const relativeURL = { search: querystring.stringify(crumbsToPass) }; + history.replace(relativeURL); + }; + return { pushToQueryParams, queryParams, + cleanUpQueryParams, }; } From 5a1972b8341c65f50a1b07109e90867248d4475d Mon Sep 17 00:00:00 2001 From: Matthew Kime Date: Thu, 23 Jul 2020 17:46:26 -0500 Subject: [PATCH 124/142] Index patterns on alias - reenable functional tests (#71802) * reenable test * nav to management --- test/functional/apps/management/_handle_alias.js | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/test/functional/apps/management/_handle_alias.js b/test/functional/apps/management/_handle_alias.js index 902b49eacdc0..67a4445d17aa 100644 --- a/test/functional/apps/management/_handle_alias.js +++ b/test/functional/apps/management/_handle_alias.js @@ -26,8 +26,7 @@ export default function ({ getService, getPageObjects }) { const security = getService('security'); const PageObjects = getPageObjects(['common', 'home', 'settings', 'discover', 'timePicker']); - // FLAKY: https://github.com/elastic/kibana/issues/59717 - describe.skip('Index patterns on aliases', function () { + describe('Index patterns on aliases', function () { before(async function () { await security.testUser.setRoles(['kibana_admin', 'test_alias_reader']); await esArchiver.loadIfNeeded('alias'); @@ -50,9 +49,8 @@ export default function ({ getService, getPageObjects }) { }); it('should be able to create index pattern without time field', async function () { - await PageObjects.settings.createIndexPattern('alias1', null); - const patternName = await PageObjects.settings.getIndexPageHeading(); - expect(patternName).to.be('alias1*'); + await PageObjects.settings.navigateTo(); + await PageObjects.settings.createIndexPattern('alias1*', null); }); it('should be able to discover and verify no of hits for alias1', async function () { @@ -64,9 +62,8 @@ export default function ({ getService, getPageObjects }) { }); it('should be able to create index pattern with timefield', async function () { - await PageObjects.settings.createIndexPattern('alias2', 'date'); - const patternName = await PageObjects.settings.getIndexPageHeading(); - expect(patternName).to.be('alias2*'); + await PageObjects.settings.navigateTo(); + await PageObjects.settings.createIndexPattern('alias2*', 'date'); }); it('should be able to discover and verify no of hits for alias2', async function () { From f5a81deadbe4b2431cf423be8c8af85a3b3caf2d Mon Sep 17 00:00:00 2001 From: Corey Robertson Date: Thu, 23 Jul 2020 19:04:23 -0400 Subject: [PATCH 125/142] Exclude variables from rendered workpad (#72970) --- x-pack/plugins/canvas/public/state/selectors/workpad.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/canvas/public/state/selectors/workpad.ts b/x-pack/plugins/canvas/public/state/selectors/workpad.ts index 1d7ea05daaa6..a677bcaf29e6 100644 --- a/x-pack/plugins/canvas/public/state/selectors/workpad.ts +++ b/x-pack/plugins/canvas/public/state/selectors/workpad.ts @@ -497,7 +497,7 @@ export function getRenderedWorkpad(state: State) { const workpad = getWorkpad(state); // eslint-disable-next-line no-unused-vars - const { pages, ...rest } = workpad; + const { pages, variables, ...rest } = workpad; return { pages: renderedPages, From aec18923daf41e32168430a1fb788a058dbb1683 Mon Sep 17 00:00:00 2001 From: Steph Milovic Date: Thu, 23 Jul 2020 17:11:04 -0600 Subject: [PATCH 126/142] update discuss link from siem to security (#72886) Co-authored-by: Elastic Machine --- .../public/common/components/help_menu/index.tsx | 2 +- .../public/common/components/news_feed/helpers.test.ts | 4 ++-- x-pack/plugins/security_solution/public/common/mock/news.ts | 2 +- .../plugins/security_solution/public/common/mock/raw_news.ts | 2 +- .../security_solution/public/overview/pages/summary.tsx | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/x-pack/plugins/security_solution/public/common/components/help_menu/index.tsx b/x-pack/plugins/security_solution/public/common/components/help_menu/index.tsx index f4477740f7b5..1eaa16fd058a 100644 --- a/x-pack/plugins/security_solution/public/common/components/help_menu/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/help_menu/index.tsx @@ -39,7 +39,7 @@ export const HelpMenu = React.memo(() => { }, { linkType: 'discuss', - href: 'https://discuss.elastic.co/c/siem', + href: 'https://discuss.elastic.co/c/security', target: '_blank', rel: 'noopener', }, diff --git a/x-pack/plugins/security_solution/public/common/components/news_feed/helpers.test.ts b/x-pack/plugins/security_solution/public/common/components/news_feed/helpers.test.ts index cdd04b50a6d5..35a59f4d18e8 100644 --- a/x-pack/plugins/security_solution/public/common/components/news_feed/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/common/components/news_feed/helpers.test.ts @@ -144,7 +144,7 @@ describe('helpers', () => { hash: '5a35c984a9cdc1c6a25913f3d0b99b1aefc7257bc3b936c39db9fa0435edeed0', imageUrl: 'https://aws1.discourse-cdn.com/elastic/original/3X/f/8/f8c3d0b9971cfcd0be349d973aa5799f71d280cc.png?blade=securitysolutionfeed', - linkUrl: 'https://discuss.elastic.co/c/siem?blade=securitysolutionfeed', + linkUrl: 'https://discuss.elastic.co/c/security?blade=securitysolutionfeed', publishOn: expect.any(Date), title: 'Got SIEM Questions?', }, @@ -284,7 +284,7 @@ describe('helpers', () => { }, link_text: null, link_url: { - en: 'https://discuss.elastic.co/c/siem?blade=securitysolutionfeed', + en: 'https://discuss.elastic.co/c/security?blade=securitysolutionfeed', ja: translatedLinkUrl, }, languages: null, diff --git a/x-pack/plugins/security_solution/public/common/mock/news.ts b/x-pack/plugins/security_solution/public/common/mock/news.ts index 3e421ce19ae9..51449347e649 100644 --- a/x-pack/plugins/security_solution/public/common/mock/news.ts +++ b/x-pack/plugins/security_solution/public/common/mock/news.ts @@ -16,7 +16,7 @@ export const rawNewsApiResponse: RawNewsApiResponse = { "There's an awesome community of Elastic SIEM users out there. Join the discussion about configuring, learning, and using the Elastic SIEM app, and detecting threats!", }, link_text: null, - link_url: { en: 'https://discuss.elastic.co/c/siem?blade=securitysolutionfeed' }, + link_url: { en: 'https://discuss.elastic.co/c/security?blade=securitysolutionfeed' }, languages: null, badge: { en: '7.6' }, image_url: { diff --git a/x-pack/plugins/security_solution/public/common/mock/raw_news.ts b/x-pack/plugins/security_solution/public/common/mock/raw_news.ts index 85bef15a41b2..9cd06ed10795 100644 --- a/x-pack/plugins/security_solution/public/common/mock/raw_news.ts +++ b/x-pack/plugins/security_solution/public/common/mock/raw_news.ts @@ -17,7 +17,7 @@ export const rawNewsJSON = ` }, "link_text":null, "link_url":{ - "en":"https://discuss.elastic.co/c/siem?blade=securitysolutionfeed" + "en":"https://discuss.elastic.co/c/security?blade=securitysolutionfeed" }, "languages":null, "badge":{ diff --git a/x-pack/plugins/security_solution/public/overview/pages/summary.tsx b/x-pack/plugins/security_solution/public/overview/pages/summary.tsx index 0f20e8bea9dc..d8260858aa24 100644 --- a/x-pack/plugins/security_solution/public/overview/pages/summary.tsx +++ b/x-pack/plugins/security_solution/public/overview/pages/summary.tsx @@ -71,7 +71,7 @@ export const Summary = React.memo(() => { defaultMessage="If you have input or suggestions regarding your experience with Elastic SIEM, please feel free to {feedback}." values={{ feedback: ( - + Date: Thu, 23 Jul 2020 17:20:24 -0600 Subject: [PATCH 127/142] [maps] fix data driven style properties not working when cloned layer contains joins (#73124) * [maps] fix data driven style properties not working when cloned layer contains joins * tslint * handle case where metrics is not provided * tslint --- .../maps/common/descriptor_types/sources.ts | 2 +- .../maps/public/classes/layers/layer.test.ts | 128 ++++++++++++++++++ .../maps/public/classes/layers/layer.tsx | 49 ++++++- 3 files changed, 175 insertions(+), 4 deletions(-) create mode 100644 x-pack/plugins/maps/public/classes/layers/layer.test.ts diff --git a/x-pack/plugins/maps/common/descriptor_types/sources.ts b/x-pack/plugins/maps/common/descriptor_types/sources.ts index 7eda37bf5335..6e8884d942e1 100644 --- a/x-pack/plugins/maps/common/descriptor_types/sources.ts +++ b/x-pack/plugins/maps/common/descriptor_types/sources.ts @@ -168,6 +168,7 @@ export type LayerDescriptor = { __trackedLayerDescriptor?: LayerDescriptor; alpha?: number; id: string; + joins?: JoinDescriptor[]; label?: string | null; areLabelsOnTop?: boolean; minZoom?: number; @@ -180,7 +181,6 @@ export type LayerDescriptor = { }; export type VectorLayerDescriptor = LayerDescriptor & { - joins?: JoinDescriptor[]; style?: VectorStyleDescriptor; }; diff --git a/x-pack/plugins/maps/public/classes/layers/layer.test.ts b/x-pack/plugins/maps/public/classes/layers/layer.test.ts new file mode 100644 index 000000000000..f25ecd710645 --- /dev/null +++ b/x-pack/plugins/maps/public/classes/layers/layer.test.ts @@ -0,0 +1,128 @@ +/* + * 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. + */ +/* eslint-disable max-classes-per-file */ + +import { AbstractLayer } from './layer'; +import { ISource } from '../sources/source'; +import { IStyle } from '../styles/style'; +import { AGG_TYPE, FIELD_ORIGIN, LAYER_STYLE_TYPE, VECTOR_STYLES } from '../../../common/constants'; +import { ESTermSourceDescriptor, VectorStyleDescriptor } from '../../../common/descriptor_types'; +import { getDefaultDynamicProperties } from '../styles/vector/vector_style_defaults'; + +jest.mock('uuid/v4', () => { + return function () { + return '12345'; + }; +}); + +class MockLayer extends AbstractLayer {} + +class MockSource { + cloneDescriptor() { + return {}; + } + + getDisplayName() { + return 'mySource'; + } +} + +class MockStyle {} + +describe('cloneDescriptor', () => { + describe('with joins', () => { + const styleDescriptor = { + type: LAYER_STYLE_TYPE.VECTOR, + properties: { + ...getDefaultDynamicProperties(), + }, + } as VectorStyleDescriptor; + // @ts-expect-error + styleDescriptor.properties[VECTOR_STYLES.FILL_COLOR].options.field = { + name: '__kbnjoin__count__557d0f15', + origin: FIELD_ORIGIN.JOIN, + }; + // @ts-expect-error + styleDescriptor.properties[VECTOR_STYLES.LINE_COLOR].options.field = { + name: 'bytes', + origin: FIELD_ORIGIN.SOURCE, + }; + // @ts-expect-error + styleDescriptor.properties[VECTOR_STYLES.LABEL_BORDER_COLOR].options.field = { + name: '__kbnjoin__count__6666666666', + origin: FIELD_ORIGIN.JOIN, + }; + + test('Should update data driven styling properties using join fields', async () => { + const layerDescriptor = AbstractLayer.createDescriptor({ + style: styleDescriptor, + joins: [ + { + leftField: 'iso2', + right: { + id: '557d0f15', + indexPatternId: 'myIndexPattern', + indexPatternTitle: 'logs-*', + metrics: [{ type: AGG_TYPE.COUNT }], + term: 'myTermField', + type: 'joinSource', + }, + }, + ], + }); + const layer = new MockLayer({ + layerDescriptor, + source: (new MockSource() as unknown) as ISource, + style: (new MockStyle() as unknown) as IStyle, + }); + const clonedDescriptor = await layer.cloneDescriptor(); + const clonedStyleProps = (clonedDescriptor.style as VectorStyleDescriptor).properties; + // Should update style field belonging to join + // @ts-expect-error + expect(clonedStyleProps[VECTOR_STYLES.FILL_COLOR].options.field.name).toEqual( + '__kbnjoin__count__12345' + ); + // Should not update style field belonging to source + // @ts-expect-error + expect(clonedStyleProps[VECTOR_STYLES.LINE_COLOR].options.field.name).toEqual('bytes'); + // Should not update style feild belonging to different join + // @ts-expect-error + expect(clonedStyleProps[VECTOR_STYLES.LABEL_BORDER_COLOR].options.field.name).toEqual( + '__kbnjoin__count__6666666666' + ); + }); + + test('Should update data driven styling properties using join fields when metrics are not provided', async () => { + const layerDescriptor = AbstractLayer.createDescriptor({ + style: styleDescriptor, + joins: [ + { + leftField: 'iso2', + right: ({ + id: '557d0f15', + indexPatternId: 'myIndexPattern', + indexPatternTitle: 'logs-*', + term: 'myTermField', + type: 'joinSource', + } as unknown) as ESTermSourceDescriptor, + }, + ], + }); + const layer = new MockLayer({ + layerDescriptor, + source: (new MockSource() as unknown) as ISource, + style: (new MockStyle() as unknown) as IStyle, + }); + const clonedDescriptor = await layer.cloneDescriptor(); + const clonedStyleProps = (clonedDescriptor.style as VectorStyleDescriptor).properties; + // Should update style field belonging to join + // @ts-expect-error + expect(clonedStyleProps[VECTOR_STYLES.FILL_COLOR].options.field.name).toEqual( + '__kbnjoin__count__12345' + ); + }); + }); +}); diff --git a/x-pack/plugins/maps/public/classes/layers/layer.tsx b/x-pack/plugins/maps/public/classes/layers/layer.tsx index d8def155a918..424100c5a7e3 100644 --- a/x-pack/plugins/maps/public/classes/layers/layer.tsx +++ b/x-pack/plugins/maps/public/classes/layers/layer.tsx @@ -14,16 +14,26 @@ import { i18n } from '@kbn/i18n'; import { FeatureCollection } from 'geojson'; import { DataRequest } from '../util/data_request'; import { + AGG_TYPE, + FIELD_ORIGIN, MAX_ZOOM, MB_SOURCE_ID_LAYER_ID_PREFIX_DELIMITER, MIN_ZOOM, SOURCE_DATA_REQUEST_ID, + STYLE_TYPE, } from '../../../common/constants'; import { copyPersistentState } from '../../reducers/util'; -import { LayerDescriptor, MapExtent, StyleDescriptor } from '../../../common/descriptor_types'; +import { + AggDescriptor, + JoinDescriptor, + LayerDescriptor, + MapExtent, + StyleDescriptor, +} from '../../../common/descriptor_types'; import { Attribution, ImmutableSourceProperty, ISource, SourceEditorArgs } from '../sources/source'; import { DataRequestContext } from '../../actions'; import { IStyle } from '../styles/style'; +import { getJoinAggKey } from '../../../common/get_agg_key'; export interface ILayer { getBounds(dataRequestContext: DataRequestContext): Promise; @@ -157,10 +167,43 @@ export class AbstractLayer implements ILayer { clonedDescriptor.sourceDescriptor = this.getSource().cloneDescriptor(); if (clonedDescriptor.joins) { - // @ts-expect-error - clonedDescriptor.joins.forEach((joinDescriptor) => { + clonedDescriptor.joins.forEach((joinDescriptor: JoinDescriptor) => { + const originalJoinId = joinDescriptor.right.id!; + // right.id is uuid used to track requests in inspector joinDescriptor.right.id = uuid(); + + // Update all data driven styling properties using join fields + if (clonedDescriptor.style && 'properties' in clonedDescriptor.style) { + const metrics = + joinDescriptor.right.metrics && joinDescriptor.right.metrics.length + ? joinDescriptor.right.metrics + : [{ type: AGG_TYPE.COUNT }]; + metrics.forEach((metricsDescriptor: AggDescriptor) => { + const originalJoinKey = getJoinAggKey({ + aggType: metricsDescriptor.type, + aggFieldName: metricsDescriptor.field ? metricsDescriptor.field : '', + rightSourceId: originalJoinId, + }); + const newJoinKey = getJoinAggKey({ + aggType: metricsDescriptor.type, + aggFieldName: metricsDescriptor.field ? metricsDescriptor.field : '', + rightSourceId: joinDescriptor.right.id!, + }); + + Object.keys(clonedDescriptor.style.properties).forEach((key) => { + const styleProp = clonedDescriptor.style.properties[key]; + if ( + styleProp.type === STYLE_TYPE.DYNAMIC && + styleProp.options.field && + styleProp.options.field.origin === FIELD_ORIGIN.JOIN && + styleProp.options.field.name === originalJoinKey + ) { + styleProp.options.field.name = newJoinKey; + } + }); + }); + } }); } return clonedDescriptor; From 8021616e4160cbcc8c5d605fd731c3138deefa3f Mon Sep 17 00:00:00 2001 From: Jonathan Budzenski Date: Thu, 23 Jul 2020 18:41:32 -0500 Subject: [PATCH 128/142] skip ingest pipeline api tests JSON formatting appears to have changed modestly. Tracking at #73170. --- .../apis/management/ingest_pipelines/ingest_pipelines.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/test/api_integration/apis/management/ingest_pipelines/ingest_pipelines.ts b/x-pack/test/api_integration/apis/management/ingest_pipelines/ingest_pipelines.ts index a48460d7a3b2..6a827298521d 100644 --- a/x-pack/test/api_integration/apis/management/ingest_pipelines/ingest_pipelines.ts +++ b/x-pack/test/api_integration/apis/management/ingest_pipelines/ingest_pipelines.ts @@ -16,7 +16,7 @@ export default function ({ getService }: FtrProviderContext) { const { createPipeline, deletePipeline } = registerEsHelpers(getService); - describe('Pipelines', function () { + describe.skip('Pipelines', function () { describe('Create', () => { const PIPELINE_ID = 'test_create_pipeline'; const REQUIRED_FIELDS_PIPELINE_ID = 'test_create_required_fields_pipeline'; From 5f4b1a36896d6cf8fad2dd0e4487ffcf6aea7a43 Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Thu, 23 Jul 2020 18:30:42 -0600 Subject: [PATCH 129/142] [Maps] fix tile layer attibution text and attribution link validation errors (#73160) * [Maps] fix tile layer attibution text and attribution link validation errors * clean up jest test * tslint * one more tslint --- .../xyz_tms_editor.test.tsx.snap | 237 ++++++++++++++++++ .../sources/xyz_tms_source/layer_wizard.tsx | 7 +- .../xyz_tms_source/xyz_tms_editor.test.tsx | 37 +++ .../sources/xyz_tms_source/xyz_tms_editor.tsx | 96 +++---- 4 files changed, 318 insertions(+), 59 deletions(-) create mode 100644 x-pack/plugins/maps/public/classes/sources/xyz_tms_source/__snapshots__/xyz_tms_editor.test.tsx.snap create mode 100644 x-pack/plugins/maps/public/classes/sources/xyz_tms_source/xyz_tms_editor.test.tsx diff --git a/x-pack/plugins/maps/public/classes/sources/xyz_tms_source/__snapshots__/xyz_tms_editor.test.tsx.snap b/x-pack/plugins/maps/public/classes/sources/xyz_tms_source/__snapshots__/xyz_tms_editor.test.tsx.snap new file mode 100644 index 000000000000..b8ed4a727fad --- /dev/null +++ b/x-pack/plugins/maps/public/classes/sources/xyz_tms_source/__snapshots__/xyz_tms_editor.test.tsx.snap @@ -0,0 +1,237 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`attribution validation should provide no validation errors when attribution text and attribution url are provided 1`] = ` + + + + + + + + + + + +`; + +exports[`attribution validation should provide validation error when attribution text is provided without attribution url 1`] = ` + + + + + + + + + + + +`; + +exports[`attribution validation should provide validation error when attribution url is provided without attribution text 1`] = ` + + + + + + + + + + + +`; + +exports[`should render 1`] = ` + + + + + + + + + + + +`; diff --git a/x-pack/plugins/maps/public/classes/sources/xyz_tms_source/layer_wizard.tsx b/x-pack/plugins/maps/public/classes/sources/xyz_tms_source/layer_wizard.tsx index 48c526855d3a..b0344a3e0e31 100644 --- a/x-pack/plugins/maps/public/classes/sources/xyz_tms_source/layer_wizard.tsx +++ b/x-pack/plugins/maps/public/classes/sources/xyz_tms_source/layer_wizard.tsx @@ -19,7 +19,12 @@ export const tmsLayerWizardConfig: LayerWizard = { }), icon: 'grid', renderWizard: ({ previewLayers }: RenderWizardArguments) => { - const onSourceConfigChange = (sourceConfig: XYZTMSSourceConfig) => { + const onSourceConfigChange = (sourceConfig: XYZTMSSourceConfig | null) => { + if (!sourceConfig) { + previewLayers([]); + return; + } + const layerDescriptor = TileLayer.createDescriptor({ sourceDescriptor: XYZTMSSource.createDescriptor(sourceConfig), }); diff --git a/x-pack/plugins/maps/public/classes/sources/xyz_tms_source/xyz_tms_editor.test.tsx b/x-pack/plugins/maps/public/classes/sources/xyz_tms_source/xyz_tms_editor.test.tsx new file mode 100644 index 000000000000..71f78c3e1515 --- /dev/null +++ b/x-pack/plugins/maps/public/classes/sources/xyz_tms_source/xyz_tms_editor.test.tsx @@ -0,0 +1,37 @@ +/* + * 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 from 'react'; +import { shallow } from 'enzyme'; +import { XYZTMSEditor } from './xyz_tms_editor'; + +const onSourceConfigChange = () => {}; + +test('should render', () => { + const component = shallow(); + expect(component).toMatchSnapshot(); +}); + +describe('attribution validation', () => { + test('should provide validation error when attribution text is provided without attribution url', () => { + const component = shallow(); + component.setState({ attributionText: 'myAttribtionLabel' }); + expect(component).toMatchSnapshot(); + }); + + test('should provide validation error when attribution url is provided without attribution text', () => { + const component = shallow(); + component.setState({ attributionUrl: 'http://mySource' }); + expect(component).toMatchSnapshot(); + }); + + test('should provide no validation errors when attribution text and attribution url are provided', () => { + const component = shallow(); + component.setState({ attributionText: 'myAttribtionLabel' }); + component.setState({ attributionUrl: 'http://mySource' }); + expect(component).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/maps/public/classes/sources/xyz_tms_source/xyz_tms_editor.tsx b/x-pack/plugins/maps/public/classes/sources/xyz_tms_source/xyz_tms_editor.tsx index bf5f2c3dfe04..5583f637b447 100644 --- a/x-pack/plugins/maps/public/classes/sources/xyz_tms_source/xyz_tms_editor.tsx +++ b/x-pack/plugins/maps/public/classes/sources/xyz_tms_source/xyz_tms_editor.tsx @@ -9,70 +9,56 @@ import React, { Component, ChangeEvent } from 'react'; import _ from 'lodash'; import { EuiFormRow, EuiFieldText, EuiPanel } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { AttributionDescriptor } from '../../../../common/descriptor_types'; -export type XYZTMSSourceConfig = AttributionDescriptor & { +export type XYZTMSSourceConfig = { urlTemplate: string; + attributionText: string; + attributionUrl: string; }; -export interface Props { - onSourceConfigChange: (sourceConfig: XYZTMSSourceConfig) => void; +interface Props { + onSourceConfigChange: (sourceConfig: XYZTMSSourceConfig | null) => void; } interface State { - tmsInput: string; - tmsCanPreview: boolean; + url: string; attributionText: string; attributionUrl: string; } export class XYZTMSEditor extends Component { state = { - tmsInput: '', - tmsCanPreview: false, + url: '', attributionText: '', attributionUrl: '', }; - _sourceConfigChange = _.debounce((updatedSourceConfig: XYZTMSSourceConfig) => { - if (this.state.tmsCanPreview) { - this.props.onSourceConfigChange(updatedSourceConfig); - } - }, 2000); - - _handleTMSInputChange(e: ChangeEvent) { - const url = e.target.value; + _previewLayer = _.debounce(() => { + const { url, attributionText, attributionUrl } = this.state; - const canPreview = + const isUrlValid = url.indexOf('{x}') >= 0 && url.indexOf('{y}') >= 0 && url.indexOf('{z}') >= 0; - this.setState( - { - tmsInput: url, - tmsCanPreview: canPreview, - }, - () => this._sourceConfigChange({ urlTemplate: url }) - ); - } + const sourceConfig = isUrlValid + ? { + urlTemplate: url, + attributionText, + attributionUrl, + } + : null; + this.props.onSourceConfigChange(sourceConfig); + }, 500); - _handleTMSAttributionChange(attributionUpdate: AttributionDescriptor) { - this.setState( - { - attributionUrl: attributionUpdate.attributionUrl || '', - attributionText: attributionUpdate.attributionText || '', - }, - () => { - const { attributionText, attributionUrl, tmsInput } = this.state; + _onUrlChange = (event: ChangeEvent) => { + this.setState({ url: event.target.value }, this._previewLayer); + }; - if (tmsInput && attributionText && attributionUrl) { - this._sourceConfigChange({ - urlTemplate: tmsInput, - attributionText, - attributionUrl, - }); - } - } - ); - } + _onAttributionTextChange = (event: ChangeEvent) => { + this.setState({ attributionText: event.target.value }, this._previewLayer); + }; + + _onAttributionUrlChange = (event: ChangeEvent) => { + this.setState({ attributionUrl: event.target.value }, this._previewLayer); + }; render() { const { attributionText, attributionUrl } = this.state; @@ -81,11 +67,13 @@ export class XYZTMSEditor extends Component { this._handleTMSInputChange(e)} + onChange={this._onUrlChange} /> { }), ]} > - ) => - this._handleTMSAttributionChange({ attributionText: target.value }) - } - /> + { }), ]} > - ) => - this._handleTMSAttributionChange({ attributionUrl: target.value }) - } - /> + ); From 47b3a947985c6bb1b26fa04f797fe2d454bd2a55 Mon Sep 17 00:00:00 2001 From: Candace Park <56409205+parkiino@users.noreply.github.com> Date: Thu, 23 Jul 2020 20:57:54 -0400 Subject: [PATCH 130/142] [Security Solution][Endpoint] Task/policy save modal text change, remove duplicate policy details text (#73130) [Security Solution][Endpoint] updates policy details text --- .../pages/policy/view/policy_details.test.tsx | 2 +- .../pages/policy/view/policy_details.tsx | 2 +- .../policy/view/policy_forms/config_form.tsx | 16 ++-------------- .../policy/view/policy_forms/events/linux.tsx | 6 ------ .../policy/view/policy_forms/events/mac.tsx | 6 ------ .../policy/view/policy_forms/events/windows.tsx | 3 --- .../view/policy_forms/protections/malware.tsx | 3 --- 7 files changed, 4 insertions(+), 34 deletions(-) diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx index 8612b15f8985..4f7c14735fe2 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx @@ -232,7 +232,7 @@ describe('Policy Details', () => { ); expect(warningCallout).toHaveLength(1); expect(warningCallout.text()).toEqual( - 'This action will update 5 hostsSaving these changes will apply the updates to all active endpoints assigned to this policy' + 'This action will update 5 hostsSaving these changes will apply updates to all endpoints assigned to this policy' ); }); it('should close dialog if cancel button is clicked', () => { diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.tsx index 9576e1aedcaf..288bc484c23b 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.tsx @@ -306,7 +306,7 @@ const ConfirmUpdate = React.memo<{ > diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/config_form.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/config_form.tsx index 763931bc2d3d..8e3c4138efb3 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/config_form.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/config_form.tsx @@ -34,17 +34,10 @@ export const ConfigForm: React.FC<{ */ supportedOss: React.ReactNode; children: React.ReactNode; - /** - * A description for the component. - */ - description: string; - /** - * The `data-test-subj` attribute to append to a certain child element. - */ dataTestSubj: string; /** React Node to be put on the right corner of the card */ rightCorner: React.ReactNode; -}> = React.memo(({ type, supportedOss, children, dataTestSubj, rightCorner, description }) => { +}> = React.memo(({ type, supportedOss, children, dataTestSubj, rightCorner }) => { const typeTitle = useMemo(() => { return ( @@ -85,12 +78,7 @@ export const ConfigForm: React.FC<{ return ( - + {children} diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/linux.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/linux.tsx index d7bae0d2e6ba..66126adb7a4e 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/linux.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/linux.tsx @@ -102,12 +102,6 @@ export const LinuxEvents = React.memo(() => { type={i18n.translate('xpack.securitySolution.endpoint.policy.details.eventCollection', { defaultMessage: 'Event Collection', })} - description={i18n.translate( - 'xpack.securitySolution.endpoint.policy.details.eventCollectionLabel', - { - defaultMessage: 'Event Collection', - } - )} supportedOss={i18n.translate('xpack.securitySolution.endpoint.policy.details.linux', { defaultMessage: 'Linux', })} diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/mac.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/mac.tsx index 37709ff60885..dc70fc0ba0f4 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/mac.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/mac.tsx @@ -102,12 +102,6 @@ export const MacEvents = React.memo(() => { type={i18n.translate('xpack.securitySolution.endpoint.policy.details.eventCollection', { defaultMessage: 'Event Collection', })} - description={i18n.translate( - 'xpack.securitySolution.endpoint.policy.details.eventCollectionLabel', - { - defaultMessage: 'Event Collection', - } - )} supportedOss={i18n.translate('xpack.securitySolution.endpoint.policy.details.mac', { defaultMessage: 'Mac', })} diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/windows.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/windows.tsx index 3c7ecae0d9b4..5acdf67922a3 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/windows.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/events/windows.tsx @@ -142,9 +142,6 @@ export const WindowsEvents = React.memo(() => { type={i18n.translate('xpack.securitySolution.endpoint.policy.details.eventCollection', { defaultMessage: 'Event Collection', })} - description={i18n.translate('xpack.securitySolution.endpoint.policy.details.windowsLabel', { - defaultMessage: 'Windows', - })} supportedOss={i18n.translate('xpack.securitySolution.endpoint.policy.details.windows', { defaultMessage: 'Windows', })} diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/malware.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/malware.tsx index 23ac6cc5b813..dee1e27782e6 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/malware.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/malware.tsx @@ -174,9 +174,6 @@ export const MalwareProtections = React.memo(() => { defaultMessage: 'Windows, Mac', })} dataTestSubj="malwareProtectionsForm" - description={i18n.translate('xpack.securitySolution.endpoint.policy.details.malwareLabel', { - defaultMessage: 'Malware', - })} rightCorner={protectionSwitch} > {radioButtons} From c2ad4bf048c9623aa897efbafd40745062ded338 Mon Sep 17 00:00:00 2001 From: Andrew Cholakian Date: Thu, 23 Jul 2020 20:02:12 -0500 Subject: [PATCH 131/142] [Uptime] Use manual intervals for ping histogram (#72928) * [Uptime] Use manual intervals for ping histogram Fixes https://github.com/elastic/uptime/issues/215 Prior to this we'd get too few buckets in some ranges. * Update test fixtures, remove overly-specific checks * Remove unused import --- .../server/lib/requests/get_ping_histogram.ts | 24 ++---- .../uptime/rest/fixtures/ping_histogram.json | 76 ++++++++++++++----- .../fixtures/ping_histogram_by_filter.json | 76 ++++++++++++++----- .../rest/fixtures/ping_histogram_by_id.json | 76 ++++++++++++++----- .../apis/uptime/rest/ping_histogram.ts | 11 --- 5 files changed, 175 insertions(+), 88 deletions(-) diff --git a/x-pack/plugins/uptime/server/lib/requests/get_ping_histogram.ts b/x-pack/plugins/uptime/server/lib/requests/get_ping_histogram.ts index a74b55c24e22..970d9ad16698 100644 --- a/x-pack/plugins/uptime/server/lib/requests/get_ping_histogram.ts +++ b/x-pack/plugins/uptime/server/lib/requests/get_ping_histogram.ts @@ -8,6 +8,7 @@ import { UMElasticsearchQueryFn } from '../adapters'; import { getFilterClause } from '../helper'; import { HistogramResult, HistogramQueryResult } from '../../../common/runtime_types'; import { QUERY } from '../../../common/constants'; +import { getHistogramInterval } from '../helper/get_histogram_interval'; export interface GetPingHistogramParams { /** @member dateRangeStart timestamp bounds */ @@ -36,22 +37,6 @@ export const getPingHistogram: UMElasticsearchQueryFn< } const filter = getFilterClause(from, to, additionalFilters); - const seriesHistogram: any = {}; - - if (bucketSize) { - seriesHistogram.date_histogram = { - field: '@timestamp', - fixed_interval: bucketSize, - missing: 0, - }; - } else { - seriesHistogram.auto_date_histogram = { - field: '@timestamp', - buckets: QUERY.DEFAULT_BUCKET_COUNT, - missing: 0, - }; - } - const params = { index: dynamicSettings.heartbeatIndices, body: { @@ -63,7 +48,12 @@ export const getPingHistogram: UMElasticsearchQueryFn< size: 0, aggs: { timeseries: { - ...seriesHistogram, + date_histogram: { + field: '@timestamp', + fixed_interval: + bucketSize || getHistogramInterval(from, to, QUERY.DEFAULT_BUCKET_COUNT) + 'ms', + missing: 0, + }, aggs: { down: { filter: { diff --git a/x-pack/test/api_integration/apis/uptime/rest/fixtures/ping_histogram.json b/x-pack/test/api_integration/apis/uptime/rest/fixtures/ping_histogram.json index 562ba64c24b0..85ce545ed92b 100644 --- a/x-pack/test/api_integration/apis/uptime/rest/fixtures/ping_histogram.json +++ b/x-pack/test/api_integration/apis/uptime/rest/fixtures/ping_histogram.json @@ -1,121 +1,157 @@ { "histogram": [ { - "x": 1568172664000, + "x": 1568172657286, "downCount": 7, "upCount": 93, "y": 1 }, { - "x": 1568172694000, + "x": 1568172680087, "downCount": 7, "upCount": 93, "y": 1 }, { - "x": 1568172724000, + "x": 1568172702888, "downCount": 7, "upCount": 93, "y": 1 }, { - "x": 1568172754000, + "x": 1568172725689, + "downCount": 0, + "upCount": 0, + "y": 1 + }, + { + "x": 1568172748490, "downCount": 7, "upCount": 93, "y": 1 }, { - "x": 1568172784000, + "x": 1568172771291, "downCount": 7, "upCount": 93, "y": 1 }, { - "x": 1568172814000, + "x": 1568172794092, "downCount": 8, "upCount": 92, "y": 1 }, { - "x": 1568172844000, + "x": 1568172816893, + "downCount": 0, + "upCount": 0, + "y": 1 + }, + { + "x": 1568172839694, "downCount": 7, "upCount": 93, "y": 1 }, { - "x": 1568172874000, + "x": 1568172862495, "downCount": 7, "upCount": 93, "y": 1 }, { - "x": 1568172904000, + "x": 1568172885296, "downCount": 7, "upCount": 93, "y": 1 }, { - "x": 1568172934000, + "x": 1568172908097, + "downCount": 0, + "upCount": 0, + "y": 1 + }, + { + "x": 1568172930898, "downCount": 7, "upCount": 93, "y": 1 }, { - "x": 1568172964000, + "x": 1568172953699, "downCount": 7, "upCount": 93, "y": 1 }, { - "x": 1568172994000, + "x": 1568172976500, "downCount": 8, "upCount": 92, "y": 1 }, { - "x": 1568173024000, + "x": 1568172999301, + "downCount": 0, + "upCount": 0, + "y": 1 + }, + { + "x": 1568173022102, "downCount": 7, "upCount": 93, "y": 1 }, { - "x": 1568173054000, + "x": 1568173044903, "downCount": 7, "upCount": 93, "y": 1 }, { - "x": 1568173084000, + "x": 1568173067704, "downCount": 7, "upCount": 93, "y": 1 }, { - "x": 1568173114000, + "x": 1568173090505, + "downCount": 0, + "upCount": 0, + "y": 1 + }, + { + "x": 1568173113306, "downCount": 7, "upCount": 93, "y": 1 }, { - "x": 1568173144000, + "x": 1568173136107, "downCount": 7, "upCount": 93, "y": 1 }, { - "x": 1568173174000, + "x": 1568173158908, "downCount": 8, "upCount": 92, "y": 1 }, { - "x": 1568173204000, + "x": 1568173181709, "downCount": 7, "upCount": 93, "y": 1 }, { - "x": 1568173234000, + "x": 1568173204510, + "downCount": 0, + "upCount": 0, + "y": 1 + }, + { + "x": 1568173227311, "downCount": 7, "upCount": 93, "y": 1 diff --git a/x-pack/test/api_integration/apis/uptime/rest/fixtures/ping_histogram_by_filter.json b/x-pack/test/api_integration/apis/uptime/rest/fixtures/ping_histogram_by_filter.json index 42be715c4acd..fe5dc9dd3da3 100644 --- a/x-pack/test/api_integration/apis/uptime/rest/fixtures/ping_histogram_by_filter.json +++ b/x-pack/test/api_integration/apis/uptime/rest/fixtures/ping_histogram_by_filter.json @@ -1,121 +1,157 @@ { "histogram": [ { - "x": 1568172664000, + "x": 1568172657286, "downCount": 0, "upCount": 93, "y": 1 }, { - "x": 1568172694000, + "x": 1568172680087, "downCount": 0, "upCount": 93, "y": 1 }, { - "x": 1568172724000, + "x": 1568172702888, "downCount": 0, "upCount": 93, "y": 1 }, { - "x": 1568172754000, + "x": 1568172725689, + "downCount": 0, + "upCount": 0, + "y": 1 + }, + { + "x": 1568172748490, "downCount": 0, "upCount": 93, "y": 1 }, { - "x": 1568172784000, + "x": 1568172771291, "downCount": 0, "upCount": 93, "y": 1 }, { - "x": 1568172814000, + "x": 1568172794092, "downCount": 0, "upCount": 92, "y": 1 }, { - "x": 1568172844000, + "x": 1568172816893, + "downCount": 0, + "upCount": 0, + "y": 1 + }, + { + "x": 1568172839694, "downCount": 0, "upCount": 93, "y": 1 }, { - "x": 1568172874000, + "x": 1568172862495, "downCount": 0, "upCount": 93, "y": 1 }, { - "x": 1568172904000, + "x": 1568172885296, "downCount": 0, "upCount": 93, "y": 1 }, { - "x": 1568172934000, + "x": 1568172908097, + "downCount": 0, + "upCount": 0, + "y": 1 + }, + { + "x": 1568172930898, "downCount": 0, "upCount": 93, "y": 1 }, { - "x": 1568172964000, + "x": 1568172953699, "downCount": 0, "upCount": 93, "y": 1 }, { - "x": 1568172994000, + "x": 1568172976500, "downCount": 0, "upCount": 92, "y": 1 }, { - "x": 1568173024000, + "x": 1568172999301, + "downCount": 0, + "upCount": 0, + "y": 1 + }, + { + "x": 1568173022102, "downCount": 0, "upCount": 93, "y": 1 }, { - "x": 1568173054000, + "x": 1568173044903, "downCount": 0, "upCount": 93, "y": 1 }, { - "x": 1568173084000, + "x": 1568173067704, "downCount": 0, "upCount": 93, "y": 1 }, { - "x": 1568173114000, + "x": 1568173090505, + "downCount": 0, + "upCount": 0, + "y": 1 + }, + { + "x": 1568173113306, "downCount": 0, "upCount": 93, "y": 1 }, { - "x": 1568173144000, + "x": 1568173136107, "downCount": 0, "upCount": 93, "y": 1 }, { - "x": 1568173174000, + "x": 1568173158908, "downCount": 0, "upCount": 92, "y": 1 }, { - "x": 1568173204000, + "x": 1568173181709, "downCount": 0, "upCount": 93, "y": 1 }, { - "x": 1568173234000, + "x": 1568173204510, + "downCount": 0, + "upCount": 0, + "y": 1 + }, + { + "x": 1568173227311, "downCount": 0, "upCount": 93, "y": 1 diff --git a/x-pack/test/api_integration/apis/uptime/rest/fixtures/ping_histogram_by_id.json b/x-pack/test/api_integration/apis/uptime/rest/fixtures/ping_histogram_by_id.json index 9a726db61632..e54738cf5dbd 100644 --- a/x-pack/test/api_integration/apis/uptime/rest/fixtures/ping_histogram_by_id.json +++ b/x-pack/test/api_integration/apis/uptime/rest/fixtures/ping_histogram_by_id.json @@ -1,121 +1,157 @@ { "histogram": [ { - "x": 1568172664000, + "x": 1568172657286, "downCount": 0, "upCount": 1, "y": 1 }, { - "x": 1568172694000, + "x": 1568172680087, "downCount": 0, "upCount": 1, "y": 1 }, { - "x": 1568172724000, + "x": 1568172702888, "downCount": 0, "upCount": 1, "y": 1 }, { - "x": 1568172754000, + "x": 1568172725689, + "downCount": 0, + "upCount": 0, + "y": 1 + }, + { + "x": 1568172748490, "downCount": 0, "upCount": 1, "y": 1 }, { - "x": 1568172784000, + "x": 1568172771291, "downCount": 0, "upCount": 1, "y": 1 }, { - "x": 1568172814000, + "x": 1568172794092, "downCount": 0, "upCount": 1, "y": 1 }, { - "x": 1568172844000, + "x": 1568172816893, + "downCount": 0, + "upCount": 0, + "y": 1 + }, + { + "x": 1568172839694, "downCount": 0, "upCount": 1, "y": 1 }, { - "x": 1568172874000, + "x": 1568172862495, "downCount": 0, "upCount": 1, "y": 1 }, { - "x": 1568172904000, + "x": 1568172885296, "downCount": 0, "upCount": 1, "y": 1 }, { - "x": 1568172934000, + "x": 1568172908097, + "downCount": 0, + "upCount": 0, + "y": 1 + }, + { + "x": 1568172930898, "downCount": 0, "upCount": 1, "y": 1 }, { - "x": 1568172964000, + "x": 1568172953699, "downCount": 0, "upCount": 1, "y": 1 }, { - "x": 1568172994000, + "x": 1568172976500, "downCount": 0, "upCount": 1, "y": 1 }, { - "x": 1568173024000, + "x": 1568172999301, + "downCount": 0, + "upCount": 0, + "y": 1 + }, + { + "x": 1568173022102, "downCount": 0, "upCount": 1, "y": 1 }, { - "x": 1568173054000, + "x": 1568173044903, "downCount": 0, "upCount": 1, "y": 1 }, { - "x": 1568173084000, + "x": 1568173067704, "downCount": 0, "upCount": 1, "y": 1 }, { - "x": 1568173114000, + "x": 1568173090505, + "downCount": 0, + "upCount": 0, + "y": 1 + }, + { + "x": 1568173113306, "downCount": 0, "upCount": 1, "y": 1 }, { - "x": 1568173144000, + "x": 1568173136107, "downCount": 0, "upCount": 1, "y": 1 }, { - "x": 1568173174000, + "x": 1568173158908, "downCount": 0, "upCount": 1, "y": 1 }, { - "x": 1568173204000, + "x": 1568173181709, "downCount": 0, "upCount": 1, "y": 1 }, { - "x": 1568173234000, + "x": 1568173204510, + "downCount": 0, + "upCount": 0, + "y": 1 + }, + { + "x": 1568173227311, "downCount": 0, "upCount": 1, "y": 1 diff --git a/x-pack/test/api_integration/apis/uptime/rest/ping_histogram.ts b/x-pack/test/api_integration/apis/uptime/rest/ping_histogram.ts index ffcb1a829f0f..b2504e3b921f 100644 --- a/x-pack/test/api_integration/apis/uptime/rest/ping_histogram.ts +++ b/x-pack/test/api_integration/apis/uptime/rest/ping_histogram.ts @@ -6,7 +6,6 @@ import { expectFixtureEql } from './helper/expect_fixture_eql'; import { FtrProviderContext } from '../../../ftr_provider_context'; -import { assertCloseTo } from '../../../../../plugins/uptime/server/lib/helper'; export default function ({ getService }: FtrProviderContext) { describe('pingHistogram', () => { @@ -21,10 +20,6 @@ export default function ({ getService }: FtrProviderContext) { ); const data = apiResponse.body; - // manually testing this value and then removing it to avoid flakiness - const { interval } = data; - assertCloseTo(interval, 22801, 100); - delete data.interval; expectFixtureEql(data, 'ping_histogram'); }); @@ -38,9 +33,6 @@ export default function ({ getService }: FtrProviderContext) { ); const data = apiResponse.body; - const { interval } = data; - assertCloseTo(interval, 22801, 100); - delete data.interval; expectFixtureEql(data, 'ping_histogram_by_id'); }); @@ -55,9 +47,6 @@ export default function ({ getService }: FtrProviderContext) { ); const data = apiResponse.body; - const { interval } = data; - assertCloseTo(interval, 22801, 100); - delete data.interval; expectFixtureEql(data, 'ping_histogram_by_filter'); }); }); From 1329b683de4fb63382449f9115ed9f0c959b3408 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Fri, 24 Jul 2020 07:50:37 +0200 Subject: [PATCH 132/142] =?UTF-8?q?[Composable=20template]=C2=A0Preview=20?= =?UTF-8?q?composite=20template=20(#72598)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jean-Louis Leysens Co-authored-by: Elastic Machine --- .../global_flyout/global_flyout.tsx | 166 +++++++++ .../global_flyout/index.ts | 20 ++ .../public/forms/form_wizard/form_wizard.tsx | 25 +- .../forms/form_wizard/form_wizard_nav.tsx | 6 + .../forms/multi_content/use_multi_content.ts | 4 +- .../public/global_flyout/index.ts | 23 ++ src/plugins/es_ui_shared/public/index.ts | 3 +- .../static/forms/helpers/serializers.ts | 6 +- .../helpers/http_requests.ts | 12 + .../helpers/setup_environment.tsx | 10 +- .../helpers/test_subjects.ts | 4 +- .../home/index_templates_tab.helpers.ts | 11 +- .../home/index_templates_tab.test.ts | 31 +- .../common/constants/index.ts | 2 + .../common/constants/ui_metric.ts | 2 + .../common/lib/template_serialization.ts | 2 +- .../component_template_details.test.ts | 7 +- .../component_template_details.helpers.ts | 6 +- .../helpers/setup_environment.tsx | 13 +- .../component_template_details.tsx | 20 +- .../component_template_details/index.ts | 6 +- .../component_template_list.tsx | 148 +++++--- .../component_templates_selector.tsx | 70 ++-- .../components/component_templates/index.ts | 5 +- .../component_templates/shared_imports.ts | 1 + .../public/application/components/index.ts | 1 + .../components/index_templates/index.ts | 7 + .../simulate_template/index.ts | 13 + .../simulate_template/simulate_template.tsx | 60 ++++ .../simulate_template_flyout.tsx | 119 +++++++ .../datatypes/shape_datatype.test.tsx | 2 - .../datatypes/text_datatype.test.tsx | 2 - .../client_integration/edit_field.test.tsx | 2 - .../helpers/mappings_editor.helpers.tsx | 14 +- .../configuration_form/configuration_form.tsx | 6 +- .../configuration_form_schema.tsx | 3 +- .../document_fields/document_fields.tsx | 14 +- .../editor_toggle_controls.tsx | 2 +- .../field_parameters/name_parameter.tsx | 2 +- .../field_parameters/type_parameter.tsx | 8 +- .../fields/create_field/create_field.tsx | 2 +- .../fields/delete_field_provider.tsx | 2 +- .../fields/edit_field/edit_field.tsx | 326 +++++++++--------- .../edit_field/edit_field_container.tsx | 81 ++++- .../edit_field/update_field_provider.tsx | 147 -------- .../fields/edit_field/use_update_field.ts | 146 ++++++++ .../fields/fields_list_item_container.tsx | 2 +- .../document_fields/fields_json_editor.tsx | 2 +- .../document_fields/fields_tree_editor.tsx | 2 +- .../search_fields/search_result.tsx | 5 +- .../search_fields/search_result_item.tsx | 2 +- .../components/load_mappings/index.ts | 4 +- .../templates_form/templates_form.tsx | 5 +- .../templates_form/templates_form_schema.ts | 2 +- .../components/mappings_editor/index.ts | 8 +- .../index_settings_context.tsx | 1 + .../mappings_editor/mappings_editor.tsx | 136 ++++---- .../mappings_editor_context.tsx | 12 + .../mappings_state_context.tsx | 77 +++++ .../components/mappings_editor/reducer.ts | 98 +----- .../mappings_editor/shared_imports.ts | 1 + .../{types.ts => types/document_fields.ts} | 101 +----- .../components/mappings_editor/types/index.ts | 11 + .../mappings_editor/types/mappings_editor.ts | 110 ++++++ .../components/mappings_editor/types/state.ts | 107 ++++++ ...pings_state.tsx => use_state_listener.tsx} | 136 ++------ .../template_form/steps/step_components.tsx | 2 +- .../template_form/steps/step_logistics.tsx | 27 +- .../template_form/steps/step_review.tsx | 69 +++- .../template_form/template_form.tsx | 142 ++++++-- .../public/application/index.tsx | 15 +- .../template_details/tabs/index.ts | 1 + .../template_details/tabs/tab_preview.tsx | 34 ++ .../template_details/template_details.tsx | 2 - .../template_details_content.tsx | 20 +- .../template_clone/template_clone.tsx | 28 +- .../template_create/template_create.tsx | 35 +- .../sections/template_edit/template_edit.tsx | 26 +- .../public/application/services/api.ts | 12 + .../application/services/documentation.ts | 6 +- .../public/application/services/index.ts | 1 + .../index_management/public/shared_imports.ts | 1 + .../server/client/elasticsearch.ts | 10 + .../api/templates/register_simulate_route.ts | 42 +++ .../api/templates/register_template_routes.ts | 2 + 85 files changed, 1877 insertions(+), 982 deletions(-) create mode 100644 src/plugins/es_ui_shared/__packages_do_not_import__/global_flyout/global_flyout.tsx create mode 100644 src/plugins/es_ui_shared/__packages_do_not_import__/global_flyout/index.ts create mode 100644 src/plugins/es_ui_shared/public/global_flyout/index.ts create mode 100644 x-pack/plugins/index_management/public/application/components/index_templates/index.ts create mode 100644 x-pack/plugins/index_management/public/application/components/index_templates/simulate_template/index.ts create mode 100644 x-pack/plugins/index_management/public/application/components/index_templates/simulate_template/simulate_template.tsx create mode 100644 x-pack/plugins/index_management/public/application/components/index_templates/simulate_template/simulate_template_flyout.tsx delete mode 100644 x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/update_field_provider.tsx create mode 100644 x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/use_update_field.ts create mode 100644 x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_editor_context.tsx create mode 100644 x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_state_context.tsx rename x-pack/plugins/index_management/public/application/components/mappings_editor/{types.ts => types/document_fields.ts} (65%) create mode 100644 x-pack/plugins/index_management/public/application/components/mappings_editor/types/index.ts create mode 100644 x-pack/plugins/index_management/public/application/components/mappings_editor/types/mappings_editor.ts create mode 100644 x-pack/plugins/index_management/public/application/components/mappings_editor/types/state.ts rename x-pack/plugins/index_management/public/application/components/mappings_editor/{mappings_state.tsx => use_state_listener.tsx} (53%) create mode 100644 x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/tabs/tab_preview.tsx create mode 100644 x-pack/plugins/index_management/server/routes/api/templates/register_simulate_route.ts diff --git a/src/plugins/es_ui_shared/__packages_do_not_import__/global_flyout/global_flyout.tsx b/src/plugins/es_ui_shared/__packages_do_not_import__/global_flyout/global_flyout.tsx new file mode 100644 index 000000000000..aa575cd64944 --- /dev/null +++ b/src/plugins/es_ui_shared/__packages_do_not_import__/global_flyout/global_flyout.tsx @@ -0,0 +1,166 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { + createContext, + useContext, + useState, + useCallback, + useMemo, + useEffect, + useRef, +} from 'react'; +import { EuiFlyout } from '@elastic/eui'; + +interface Context { + addContent:

(content: Content

) => void; + removeContent: (contentId: string) => void; + closeFlyout: () => void; +} + +interface Content

{ + id: string; + Component: React.FunctionComponent

; + props?: P; + flyoutProps?: { [key: string]: any }; + cleanUpFunc?: () => void; +} + +const FlyoutMultiContentContext = createContext(undefined); + +const DEFAULT_FLYOUT_PROPS = { + 'data-test-subj': 'flyout', + size: 'm' as 'm', + maxWidth: 500, +}; + +export const GlobalFlyoutProvider: React.FC = ({ children }) => { + const [showFlyout, setShowFlyout] = useState(false); + const [activeContent, setActiveContent] = useState | undefined>(undefined); + + const { id, Component, props, flyoutProps } = activeContent ?? {}; + + const addContent: Context['addContent'] = useCallback((content) => { + setActiveContent((prev) => { + if (prev !== undefined) { + if (prev.id !== content.id && prev.cleanUpFunc) { + // Clean up anything from the content about to be removed + prev.cleanUpFunc(); + } + } + return content; + }); + + setShowFlyout(true); + }, []); + + const closeFlyout: Context['closeFlyout'] = useCallback(() => { + setActiveContent(undefined); + setShowFlyout(false); + }, []); + + const removeContent: Context['removeContent'] = useCallback( + (contentId: string) => { + if (contentId === id) { + closeFlyout(); + } + }, + [id, closeFlyout] + ); + + const mergedFlyoutProps = useMemo(() => { + return { + ...DEFAULT_FLYOUT_PROPS, + onClose: closeFlyout, + ...flyoutProps, + }; + }, [flyoutProps, closeFlyout]); + + const context: Context = { + addContent, + removeContent, + closeFlyout, + }; + + const ContentFlyout = showFlyout && Component !== undefined ? Component : null; + + return ( + + <> + {children} + {ContentFlyout && ( + + + + )} + + + ); +}; + +export const useGlobalFlyout = () => { + const ctx = useContext(FlyoutMultiContentContext); + + if (ctx === undefined) { + throw new Error('useGlobalFlyout must be used within a '); + } + + const isMounted = useRef(false); + /** + * A component can add one or multiple content to the flyout + * during its lifecycle. When it unmounts, we will remove + * all those content added to the flyout. + */ + const contents = useRef | undefined>(undefined); + const { removeContent, addContent: addContentToContext } = ctx; + + useEffect(() => { + isMounted.current = true; + + return () => { + isMounted.current = false; + }; + }, []); + + const getContents = useCallback(() => { + if (contents.current === undefined) { + contents.current = new Set(); + } + return contents.current; + }, []); + + const addContent: Context['addContent'] = useCallback( + (content) => { + getContents().add(content.id); + return addContentToContext(content); + }, + [getContents, addContentToContext] + ); + + useEffect(() => { + return () => { + if (!isMounted.current) { + // When the component unmounts, remove all the content it has added to the flyout + Array.from(getContents()).forEach(removeContent); + } + }; + }, [removeContent]); + + return { ...ctx, addContent }; +}; diff --git a/src/plugins/es_ui_shared/__packages_do_not_import__/global_flyout/index.ts b/src/plugins/es_ui_shared/__packages_do_not_import__/global_flyout/index.ts new file mode 100644 index 000000000000..c49692547fb2 --- /dev/null +++ b/src/plugins/es_ui_shared/__packages_do_not_import__/global_flyout/index.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { GlobalFlyoutProvider, useGlobalFlyout } from './global_flyout'; diff --git a/src/plugins/es_ui_shared/public/forms/form_wizard/form_wizard.tsx b/src/plugins/es_ui_shared/public/forms/form_wizard/form_wizard.tsx index cdb332e9e913..642a21eae50e 100644 --- a/src/plugins/es_ui_shared/public/forms/form_wizard/form_wizard.tsx +++ b/src/plugins/es_ui_shared/public/forms/form_wizard/form_wizard.tsx @@ -27,13 +27,14 @@ import { } from './form_wizard_context'; import { FormWizardNav, NavTexts } from './form_wizard_nav'; -interface Props extends ProviderProps { +interface Props extends ProviderProps { isSaving?: boolean; apiError: JSX.Element | null; texts?: Partial; + rightContentNav?: JSX.Element | null | ((stepId: S) => JSX.Element | null); } -export function FormWizard({ +export function FormWizard({ texts, defaultActiveStep, defaultValue, @@ -43,7 +44,8 @@ export function FormWizard({ onSave, onChange, children, -}: Props) { + rightContentNav, +}: Props) { return ( defaultValue={defaultValue} @@ -53,7 +55,14 @@ export function FormWizard({ defaultActiveStep={defaultActiveStep} > - {({ activeStepIndex, lastStep, steps, isCurrentStepValid, navigateToStep }) => { + {({ + activeStepIndex, + lastStep, + steps, + isCurrentStepValid, + navigateToStep, + activeStepId, + }) => { const stepsRequiredArray = Object.values(steps).map( (step) => Boolean(step.isRequired) && step.isComplete === false ); @@ -95,6 +104,13 @@ export function FormWizard({ }; }); + const getRightContentNav = () => { + if (typeof rightContentNav === 'function') { + return rightContentNav(activeStepId); + } + return rightContentNav; + }; + const onBack = () => { const prevStep = activeStepIndex - 1; navigateToStep(prevStep); @@ -129,6 +145,7 @@ export function FormWizard({ onBack={onBack} onNext={onNext} texts={texts} + getRightContent={getRightContentNav} /> ); diff --git a/src/plugins/es_ui_shared/public/forms/form_wizard/form_wizard_nav.tsx b/src/plugins/es_ui_shared/public/forms/form_wizard/form_wizard_nav.tsx index 3e0e9cf897b5..0af99e8bce35 100644 --- a/src/plugins/es_ui_shared/public/forms/form_wizard/form_wizard_nav.tsx +++ b/src/plugins/es_ui_shared/public/forms/form_wizard/form_wizard_nav.tsx @@ -29,6 +29,7 @@ interface Props { isSaving?: boolean; isStepValid?: boolean; texts?: Partial; + getRightContent?: () => JSX.Element | null | undefined; } export interface NavTexts { @@ -53,6 +54,7 @@ export const FormWizardNav = ({ onBack, onNext, texts, + getRightContent, }: Props) => { const isLastStep = activeStepIndex === lastStep; const labels = { @@ -66,6 +68,8 @@ export const FormWizardNav = ({ : labels.save : labels.next; + const rightContent = getRightContent !== undefined ? getRightContent() : undefined; + return ( @@ -100,6 +104,8 @@ export const FormWizardNav = ({ + + {rightContent && {rightContent}} ); }; diff --git a/src/plugins/es_ui_shared/public/forms/multi_content/use_multi_content.ts b/src/plugins/es_ui_shared/public/forms/multi_content/use_multi_content.ts index 8d470f6454b0..2e7c91a26e1f 100644 --- a/src/plugins/es_ui_shared/public/forms/multi_content/use_multi_content.ts +++ b/src/plugins/es_ui_shared/public/forms/multi_content/use_multi_content.ts @@ -94,7 +94,7 @@ export function useMultiContent({ const activeContentData: Partial = {}; for (const [id, _content] of Object.entries(contents.current)) { - if (validation.contents[id as keyof T]) { + if (validation.contents[id as keyof T] !== false) { const contentData = (_content as Content).getData(); // Replace the getData() handler with the cached value @@ -161,7 +161,7 @@ export function useMultiContent({ ); /** - * Validate the multi-content active content(s) in the DOM + * Validate the content(s) currently in the DOM */ const validate = useCallback(async () => { if (Object.keys(contents.current).length === 0) { diff --git a/src/plugins/es_ui_shared/public/global_flyout/index.ts b/src/plugins/es_ui_shared/public/global_flyout/index.ts new file mode 100644 index 000000000000..e876594337c1 --- /dev/null +++ b/src/plugins/es_ui_shared/public/global_flyout/index.ts @@ -0,0 +1,23 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { + GlobalFlyoutProvider, + useGlobalFlyout, +} from '../../__packages_do_not_import__/global_flyout'; diff --git a/src/plugins/es_ui_shared/public/index.ts b/src/plugins/es_ui_shared/public/index.ts index 98a305fe68f0..bdea5ccf5fe2 100644 --- a/src/plugins/es_ui_shared/public/index.ts +++ b/src/plugins/es_ui_shared/public/index.ts @@ -24,6 +24,7 @@ import * as Forms from './forms'; import * as Monaco from './monaco'; import * as ace from './ace'; +import * as GlobalFlyout from './global_flyout'; export { JsonEditor, OnJsonEditorUpdateHandler } from './components/json_editor'; @@ -65,7 +66,7 @@ export { useAuthorizationContext, } from './authorization'; -export { Monaco, Forms, ace }; +export { Monaco, Forms, ace, GlobalFlyout }; export { extractQueryParams } from './url'; diff --git a/src/plugins/es_ui_shared/static/forms/helpers/serializers.ts b/src/plugins/es_ui_shared/static/forms/helpers/serializers.ts index 98287f6bac35..733a60f1f86f 100644 --- a/src/plugins/es_ui_shared/static/forms/helpers/serializers.ts +++ b/src/plugins/es_ui_shared/static/forms/helpers/serializers.ts @@ -64,9 +64,13 @@ interface StripEmptyFieldsOptions { * @param options An optional configuration object. By default recursive it turned on. */ export const stripEmptyFields = ( - object: { [key: string]: any }, + object?: { [key: string]: any }, options?: StripEmptyFieldsOptions ): { [key: string]: any } => { + if (object === undefined) { + return {}; + } + const { types = ['string', 'object'], recursive = false } = options || {}; return Object.entries(object).reduce((acc, [key, value]) => { diff --git a/x-pack/plugins/index_management/__jest__/client_integration/helpers/http_requests.ts b/x-pack/plugins/index_management/__jest__/client_integration/helpers/http_requests.ts index 907c749f8ec0..12cf7ccac6c5 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/helpers/http_requests.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/helpers/http_requests.ts @@ -92,6 +92,17 @@ const registerHttpRequestMockHelpers = (server: SinonFakeServer) => { ]); }; + const setSimulateTemplateResponse = (response?: HttpResponse, error?: any) => { + const status = error ? error.status || 400 : 200; + const body = error ? JSON.stringify(error.body) : JSON.stringify(response); + + server.respondWith('POST', `${API_BASE_PATH}/index_templates/simulate`, [ + status, + { 'Content-Type': 'application/json' }, + body, + ]); + }; + return { setLoadTemplatesResponse, setLoadIndicesResponse, @@ -102,6 +113,7 @@ const registerHttpRequestMockHelpers = (server: SinonFakeServer) => { setLoadTemplateResponse, setCreateTemplateResponse, setUpdateTemplateResponse, + setSimulateTemplateResponse, }; }; diff --git a/x-pack/plugins/index_management/__jest__/client_integration/helpers/setup_environment.tsx b/x-pack/plugins/index_management/__jest__/client_integration/helpers/setup_environment.tsx index ad445f75f047..e40cdc026210 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/helpers/setup_environment.tsx +++ b/x-pack/plugins/index_management/__jest__/client_integration/helpers/setup_environment.tsx @@ -14,6 +14,8 @@ import { notificationServiceMock, docLinksServiceMock, } from '../../../../../../src/core/public/mocks'; +import { GlobalFlyout } from '../../../../../../src/plugins/es_ui_shared/public'; + import { AppContextProvider } from '../../../public/application/app_context'; import { httpService } from '../../../public/application/services/http'; import { breadcrumbService } from '../../../public/application/services/breadcrumbs'; @@ -23,9 +25,11 @@ import { ExtensionsService } from '../../../public/services'; import { UiMetricService } from '../../../public/application/services/ui_metric'; import { setUiMetricService } from '../../../public/application/services/api'; import { setExtensionsService } from '../../../public/application/store/selectors'; +import { MappingsEditorProvider } from '../../../public/application/components'; import { init as initHttpRequests } from './http_requests'; const mockHttpClient = axios.create({ adapter: axiosXhrAdapter }); +const { GlobalFlyoutProvider } = GlobalFlyout; export const services = { extensionsService: new ExtensionsService(), @@ -62,7 +66,11 @@ export const WithAppDependencies = (Comp: any, overridingDependencies: any = {}) const mergedDependencies = merge({}, appDependencies, overridingDependencies); return ( - + + + + + ); }; diff --git a/x-pack/plugins/index_management/__jest__/client_integration/helpers/test_subjects.ts b/x-pack/plugins/index_management/__jest__/client_integration/helpers/test_subjects.ts index 9889ebe16ba1..ecedf819e618 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/helpers/test_subjects.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/helpers/test_subjects.ts @@ -28,6 +28,7 @@ export type TestSubjects = | 'legacyTemplateTable' | 'manageTemplateButton' | 'mappingsTabContent' + | 'previewTabContent' | 'noAliasesCallout' | 'noMappingsCallout' | 'noSettingsCallout' @@ -48,4 +49,5 @@ export type TestSubjects = | 'templateList' | 'templatesTab' | 'templateTable' - | 'viewButton'; + | 'viewButton' + | 'simulateTemplatePreview'; diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.helpers.ts index a39741905335..23b40f4cbd3d 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.helpers.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.helpers.ts @@ -40,10 +40,15 @@ const createActions = (testBed: TestBed) => { /** * User Actions */ - const selectDetailsTab = (tab: 'summary' | 'settings' | 'mappings' | 'aliases') => { - const tabs = ['summary', 'settings', 'mappings', 'aliases']; + const selectDetailsTab = async ( + tab: 'summary' | 'settings' | 'mappings' | 'aliases' | 'preview' + ) => { + const tabs = ['summary', 'settings', 'mappings', 'aliases', 'preview']; - testBed.find('templateDetails.tab').at(tabs.indexOf(tab)).simulate('click'); + await act(async () => { + testBed.find('templateDetails.tab').at(tabs.indexOf(tab)).simulate('click'); + }); + testBed.component.update(); }; const clickReloadButton = () => { diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.test.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.test.ts index f7ebc0bcf632..06f57896d490 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.test.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/index_templates_tab.test.ts @@ -493,7 +493,7 @@ describe('Index Templates tab', () => { }); describe('tabs', () => { - test('should have 4 tabs', async () => { + test('should have 5 tabs', async () => { const template = fixtures.getTemplate({ name: `a${getRandomString()}`, indexPatterns: ['template1Pattern1*', 'template1Pattern2'], @@ -524,35 +524,48 @@ describe('Index Templates tab', () => { const { find, actions, exists } = testBed; httpRequestsMockHelpers.setLoadTemplateResponse(template); + httpRequestsMockHelpers.setSimulateTemplateResponse({ simulateTemplate: 'response' }); await actions.clickTemplateAt(0); - expect(find('templateDetails.tab').length).toBe(4); + expect(find('templateDetails.tab').length).toBe(5); expect(find('templateDetails.tab').map((t) => t.text())).toEqual([ 'Summary', 'Settings', 'Mappings', 'Aliases', + 'Preview', ]); // Summary tab should be initial active tab expect(exists('summaryTab')).toBe(true); // Navigate and verify all tabs - actions.selectDetailsTab('settings'); + await actions.selectDetailsTab('settings'); expect(exists('summaryTab')).toBe(false); expect(exists('settingsTabContent')).toBe(true); - actions.selectDetailsTab('aliases'); + await actions.selectDetailsTab('aliases'); expect(exists('summaryTab')).toBe(false); expect(exists('settingsTabContent')).toBe(false); expect(exists('aliasesTabContent')).toBe(true); - actions.selectDetailsTab('mappings'); + await actions.selectDetailsTab('mappings'); expect(exists('summaryTab')).toBe(false); expect(exists('settingsTabContent')).toBe(false); expect(exists('aliasesTabContent')).toBe(false); expect(exists('mappingsTabContent')).toBe(true); + + await actions.selectDetailsTab('preview'); + expect(exists('summaryTab')).toBe(false); + expect(exists('settingsTabContent')).toBe(false); + expect(exists('aliasesTabContent')).toBe(false); + expect(exists('mappingsTabContent')).toBe(false); + expect(exists('previewTabContent')).toBe(true); + + expect(find('simulateTemplatePreview').text().replace(/\s/g, '')).toEqual( + JSON.stringify({ simulateTemplate: 'response' }) + ); }); test('should show an info callout if data is not present', async () => { @@ -568,17 +581,17 @@ describe('Index Templates tab', () => { await actions.clickTemplateAt(0); - expect(find('templateDetails.tab').length).toBe(4); + expect(find('templateDetails.tab').length).toBe(5); expect(exists('summaryTab')).toBe(true); // Navigate and verify callout message per tab - actions.selectDetailsTab('settings'); + await actions.selectDetailsTab('settings'); expect(exists('noSettingsCallout')).toBe(true); - actions.selectDetailsTab('mappings'); + await actions.selectDetailsTab('mappings'); expect(exists('noMappingsCallout')).toBe(true); - actions.selectDetailsTab('aliases'); + await actions.selectDetailsTab('aliases'); expect(exists('noAliasesCallout')).toBe(true); }); }); diff --git a/x-pack/plugins/index_management/common/constants/index.ts b/x-pack/plugins/index_management/common/constants/index.ts index d1700f0e611c..11240271503e 100644 --- a/x-pack/plugins/index_management/common/constants/index.ts +++ b/x-pack/plugins/index_management/common/constants/index.ts @@ -47,7 +47,9 @@ export { UIM_TEMPLATE_DETAIL_PANEL_SETTINGS_TAB, UIM_TEMPLATE_DETAIL_PANEL_MAPPINGS_TAB, UIM_TEMPLATE_DETAIL_PANEL_ALIASES_TAB, + UIM_TEMPLATE_DETAIL_PANEL_PREVIEW_TAB, UIM_TEMPLATE_CREATE, UIM_TEMPLATE_UPDATE, UIM_TEMPLATE_CLONE, + UIM_TEMPLATE_SIMULATE, } from './ui_metric'; diff --git a/x-pack/plugins/index_management/common/constants/ui_metric.ts b/x-pack/plugins/index_management/common/constants/ui_metric.ts index 5fda812c704d..545555b92f35 100644 --- a/x-pack/plugins/index_management/common/constants/ui_metric.ts +++ b/x-pack/plugins/index_management/common/constants/ui_metric.ts @@ -41,6 +41,8 @@ export const UIM_TEMPLATE_DETAIL_PANEL_SUMMARY_TAB = 'template_details_summary_t export const UIM_TEMPLATE_DETAIL_PANEL_SETTINGS_TAB = 'template_details_settings_tab'; export const UIM_TEMPLATE_DETAIL_PANEL_MAPPINGS_TAB = 'template_details_mappings_tab'; export const UIM_TEMPLATE_DETAIL_PANEL_ALIASES_TAB = 'template_details_aliases_tab'; +export const UIM_TEMPLATE_DETAIL_PANEL_PREVIEW_TAB = 'template_details_preview_tab'; export const UIM_TEMPLATE_CREATE = 'template_create'; export const UIM_TEMPLATE_UPDATE = 'template_update'; export const UIM_TEMPLATE_CLONE = 'template_clone'; +export const UIM_TEMPLATE_SIMULATE = 'template_simulate'; diff --git a/x-pack/plugins/index_management/common/lib/template_serialization.ts b/x-pack/plugins/index_management/common/lib/template_serialization.ts index 069d6ac29fbc..1803d89a4001 100644 --- a/x-pack/plugins/index_management/common/lib/template_serialization.ts +++ b/x-pack/plugins/index_management/common/lib/template_serialization.ts @@ -109,7 +109,7 @@ export function serializeLegacyTemplate(template: TemplateDeserialized): LegacyT version, order, indexPatterns, - template: { settings, aliases, mappings }, + template: { settings, aliases, mappings } = {}, } = template; return { diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_details.test.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_details.test.ts index 3d496d68cc66..a112d73230b8 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_details.test.ts +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/component_template_details.test.ts @@ -61,11 +61,10 @@ describe('', () => { const { exists, find, actions, component } = testBed; // Verify flyout exists with correct title - expect(exists('componentTemplateDetails')).toBe(true); - expect(find('componentTemplateDetails.title').text()).toBe(COMPONENT_TEMPLATE.name); + expect(find('title').text()).toBe(COMPONENT_TEMPLATE.name); // Verify footer does not display since "actions" prop was not provided - expect(exists('componentTemplateDetails.footer')).toBe(false); + expect(exists('footer')).toBe(false); // Verify tabs exist expect(exists('settingsTab')).toBe(true); @@ -185,7 +184,7 @@ describe('', () => { const { exists, actions, component, find } = testBed; // Verify footer exists - expect(exists('componentTemplateDetails.footer')).toBe(true); + expect(exists('footer')).toBe(true); expect(exists('manageComponentTemplateButton')).toBe(true); // Click manage button and verify actions diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_details.helpers.ts b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_details.helpers.ts index 25c2d654fd90..fe81e8dcfe12 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_details.helpers.ts +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/component_template_details.helpers.ts @@ -6,7 +6,7 @@ import { registerTestBed, TestBed } from '../../../../../../../../../test_utils'; import { WithAppDependencies } from './setup_environment'; -import { ComponentTemplateDetailsFlyout } from '../../../component_template_details'; +import { ComponentTemplateDetailsFlyoutContent } from '../../../component_template_details'; export type ComponentTemplateDetailsTestBed = TestBed & { actions: ReturnType; @@ -44,7 +44,7 @@ const createActions = (testBed: TestBed) = export const setup = (props: any): ComponentTemplateDetailsTestBed => { const setupTestBed = registerTestBed( - WithAppDependencies(ComponentTemplateDetailsFlyout), + WithAppDependencies(ComponentTemplateDetailsFlyoutContent), { memoryRouter: { wrapComponent: false, @@ -65,6 +65,8 @@ export type ComponentTemplateDetailsTestSubjects = | 'componentTemplateDetails' | 'componentTemplateDetails.title' | 'componentTemplateDetails.footer' + | 'title' + | 'footer' | 'summaryTab' | 'mappingsTab' | 'settingsTab' diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/setup_environment.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/setup_environment.tsx index 7e460d3855cb..2f7317e3e656 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/setup_environment.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/__jest__/client_integration/helpers/setup_environment.tsx @@ -15,12 +15,15 @@ import { applicationServiceMock, } from '../../../../../../../../../../src/core/public/mocks'; +import { GlobalFlyout } from '../../../../../../../../../../src/plugins/es_ui_shared/public'; +import { MappingsEditorProvider } from '../../../../mappings_editor'; import { ComponentTemplatesProvider } from '../../../component_templates_context'; import { init as initHttpRequests } from './http_requests'; import { API_BASE_PATH } from './constants'; const mockHttpClient = axios.create({ adapter: axiosXhrAdapter }); +const { GlobalFlyoutProvider } = GlobalFlyout; const appDependencies = { httpClient: (mockHttpClient as unknown) as HttpSetup, @@ -42,7 +45,11 @@ export const setupEnvironment = () => { }; export const WithAppDependencies = (Comp: any) => (props: any) => ( - - - + + + + + + + ); diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_details/component_template_details.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_details/component_template_details.tsx index 60f1fff3cc9d..0f5bc64c358b 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_details/component_template_details.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_details/component_template_details.tsx @@ -8,7 +8,6 @@ import React, { useState } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { - EuiFlyout, EuiFlyoutHeader, EuiTitle, EuiFlyoutBody, @@ -28,14 +27,19 @@ import { ComponentTemplateTabs, TabType } from './tabs'; import { ManageButton, ManageAction } from './manage_button'; import { attemptToDecodeURI } from '../lib'; -interface Props { +export interface Props { componentTemplateName: string; onClose: () => void; actions?: ManageAction[]; showSummaryCallToAction?: boolean; } -export const ComponentTemplateDetailsFlyout: React.FunctionComponent = ({ +export const defaultFlyoutProps = { + 'data-test-subj': 'componentTemplateDetails', + 'aria-labelledby': 'componentTemplateDetailsFlyoutTitle', +}; + +export const ComponentTemplateDetailsFlyoutContent: React.FunctionComponent = ({ componentTemplateName, onClose, actions, @@ -109,13 +113,7 @@ export const ComponentTemplateDetailsFlyout: React.FunctionComponent = ({ } return ( - + <> @@ -172,6 +170,6 @@ export const ComponentTemplateDetailsFlyout: React.FunctionComponent = ({ )} - + ); }; diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_details/index.ts b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_details/index.ts index 11aac200a2f1..8687a1f5b89c 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_details/index.ts +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_details/index.ts @@ -4,4 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -export { ComponentTemplateDetailsFlyout } from './component_template_details'; +export { + ComponentTemplateDetailsFlyoutContent, + defaultFlyoutProps, + Props as ComponentTemplateDetailsProps, +} from './component_template_details'; diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/component_template_list.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/component_template_list.tsx index efc8b649ef87..8ba7409a9ac5 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/component_template_list.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_list/component_template_list.tsx @@ -4,18 +4,22 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { RouteComponentProps } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { ScopedHistory } from 'kibana/public'; import { EuiLink, EuiText, EuiSpacer } from '@elastic/eui'; -import { SectionLoading, ComponentTemplateDeserialized } from '../shared_imports'; +import { SectionLoading, ComponentTemplateDeserialized, GlobalFlyout } from '../shared_imports'; import { UIM_COMPONENT_TEMPLATE_LIST_LOAD } from '../constants'; import { attemptToDecodeURI } from '../lib'; import { useComponentTemplatesContext } from '../component_templates_context'; -import { ComponentTemplateDetailsFlyout } from '../component_template_details'; +import { + ComponentTemplateDetailsFlyoutContent, + defaultFlyoutProps, + ComponentTemplateDetailsProps, +} from '../component_template_details'; import { EmptyPrompt } from './empty_prompt'; import { ComponentTable } from './table'; import { LoadError } from './error'; @@ -26,39 +30,112 @@ interface Props { history: RouteComponentProps['history']; } +const { useGlobalFlyout } = GlobalFlyout; + export const ComponentTemplateList: React.FunctionComponent = ({ componentTemplateName, history, }) => { + const { + addContent: addContentToGlobalFlyout, + removeContent: removeContentFromGlobalFlyout, + } = useGlobalFlyout(); const { api, trackMetric, documentation } = useComponentTemplatesContext(); const { data, isLoading, error, sendRequest } = api.useLoadComponentTemplates(); const [componentTemplatesToDelete, setComponentTemplatesToDelete] = useState([]); - const goToComponentTemplateList = () => { + const goToComponentTemplateList = useCallback(() => { return history.push({ pathname: 'component_templates', }); - }; - - const goToEditComponentTemplate = (name: string) => { - return history.push({ - pathname: encodeURI(`edit_component_template/${encodeURIComponent(name)}`), - }); - }; + }, [history]); + + const goToEditComponentTemplate = useCallback( + (name: string) => { + return history.push({ + pathname: encodeURI(`edit_component_template/${encodeURIComponent(name)}`), + }); + }, + [history] + ); - const goToCloneComponentTemplate = (name: string) => { - return history.push({ - pathname: encodeURI(`create_component_template/${encodeURIComponent(name)}`), - }); - }; + const goToCloneComponentTemplate = useCallback( + (name: string) => { + return history.push({ + pathname: encodeURI(`create_component_template/${encodeURIComponent(name)}`), + }); + }, + [history] + ); // Track component loaded useEffect(() => { trackMetric('loaded', UIM_COMPONENT_TEMPLATE_LIST_LOAD); }, [trackMetric]); + useEffect(() => { + if (componentTemplateName) { + const actions = [ + { + name: i18n.translate('xpack.idxMgmt.componentTemplateDetails.editButtonLabel', { + defaultMessage: 'Edit', + }), + icon: 'pencil', + handleActionClick: () => + goToEditComponentTemplate(attemptToDecodeURI(componentTemplateName)), + }, + { + name: i18n.translate('xpack.idxMgmt.componentTemplateDetails.cloneActionLabel', { + defaultMessage: 'Clone', + }), + icon: 'copy', + handleActionClick: () => + goToCloneComponentTemplate(attemptToDecodeURI(componentTemplateName)), + }, + { + name: i18n.translate('xpack.idxMgmt.componentTemplateDetails.deleteButtonLabel', { + defaultMessage: 'Delete', + }), + icon: 'trash', + getIsDisabled: (details: ComponentTemplateDeserialized) => + details._kbnMeta.usedBy.length > 0, + closePopoverOnClick: true, + handleActionClick: () => { + setComponentTemplatesToDelete([attemptToDecodeURI(componentTemplateName)]); + }, + }, + ]; + + // Open the flyout with the Component Template Details content + addContentToGlobalFlyout({ + id: 'componentTemplateDetails', + Component: ComponentTemplateDetailsFlyoutContent, + props: { + onClose: goToComponentTemplateList, + componentTemplateName, + showSummaryCallToAction: true, + actions, + }, + flyoutProps: { ...defaultFlyoutProps, onClose: goToComponentTemplateList }, + }); + } + }, [ + componentTemplateName, + goToComponentTemplateList, + goToEditComponentTemplate, + goToCloneComponentTemplate, + addContentToGlobalFlyout, + history, + ]); + + useEffect(() => { + if (!componentTemplateName) { + removeContentFromGlobalFlyout('componentTemplateDetails'); + } + }, [componentTemplateName, removeContentFromGlobalFlyout]); + let content: React.ReactNode; if (isLoading) { @@ -126,45 +203,6 @@ export const ComponentTemplateList: React.FunctionComponent = ({ componentTemplatesToDelete={componentTemplatesToDelete} /> ) : null} - - {/* details flyout */} - {componentTemplateName && ( - - goToEditComponentTemplate(attemptToDecodeURI(componentTemplateName)), - }, - { - name: i18n.translate('xpack.idxMgmt.componentTemplateDetails.cloneActionLabel', { - defaultMessage: 'Clone', - }), - icon: 'copy', - handleActionClick: () => - goToCloneComponentTemplate(attemptToDecodeURI(componentTemplateName)), - }, - { - name: i18n.translate('xpack.idxMgmt.componentTemplateDetails.deleteButtonLabel', { - defaultMessage: 'Delete', - }), - icon: 'trash', - getIsDisabled: (details: ComponentTemplateDeserialized) => - details._kbnMeta.usedBy.length > 0, - closePopoverOnClick: true, - handleActionClick: () => { - setComponentTemplatesToDelete([attemptToDecodeURI(componentTemplateName)]); - }, - }, - ]} - /> - )}

); }; diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates_selector.tsx b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates_selector.tsx index 8795c08fd2be..ed570579d4e4 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates_selector.tsx +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates_selector.tsx @@ -11,8 +11,12 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { ComponentTemplateListItem } from '../../../../../common'; -import { SectionError, SectionLoading } from '../shared_imports'; -import { ComponentTemplateDetailsFlyout } from '../component_template_details'; +import { SectionError, SectionLoading, GlobalFlyout } from '../shared_imports'; +import { + ComponentTemplateDetailsFlyoutContent, + defaultFlyoutProps, + ComponentTemplateDetailsProps, +} from '../component_template_details'; import { CreateButtonPopOver } from './components'; import { ComponentTemplates } from './component_templates'; import { ComponentTemplatesSelection } from './component_templates_selection'; @@ -20,10 +24,12 @@ import { useApi } from '../component_templates_context'; import './component_templates_selector.scss'; +const { useGlobalFlyout } = GlobalFlyout; + interface Props { onChange: (components: string[]) => void; onComponentsLoaded: (components: ComponentTemplateListItem[]) => void; - defaultValue: string[]; + defaultValue?: string[]; docUri: string; emptyPrompt?: { text?: string | JSX.Element; @@ -53,6 +59,10 @@ export const ComponentTemplatesSelector = ({ emptyPrompt: { text, showCreateButton } = {}, }: Props) => { const { data: components, isLoading, error } = useApi().useLoadComponentTemplates(); + const { + addContent: addContentToGlobalFlyout, + removeContent: removeContentFromGlobalFlyout, + } = useGlobalFlyout(); const [selectedComponent, setSelectedComponent] = useState(null); const [componentsSelected, setComponentsSelected] = useState([]); const isInitialized = useRef(false); @@ -60,15 +70,20 @@ export const ComponentTemplatesSelector = ({ const hasSelection = Object.keys(componentsSelected).length > 0; const hasComponents = components && components.length > 0 ? true : false; + const closeComponentTemplateDetails = () => { + setSelectedComponent(null); + }; + useEffect(() => { if (components) { if ( + defaultValue && defaultValue.length > 0 && componentsSelected.length === 0 && isInitialized.current === false ) { - // Once the components are loaded we check the ones selected - // from the defaultValue provided + // Once the components are fetched, we check the ones previously selected + // from the prop "defaultValue" passed. const nextComponentsSelected = defaultValue .map((name) => components.find((comp) => comp.name === name)) .filter(Boolean) as ComponentTemplateListItem[]; @@ -88,6 +103,30 @@ export const ComponentTemplatesSelector = ({ } }, [isLoading, error, components, onComponentsLoaded]); + useEffect(() => { + if (selectedComponent) { + // Open the flyout with the Component Template Details content + addContentToGlobalFlyout({ + id: 'componentTemplateDetails', + Component: ComponentTemplateDetailsFlyoutContent, + props: { + onClose: closeComponentTemplateDetails, + componentTemplateName: selectedComponent, + }, + flyoutProps: { ...defaultFlyoutProps, onClose: closeComponentTemplateDetails }, + cleanUpFunc: () => { + setSelectedComponent(null); + }, + }); + } + }, [selectedComponent, addContentToGlobalFlyout]); + + useEffect(() => { + if (!selectedComponent) { + removeContentFromGlobalFlyout('componentTemplateDetails'); + } + }, [selectedComponent, removeContentFromGlobalFlyout]); + const onSelectionReorder = (reorderedComponents: ComponentTemplateListItem[]) => { setComponentsSelected(reorderedComponents); }; @@ -198,30 +237,12 @@ export const ComponentTemplatesSelector = ({
); - const renderComponentDetails = () => { - if (!selectedComponent) { - return null; - } - - return ( - setSelectedComponent(null)} - componentTemplateName={selectedComponent} - /> - ); - }; - if (isLoading) { return renderLoading(); } else if (error) { return renderError(); } else if (hasComponents) { - return ( - <> - {renderSelector()} - {renderComponentDetails()} - - ); + return renderSelector(); } // No components: render empty prompt @@ -244,6 +265,7 @@ export const ComponentTemplatesSelector = ({

); + return ( { + const [templatePreview, setTemplatePreview] = useState('{}'); + + const updatePreview = useCallback(async () => { + if (!template || Object.keys(template).length === 0) { + return; + } + + const indexTemplate = serializeTemplate(stripEmptyFields(template) as TemplateDeserialized); + + // Until ES fixes a bug on their side we will send a random index pattern to the simulate API. + // Issue: https://github.com/elastic/elasticsearch/issues/59152 + indexTemplate.index_patterns = [uuid.v4()]; + + const { data, error } = await simulateIndexTemplate(indexTemplate); + + if (data) { + // "Overlapping" info is only useful when simulating against an index + // which we don't do here. + delete data.overlapping; + } + + setTemplatePreview(JSON.stringify(data ?? error, null, 2)); + }, [template]); + + useEffect(() => { + updatePreview(); + }, [updatePreview]); + + return templatePreview === '{}' ? null : ( + + {templatePreview} + + ); +}); diff --git a/x-pack/plugins/index_management/public/application/components/index_templates/simulate_template/simulate_template_flyout.tsx b/x-pack/plugins/index_management/public/application/components/index_templates/simulate_template/simulate_template_flyout.tsx new file mode 100644 index 000000000000..63bfe7854604 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/index_templates/simulate_template/simulate_template_flyout.tsx @@ -0,0 +1,119 @@ +/* + * 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, useEffect, useRef } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiFlyoutHeader, + EuiTitle, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlexGroup, + EuiFlexItem, + EuiButton, + EuiButtonEmpty, + EuiTextColor, + EuiText, + EuiSpacer, +} from '@elastic/eui'; + +import { SimulateTemplate } from './simulate_template'; + +export interface Props { + onClose(): void; + getTemplate: () => { [key: string]: any }; +} + +export const defaultFlyoutProps = { + 'data-test-subj': 'simulateTemplateFlyout', + 'aria-labelledby': 'simulateTemplateFlyoutTitle', +}; + +export const SimulateTemplateFlyoutContent = ({ onClose, getTemplate }: Props) => { + const isMounted = useRef(false); + const [heightCodeBlock, setHeightCodeBlock] = useState(0); + const [template, setTemplate] = useState<{ [key: string]: any }>({}); + + useEffect(() => { + setHeightCodeBlock( + document.getElementsByClassName('euiFlyoutBody__overflow')[0].clientHeight - 96 + ); + }, []); + + const updatePreview = useCallback(async () => { + const indexTemplate = await getTemplate(); + setTemplate(indexTemplate); + }, [getTemplate]); + + useEffect(() => { + if (isMounted.current === false) { + updatePreview(); + } + isMounted.current = true; + }, [updatePreview]); + + return ( + <> + + +

+ +

+
+ + + +

+ +

+
+
+
+ + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/shape_datatype.test.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/shape_datatype.test.tsx index 311cb37d0b47..64347d19e9b4 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/shape_datatype.test.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/shape_datatype.test.tsx @@ -36,8 +36,6 @@ describe('Mappings editor: shape datatype', () => { test('initial view and default parameters values', async () => { const defaultMappings = { - _meta: {}, - _source: {}, properties: { myField: { type: 'shape', diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/text_datatype.test.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/text_datatype.test.tsx index ed60414d198f..c03aa4805d27 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/text_datatype.test.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/datatypes/text_datatype.test.tsx @@ -47,8 +47,6 @@ describe.skip('Mappings editor: text datatype', () => { test('initial view and default parameters values', async () => { const defaultMappings = { - _meta: {}, - _source: {}, properties: { myField: { type: 'text', diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/edit_field.test.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/edit_field.test.tsx index 4f9d8a960a1a..c146c7704911 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/edit_field.test.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/edit_field.test.tsx @@ -65,8 +65,6 @@ describe('Mappings editor: edit field', () => { test('should update form parameters when changing the field datatype', async () => { const defaultMappings = { - _meta: {}, - _source: {}, properties: { userName: { ...defaultTextParameters, diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/mappings_editor.helpers.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/mappings_editor.helpers.tsx index 638bbfd925ff..a6558b28a127 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/mappings_editor.helpers.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/helpers/mappings_editor.helpers.tsx @@ -7,9 +7,11 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import { ReactWrapper } from 'enzyme'; +import { GlobalFlyout } from '../../../../../../../../../../src/plugins/es_ui_shared/public'; import { registerTestBed, TestBed } from '../../../../../../../../../test_utils'; import { getChildFieldsName } from '../../../lib'; import { MappingsEditor } from '../../../mappings_editor'; +import { MappingsEditorProvider } from '../../../mappings_editor_context'; jest.mock('@elastic/eui', () => { const original = jest.requireActual('@elastic/eui'); @@ -51,6 +53,8 @@ jest.mock('@elastic/eui', () => { }; }); +const { GlobalFlyoutProvider } = GlobalFlyout; + export interface DomFields { [key: string]: { type: string; @@ -247,7 +251,15 @@ const createActions = (testBed: TestBed) => { }; export const setup = (props: any = { onUpdate() {} }): MappingsEditorTestBed => { - const setupTestBed = registerTestBed(MappingsEditor, { + const ComponentToTest = (propsOverride: { [key: string]: any }) => ( + + + + + + ); + + const setupTestBed = registerTestBed(ComponentToTest, { memoryRouter: { wrapComponent: false, }, diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form.tsx index 86bcc796a88e..20b2e1185502 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form.tsx @@ -7,16 +7,14 @@ import React, { useEffect, useRef } from 'react'; import { EuiSpacer } from '@elastic/eui'; import { useForm, Form, SerializerFunc } from '../../shared_imports'; -import { GenericObject } from '../../types'; -import { Types, useDispatch } from '../../mappings_state'; +import { GenericObject, MappingsConfiguration } from '../../types'; +import { useDispatch } from '../../mappings_state_context'; import { DynamicMappingSection } from './dynamic_mapping_section'; import { SourceFieldSection } from './source_field_section'; import { MetaFieldSection } from './meta_field_section'; import { RoutingSection } from './routing_section'; import { configurationFormSchema } from './configuration_form_schema'; -type MappingsConfiguration = Types['MappingsConfiguration']; - interface Props { value?: MappingsConfiguration; } diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form_schema.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form_schema.tsx index 6e80f8b813ec..8742dfc91692 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form_schema.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/configuration_form/configuration_form_schema.tsx @@ -11,8 +11,7 @@ import { EuiLink, EuiCode } from '@elastic/eui'; import { documentationService } from '../../../../services/documentation'; import { FormSchema, FIELD_TYPES, VALIDATION_TYPES, fieldValidators } from '../../shared_imports'; -import { MappingsConfiguration } from '../../reducer'; -import { ComboBoxOption } from '../../types'; +import { ComboBoxOption, MappingsConfiguration } from '../../types'; const { containsCharsField, isJsonField } = fieldValidators; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/document_fields.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/document_fields.tsx index 400de4052afa..4b19b6f7ae5c 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/document_fields.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/document_fields.tsx @@ -6,7 +6,7 @@ import React, { useMemo, useCallback } from 'react'; import { EuiSpacer } from '@elastic/eui'; -import { useMappingsState, useDispatch } from '../../mappings_state'; +import { useMappingsState, useDispatch } from '../../mappings_state_context'; import { deNormalize } from '../../lib'; import { EditFieldContainer } from './fields'; import { DocumentFieldsHeader } from './document_fields_header'; @@ -18,7 +18,7 @@ export const DocumentFields = React.memo(() => { const { fields, search, documentFields } = useMappingsState(); const dispatch = useDispatch(); - const { status, fieldToEdit, editor: editorType } = documentFields; + const { editor: editorType } = documentFields; const jsonEditorDefaultValue = useMemo(() => { if (editorType === 'json') { @@ -33,14 +33,6 @@ export const DocumentFields = React.memo(() => { ); - const renderEditField = () => { - if (status !== 'editingField') { - return null; - } - const field = fields.byId[fieldToEdit!]; - return ; - }; - const onSearchChange = useCallback( (value: string) => { dispatch({ type: 'search:update', value }); @@ -59,7 +51,7 @@ export const DocumentFields = React.memo(() => { ) : ( editor )} - {renderEditField()} +
); }); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/editor_toggle_controls.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/editor_toggle_controls.tsx index 51f9ca63be40..ad283a3fe47b 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/editor_toggle_controls.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/editor_toggle_controls.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { EuiButton, EuiText } from '@elastic/eui'; -import { useDispatch, useMappingsState } from '../../mappings_state'; +import { useDispatch, useMappingsState } from '../../mappings_state_context'; import { FieldsEditor } from '../../types'; import { canUseMappingsEditor, normalize } from '../../lib'; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/name_parameter.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/name_parameter.tsx index 01cca7e249a2..0320f2ff51da 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/name_parameter.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/name_parameter.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { TextField, UseField, FieldConfig } from '../../../shared_imports'; import { validateUniqueName } from '../../../lib'; import { PARAMETERS_DEFINITION } from '../../../constants'; -import { useMappingsState } from '../../../mappings_state'; +import { useMappingsState } from '../../../mappings_state_context'; export const NameParameter = () => { const { diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/type_parameter.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/type_parameter.tsx index 46e70bf8e56b..31ae37c82a43 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/type_parameter.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/field_parameters/type_parameter.tsx @@ -70,7 +70,13 @@ export const TypeParameter = ({ isMultiField, isRootLevelField, showDocLink = fa : filterTypesForNonRootFields(FIELD_TYPES_OPTIONS) } selectedOptions={typeField.value} - onChange={typeField.setValue} + onChange={(value) => { + if (value.length === 0) { + // Don't allow clearing the type. One must always be selected + return; + } + typeField.setValue(value); + }} isClearable={false} data-test-subj="fieldType" /> diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/create_field/create_field.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/create_field/create_field.tsx index 57a765c38dd2..dc631b7dbf32 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/create_field/create_field.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/create_field/create_field.tsx @@ -18,7 +18,7 @@ import { import { useForm, Form, FormDataProvider } from '../../../../shared_imports'; import { EUI_SIZE } from '../../../../constants'; -import { useDispatch } from '../../../../mappings_state'; +import { useDispatch } from '../../../../mappings_state_context'; import { fieldSerializer } from '../../../../lib'; import { Field, NormalizedFields } from '../../../../types'; import { NameParameter, TypeParameter, SubTypeParameter } from '../../field_parameters'; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/delete_field_provider.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/delete_field_provider.tsx index 80e3e9bec605..2a98b5948e5a 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/delete_field_provider.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/delete_field_provider.tsx @@ -7,7 +7,7 @@ import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; -import { useMappingsState, useDispatch } from '../../../mappings_state'; +import { useMappingsState, useDispatch } from '../../../mappings_state_context'; import { NormalizedField } from '../../../types'; import { getAllDescendantAliases } from '../../../lib'; import { ModalConfirmationDeleteFields } from './modal_confirmation_delete_fields'; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field.tsx index e8e41955a5e8..e6950ccfe253 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field.tsx @@ -6,7 +6,6 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { - EuiFlyout, EuiFlyoutHeader, EuiFlyoutBody, EuiFlyoutFooter, @@ -25,7 +24,7 @@ import { TYPE_DEFINITION } from '../../../../constants'; import { Field, NormalizedField, NormalizedFields, MainType, SubType } from '../../../../types'; import { CodeBlock } from '../../../code_block'; import { getParametersFormForType } from '../field_types'; -import { UpdateFieldProvider, UpdateFieldFunc } from './update_field_provider'; +import { UpdateFieldFunc } from './use_update_field'; import { EditFieldHeaderForm } from './edit_field_header_form'; const limitStringLength = (text: string, limit = 18): string => { @@ -36,19 +35,28 @@ const limitStringLength = (text: string, limit = 18): string => { return `...${text.substr(limit * -1)}`; }; -interface Props { +export interface Props { form: FormHook; field: NormalizedField; allFields: NormalizedFields['byId']; exitEdit(): void; + updateField: UpdateFieldFunc; } -export const EditField = React.memo(({ form, field, allFields, exitEdit }: Props) => { - const getSubmitForm = (updateField: UpdateFieldFunc) => async (e?: React.FormEvent) => { - if (e) { - e.preventDefault(); - } +export const defaultFlyoutProps = { + 'data-test-subj': 'mappingsEditorFieldEdit', + 'aria-labelledby': 'mappingsEditorFieldEditTitle', + className: 'mappingsEditor__editField', + maxWidth: 720, +}; + +// The default FormWrapper is the , which wrapps the form with +// a
. We can't have a div as first child of the Flyout as it breaks +// the height calculaction and does not render the footer position correctly. +const FormWrapper: React.FC = ({ children }) => <>{children}; +export const EditField = React.memo(({ form, field, allFields, exitEdit, updateField }: Props) => { + const submitForm = async () => { const { isValid, data } = await form.submit(); if (isValid) { @@ -56,174 +64,152 @@ export const EditField = React.memo(({ form, field, allFields, exitEdit }: Props } }; - const cancel = () => { - exitEdit(); - }; - const { isMultiField } = field; return ( - - {(updateField) => ( -
- - - - - {/* We need an extra div to get out of flex grow */} -
- {/* Title */} - -

- {isMultiField - ? i18n.translate('xpack.idxMgmt.mappingsEditor.editMultiFieldTitle', { - defaultMessage: "Edit multi-field '{fieldName}'", - values: { - fieldName: limitStringLength(field.source.name), - }, - }) - : i18n.translate('xpack.idxMgmt.mappingsEditor.editFieldTitle', { - defaultMessage: "Edit field '{fieldName}'", - values: { - fieldName: limitStringLength(field.source.name), - }, - })} -

-
-
-
- - {/* Documentation link */} - - {({ type, subType }) => { - const linkDocumentation = - documentationService.getTypeDocLink(subType) || - documentationService.getTypeDocLink(type); - - if (!linkDocumentation) { - return null; - } - - const typeDefinition = TYPE_DEFINITION[type as MainType]; - const subTypeDefinition = TYPE_DEFINITION[subType as SubType]; - - return ( - - - {i18n.translate( - 'xpack.idxMgmt.mappingsEditor.editField.typeDocumentation', - { - defaultMessage: '{type} documentation', - values: { - type: subTypeDefinition - ? subTypeDefinition.label - : typeDefinition.label, - }, - } - )} - - - ); - }} - -
- - {/* Field path */} - - - {field.path.join(' > ')} - - -
- - - - - - {({ type, subType }) => { - const ParametersForm = getParametersFormForType(type, subType); - - if (!ParametersForm) { - return null; - } - - return ( - - ); - }} - - - - - {form.isSubmitted && !form.isValid && ( - <> - - - - )} - - + + + + + {/* We need an extra div to get out of flex grow */} +
+ {/* Title */} + +

+ {isMultiField + ? i18n.translate('xpack.idxMgmt.mappingsEditor.editMultiFieldTitle', { + defaultMessage: "Edit multi-field '{fieldName}'", + values: { + fieldName: limitStringLength(field.source.name), + }, + }) + : i18n.translate('xpack.idxMgmt.mappingsEditor.editFieldTitle', { + defaultMessage: "Edit field '{fieldName}'", + values: { + fieldName: limitStringLength(field.source.name), + }, + })} +

+
+
+
+ + {/* Documentation link */} + + {({ type, subType }) => { + const linkDocumentation = + documentationService.getTypeDocLink(subType) || + documentationService.getTypeDocLink(type); + + if (!linkDocumentation) { + return null; + } + + const typeDefinition = TYPE_DEFINITION[type as MainType]; + const subTypeDefinition = TYPE_DEFINITION[subType as SubType]; + + return ( - - {i18n.translate('xpack.idxMgmt.mappingsEditor.editFieldCancelButtonLabel', { - defaultMessage: 'Cancel', - })} - - - - - {i18n.translate('xpack.idxMgmt.mappingsEditor.editFieldUpdateButtonLabel', { - defaultMessage: 'Update', + {i18n.translate('xpack.idxMgmt.mappingsEditor.editField.typeDocumentation', { + defaultMessage: '{type} documentation', + values: { + type: subTypeDefinition ? subTypeDefinition.label : typeDefinition.label, + }, })} - + -
-
-
-
- )} -
+ ); + }} + + + + {/* Field path */} + + + {field.path.join(' > ')} + + + + + + + + + {({ type, subType }) => { + const ParametersForm = getParametersFormForType(type, subType); + + if (!ParametersForm) { + return null; + } + + return ( + + ); + }} + + + + + {form.isSubmitted && !form.isValid && ( + <> + + + + )} + + + + + {i18n.translate('xpack.idxMgmt.mappingsEditor.editFieldCancelButtonLabel', { + defaultMessage: 'Cancel', + })} + + + + + {i18n.translate('xpack.idxMgmt.mappingsEditor.editFieldUpdateButtonLabel', { + defaultMessage: 'Update', + })} + + + + + ); }); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field_container.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field_container.tsx index 5105a2a157a6..4996f59105c0 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field_container.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/edit_field_container.tsx @@ -3,24 +3,38 @@ * 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, { useEffect, useCallback } from 'react'; +import React, { useEffect, useCallback, useMemo } from 'react'; -import { useForm } from '../../../../shared_imports'; -import { useDispatch } from '../../../../mappings_state'; -import { Field, NormalizedField, NormalizedFields } from '../../../../types'; +import { useForm, GlobalFlyout } from '../../../../shared_imports'; +import { useDispatch, useMappingsState } from '../../../../mappings_state_context'; +import { Field } from '../../../../types'; import { fieldSerializer, fieldDeserializer } from '../../../../lib'; -import { EditField } from './edit_field'; +import { ModalConfirmationDeleteFields } from '../modal_confirmation_delete_fields'; +import { EditField, defaultFlyoutProps, Props as EditFieldProps } from './edit_field'; +import { useUpdateField } from './use_update_field'; -interface Props { - field: NormalizedField; - allFields: NormalizedFields['byId']; -} +const { useGlobalFlyout } = GlobalFlyout; -export const EditFieldContainer = React.memo(({ field, allFields }: Props) => { +export const EditFieldContainer = React.memo(() => { + const { fields, documentFields } = useMappingsState(); const dispatch = useDispatch(); + const { + addContent: addContentToGlobalFlyout, + removeContent: removeContentFromGlobalFlyout, + } = useGlobalFlyout(); + const { updateField, modal } = useUpdateField(); + + const { status, fieldToEdit } = documentFields; + const isEditing = status === 'editingField'; + + const field = isEditing ? fields.byId[fieldToEdit!] : undefined; + + const formDefaultValue = useMemo(() => { + return { ...field?.source }; + }, [field?.source]); const { form } = useForm({ - defaultValue: { ...field.source }, + defaultValue: formDefaultValue, serializer: fieldSerializer, deserializer: fieldDeserializer, options: { stripEmptyFields: false }, @@ -40,5 +54,48 @@ export const EditFieldContainer = React.memo(({ field, allFields }: Props) => { dispatch({ type: 'documentField.changeStatus', value: 'idle' }); }, [dispatch]); - return ; + useEffect(() => { + if (isEditing) { + // Open the flyout with the content + addContentToGlobalFlyout({ + id: 'mappingsEditField', + Component: EditField, + props: { + form, + field: field!, + exitEdit, + allFields: fields.byId, + updateField, + }, + flyoutProps: { ...defaultFlyoutProps, onClose: exitEdit }, + cleanUpFunc: exitEdit, + }); + } + }, [ + isEditing, + field, + form, + addContentToGlobalFlyout, + fields.byId, + fieldToEdit, + exitEdit, + updateField, + ]); + + useEffect(() => { + if (!isEditing) { + removeContentFromGlobalFlyout('mappingsEditField'); + } + }, [isEditing, removeContentFromGlobalFlyout]); + + useEffect(() => { + return () => { + if (isEditing) { + // When the component unmounts, exit edit mode. + exitEdit(); + } + }; + }, [isEditing, exitEdit]); + + return modal.isOpen ? : null; }); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/update_field_provider.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/update_field_provider.tsx deleted file mode 100644 index e31d12689e7e..000000000000 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/update_field_provider.tsx +++ /dev/null @@ -1,147 +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 React, { useState } from 'react'; -import { i18n } from '@kbn/i18n'; - -import { useMappingsState, useDispatch } from '../../../../mappings_state'; -import { shouldDeleteChildFieldsAfterTypeChange, getAllDescendantAliases } from '../../../../lib'; -import { NormalizedField, DataType } from '../../../../types'; -import { PARAMETERS_DEFINITION } from '../../../../constants'; -import { ModalConfirmationDeleteFields } from '../modal_confirmation_delete_fields'; - -export type UpdateFieldFunc = (field: NormalizedField) => void; - -interface Props { - children: (saveProperty: UpdateFieldFunc) => React.ReactNode; -} - -interface State { - isModalOpen: boolean; - field?: NormalizedField; - aliases?: string[]; -} - -export const UpdateFieldProvider = ({ children }: Props) => { - const [state, setState] = useState({ - isModalOpen: false, - }); - const dispatch = useDispatch(); - - const { fields } = useMappingsState(); - const { byId, aliases } = fields; - - const confirmButtonText = i18n.translate( - 'xpack.idxMgmt.mappingsEditor.updateField.confirmationModal.confirmDescription', - { - defaultMessage: 'Confirm type change', - } - ); - - let modalTitle: string | undefined; - - if (state.field) { - const { source } = state.field; - - modalTitle = i18n.translate( - 'xpack.idxMgmt.mappingsEditor.updateField.confirmationModal.title', - { - defaultMessage: "Confirm change '{fieldName}' type to '{fieldType}'.", - values: { - fieldName: source.name, - fieldType: source.type, - }, - } - ); - } - - const closeModal = () => { - setState({ isModalOpen: false }); - }; - - const updateField: UpdateFieldFunc = (field) => { - const previousField = byId[field.id]; - - const willDeleteChildFields = (oldType: DataType, newType: DataType): boolean => { - const { hasChildFields, hasMultiFields } = field; - - if (!hasChildFields && !hasMultiFields) { - // No child or multi-fields will be deleted, no confirmation needed. - return false; - } - - return shouldDeleteChildFieldsAfterTypeChange(oldType, newType); - }; - - if (field.source.type !== previousField.source.type) { - // Array of all the aliases pointing to the current field beeing updated - const aliasesOnField = aliases[field.id] || []; - - // Array of all the aliases pointing to the current field + all its possible children - const aliasesOnFieldAndDescendants = getAllDescendantAliases(field, fields); - - const isReferencedByAlias = aliasesOnField && Boolean(aliasesOnField.length); - const nextTypeCanHaveAlias = !PARAMETERS_DEFINITION.path.targetTypesNotAllowed.includes( - field.source.type - ); - - // We need to check if, by changing the type, we will also - // delete possible child properties ("fields" or "properties"). - // If we will, we need to warn the user about it. - let requiresConfirmation: boolean; - let aliasesToDelete: string[] = []; - - if (isReferencedByAlias && !nextTypeCanHaveAlias) { - aliasesToDelete = aliasesOnFieldAndDescendants; - requiresConfirmation = true; - } else { - requiresConfirmation = willDeleteChildFields(previousField.source.type, field.source.type); - if (requiresConfirmation) { - aliasesToDelete = aliasesOnFieldAndDescendants.filter( - // We will only delete aliases that points to possible children, *NOT* the field itself - (id) => aliasesOnField.includes(id) === false - ); - } - } - - if (requiresConfirmation) { - setState({ - isModalOpen: true, - field, - aliases: Boolean(aliasesToDelete.length) - ? aliasesToDelete.map((id) => byId[id].path.join(' > ')).sort() - : undefined, - }); - return; - } - } - - dispatch({ type: 'field.edit', value: field.source }); - }; - - const confirmTypeUpdate = () => { - dispatch({ type: 'field.edit', value: state.field!.source }); - closeModal(); - }; - - return ( - <> - {children(updateField)} - - {state.isModalOpen && ( - - )} - - ); -}; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/use_update_field.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/use_update_field.ts new file mode 100644 index 000000000000..ed659cd05b06 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/edit_field/use_update_field.ts @@ -0,0 +1,146 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useState, useCallback } from 'react'; +import { i18n } from '@kbn/i18n'; + +import { useMappingsState, useDispatch } from '../../../../mappings_state_context'; +import { shouldDeleteChildFieldsAfterTypeChange, getAllDescendantAliases } from '../../../../lib'; +import { NormalizedField, DataType } from '../../../../types'; +import { PARAMETERS_DEFINITION } from '../../../../constants'; + +export type UpdateFieldFunc = (field: NormalizedField) => void; + +interface State { + isModalOpen: boolean; + field?: NormalizedField; + aliases?: string[]; +} + +export const useUpdateField = () => { + const [state, setState] = useState({ + isModalOpen: false, + }); + const dispatch = useDispatch(); + + const { fields } = useMappingsState(); + const { byId, aliases } = fields; + + const confirmButtonText = i18n.translate( + 'xpack.idxMgmt.mappingsEditor.updateField.confirmationModal.confirmDescription', + { + defaultMessage: 'Confirm type change', + } + ); + + let modalTitle = ''; + + if (state.field) { + const { source } = state.field; + + modalTitle = i18n.translate( + 'xpack.idxMgmt.mappingsEditor.updateField.confirmationModal.title', + { + defaultMessage: "Confirm change '{fieldName}' type to '{fieldType}'.", + values: { + fieldName: source.name, + fieldType: source.type, + }, + } + ); + } + + const closeModal = () => { + setState({ isModalOpen: false }); + }; + + const updateField: UpdateFieldFunc = useCallback( + (field) => { + const previousField = byId[field.id]; + + const willDeleteChildFields = (oldType: DataType, newType: DataType): boolean => { + const { hasChildFields, hasMultiFields } = field; + + if (!hasChildFields && !hasMultiFields) { + // No child or multi-fields will be deleted, no confirmation needed. + return false; + } + + return shouldDeleteChildFieldsAfterTypeChange(oldType, newType); + }; + + if (field.source.type !== previousField.source.type) { + // Array of all the aliases pointing to the current field beeing updated + const aliasesOnField = aliases[field.id] || []; + + // Array of all the aliases pointing to the current field + all its possible children + const aliasesOnFieldAndDescendants = getAllDescendantAliases(field, fields); + + const isReferencedByAlias = aliasesOnField && Boolean(aliasesOnField.length); + const nextTypeCanHaveAlias = !PARAMETERS_DEFINITION.path.targetTypesNotAllowed.includes( + field.source.type + ); + + // We need to check if, by changing the type, we will also + // delete possible child properties ("fields" or "properties"). + // If we will, we need to warn the user about it. + let requiresConfirmation: boolean; + let aliasesToDelete: string[] = []; + + if (isReferencedByAlias && !nextTypeCanHaveAlias) { + aliasesToDelete = aliasesOnFieldAndDescendants; + requiresConfirmation = true; + } else { + requiresConfirmation = willDeleteChildFields( + previousField.source.type, + field.source.type + ); + if (requiresConfirmation) { + aliasesToDelete = aliasesOnFieldAndDescendants.filter( + // We will only delete aliases that points to possible children, *NOT* the field itself + (id) => aliasesOnField.includes(id) === false + ); + } + } + + if (requiresConfirmation) { + setState({ + isModalOpen: true, + field, + aliases: Boolean(aliasesToDelete.length) + ? aliasesToDelete.map((id) => byId[id].path.join(' > ')).sort() + : undefined, + }); + return; + } + } + + dispatch({ type: 'field.edit', value: field.source }); + }, + [dispatch, aliases, fields, byId] + ); + + const confirmTypeUpdate = () => { + dispatch({ type: 'field.edit', value: state.field!.source }); + closeModal(); + }; + + return { + updateField, + modal: { + isOpen: state.isModalOpen, + props: { + childFields: state.field && state.field.childFields, + title: modalTitle, + aliases: state.aliases, + byId, + confirmButtonText, + onConfirm: confirmTypeUpdate, + onCancel: closeModal, + }, + }, + }; +}; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/fields_list_item_container.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/fields_list_item_container.tsx index 55093e606cfa..7d9ad3bc6aae 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/fields_list_item_container.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields/fields_list_item_container.tsx @@ -5,7 +5,7 @@ */ import React, { useMemo, useCallback, useRef } from 'react'; -import { useMappingsState, useDispatch } from '../../../mappings_state'; +import { useMappingsState, useDispatch } from '../../../mappings_state_context'; import { NormalizedField } from '../../../types'; import { FieldsListItem } from './fields_list_item'; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields_json_editor.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields_json_editor.tsx index 5954f6f285f1..d750c0e604c5 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields_json_editor.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields_json_editor.tsx @@ -6,7 +6,7 @@ import React, { useRef, useCallback } from 'react'; -import { useDispatch } from '../../mappings_state'; +import { useDispatch } from '../../mappings_state_context'; import { JsonEditor } from '../../shared_imports'; export interface Props { diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields_tree_editor.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields_tree_editor.tsx index 9d9df38ef4e2..7a0b72ae647d 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields_tree_editor.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/fields_tree_editor.tsx @@ -8,7 +8,7 @@ import React, { useMemo, useCallback } from 'react'; import { EuiButtonEmpty, EuiSpacer } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { useMappingsState, useDispatch } from '../../mappings_state'; +import { useMappingsState, useDispatch } from '../../mappings_state_context'; import { FieldsList, CreateField } from './fields'; export const DocumentFieldsTreeEditor = () => { diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/search_fields/search_result.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/search_fields/search_result.tsx index 9077781b7fb4..f3602a800eee 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/search_fields/search_result.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/search_fields/search_result.tsx @@ -8,9 +8,8 @@ import VirtualList from 'react-tiny-virtual-list'; import { EuiEmptyPrompt, EuiButton } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { SearchResult as SearchResultType } from '../../../types'; -import { useDispatch } from '../../../mappings_state'; -import { State } from '../../../reducer'; +import { SearchResult as SearchResultType, State } from '../../../types'; +import { useDispatch } from '../../../mappings_state_context'; import { SearchResultItem } from './search_result_item'; interface Props { diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/search_fields/search_result_item.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/search_fields/search_result_item.tsx index ab8b90b6be3b..73d3e078f6ff 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/search_fields/search_result_item.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/document_fields/search_fields/search_result_item.tsx @@ -10,7 +10,7 @@ import { i18n } from '@kbn/i18n'; import { SearchResult } from '../../../types'; import { TYPE_DEFINITION } from '../../../constants'; -import { useDispatch } from '../../../mappings_state'; +import { useDispatch } from '../../../mappings_state_context'; import { getTypeLabelFromType } from '../../../lib'; import { DeleteFieldProvider } from '../fields/delete_field_provider'; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/load_mappings/index.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/load_mappings/index.ts index 34c410f06e52..dc7f20f4d026 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/load_mappings/index.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/load_mappings/index.ts @@ -4,5 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './load_from_json_button'; -export * from './load_mappings_provider'; +export { LoadMappingsFromJsonButton } from './load_from_json_button'; +export { LoadMappingsProvider } from './load_mappings_provider'; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/templates_form/templates_form.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/templates_form/templates_form.tsx index a95579a8a141..44a809a7a01b 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/templates_form/templates_form.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/templates_form/templates_form.tsx @@ -9,12 +9,11 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { EuiText, EuiLink, EuiSpacer } from '@elastic/eui'; import { useForm, Form, SerializerFunc, UseField, JsonEditorField } from '../../shared_imports'; -import { Types, useDispatch } from '../../mappings_state'; +import { MappingsTemplates } from '../../types'; +import { useDispatch } from '../../mappings_state_context'; import { templatesFormSchema } from './templates_form_schema'; import { documentationService } from '../../../../services/documentation'; -type MappingsTemplates = Types['MappingsTemplates']; - interface Props { value?: MappingsTemplates; } diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/templates_form/templates_form_schema.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/templates_form/templates_form_schema.ts index 667b5685723d..daca85f95b0b 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/components/templates_form/templates_form_schema.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/components/templates_form/templates_form_schema.ts @@ -7,7 +7,7 @@ import { i18n } from '@kbn/i18n'; import { FormSchema, fieldValidators } from '../../shared_imports'; -import { MappingsTemplates } from '../../reducer'; +import { MappingsTemplates } from '../../types'; const { isJsonField } = fieldValidators; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/index.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/index.ts index 29cfaf99c655..00bb41663dd9 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/index.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/index.ts @@ -4,12 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './mappings_editor'; +export { MappingsEditor } from './mappings_editor'; // We export both the button & the load mappings provider // to give flexibility to the consumer -export * from './components/load_mappings'; +export { LoadMappingsFromJsonButton, LoadMappingsProvider } from './components/load_mappings'; -export { OnUpdateHandler, Types } from './mappings_state'; +export { MappingsEditorProvider } from './mappings_editor_context'; -export { IndexSettings } from './types'; +export { IndexSettings, OnUpdateHandler } from './types'; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/index_settings_context.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/index_settings_context.tsx index 9e3637f97029..411193f10b24 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/index_settings_context.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/index_settings_context.tsx @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import React, { createContext, useContext } from 'react'; + import { IndexSettings } from './types'; const IndexSettingsContext = createContext(undefined); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_editor.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_editor.tsx index e8fda9073770..292882f1c5b4 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_editor.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_editor.tsx @@ -14,24 +14,40 @@ import { TemplatesForm, MultipleMappingsWarning, } from './components'; -import { IndexSettings } from './types'; +import { + OnUpdateHandler, + IndexSettings, + Field, + Mappings, + MappingsConfiguration, + MappingsTemplates, +} from './types'; import { extractMappingsDefinition } from './lib'; -import { State } from './reducer'; -import { MappingsState, Props as MappingsStateProps, Types } from './mappings_state'; +import { useMappingsState } from './mappings_state_context'; +import { useMappingsStateListener } from './use_state_listener'; import { IndexSettingsProvider } from './index_settings_context'; +type TabName = 'fields' | 'advanced' | 'templates'; + +interface MappingsEditorParsedMetadata { + parsedDefaultValue?: { + configuration: MappingsConfiguration; + fields: { [key: string]: Field }; + templates: MappingsTemplates; + }; + multipleMappingsDeclared: boolean; +} + interface Props { - onChange: MappingsStateProps['onChange']; + onChange: OnUpdateHandler; value?: { [key: string]: any }; indexSettings?: IndexSettings; } -type TabName = 'fields' | 'advanced' | 'templates'; - export const MappingsEditor = React.memo(({ onChange, value, indexSettings }: Props) => { - const [selectedTab, selectTab] = useState('fields'); - - const { parsedDefaultValue, multipleMappingsDeclared } = useMemo(() => { + const { parsedDefaultValue, multipleMappingsDeclared } = useMemo< + MappingsEditorParsedMetadata + >(() => { const mappingsDefinition = extractMappingsDefinition(value); if (mappingsDefinition === null) { @@ -69,18 +85,28 @@ export const MappingsEditor = React.memo(({ onChange, value, indexSettings }: Pr return { parsedDefaultValue: parsed, multipleMappingsDeclared: false }; }, [value]); + /** + * Hook that will listen to: + * 1. "value" prop changes in order to reset the mappings editor + * 2. "state" changes in order to communicate any updates to the consumer + */ + useMappingsStateListener({ onChange, value: parsedDefaultValue }); + + const state = useMappingsState(); + const [selectedTab, selectTab] = useState('fields'); + useEffect(() => { if (multipleMappingsDeclared) { // We set the data getter here as the user won't be able to make any changes onChange({ - getData: () => value! as Types['Mappings'], + getData: () => value! as Mappings, validate: () => Promise.resolve(true), isValid: true, }); } }, [multipleMappingsDeclared, onChange, value]); - const changeTab = async (tab: TabName, state: State) => { + const changeTab = async (tab: TabName) => { if (selectedTab === 'advanced') { // When we navigate away we need to submit the form to validate if there are any errors. const { isValid: isConfigurationFormValid } = await state.configuration.submitForm!(); @@ -102,59 +128,53 @@ export const MappingsEditor = React.memo(({ onChange, value, indexSettings }: Pr selectTab(tab); }; + const tabToContentMap = { + fields: , + templates: , + advanced: , + }; + return (
{multipleMappingsDeclared ? ( ) : ( - - {({ state }) => { - const tabToContentMap = { - fields: , - templates: , - advanced: , - }; - - return ( -
- - changeTab('fields', state)} - isSelected={selectedTab === 'fields'} - data-test-subj="formTab" - > - {i18n.translate('xpack.idxMgmt.mappingsEditor.fieldsTabLabel', { - defaultMessage: 'Mapped fields', - })} - - changeTab('templates', state)} - isSelected={selectedTab === 'templates'} - data-test-subj="formTab" - > - {i18n.translate('xpack.idxMgmt.mappingsEditor.templatesTabLabel', { - defaultMessage: 'Dynamic templates', - })} - - changeTab('advanced', state)} - isSelected={selectedTab === 'advanced'} - data-test-subj="formTab" - > - {i18n.translate('xpack.idxMgmt.mappingsEditor.advancedTabLabel', { - defaultMessage: 'Advanced options', - })} - - - - - - {tabToContentMap[selectedTab]} -
- ); - }} -
+
+ + changeTab('fields')} + isSelected={selectedTab === 'fields'} + data-test-subj="formTab" + > + {i18n.translate('xpack.idxMgmt.mappingsEditor.fieldsTabLabel', { + defaultMessage: 'Mapped fields', + })} + + changeTab('templates')} + isSelected={selectedTab === 'templates'} + data-test-subj="formTab" + > + {i18n.translate('xpack.idxMgmt.mappingsEditor.templatesTabLabel', { + defaultMessage: 'Dynamic templates', + })} + + changeTab('advanced')} + isSelected={selectedTab === 'advanced'} + data-test-subj="formTab" + > + {i18n.translate('xpack.idxMgmt.mappingsEditor.advancedTabLabel', { + defaultMessage: 'Advanced options', + })} + + + + + + {tabToContentMap[selectedTab]} +
)}
diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_editor_context.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_editor_context.tsx new file mode 100644 index 000000000000..596b49cc89ee --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_editor_context.tsx @@ -0,0 +1,12 @@ +/* + * 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 from 'react'; + +import { StateProvider } from './mappings_state_context'; + +export const MappingsEditorProvider: React.FC = ({ children }) => { + return {children}; +}; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_state_context.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_state_context.tsx new file mode 100644 index 000000000000..a402dec25005 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_state_context.tsx @@ -0,0 +1,77 @@ +/* + * 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, { useReducer, createContext, useContext } from 'react'; + +import { reducer } from './reducer'; +import { State, Dispatch } from './types'; + +const StateContext = createContext(undefined); +const DispatchContext = createContext(undefined); + +export const StateProvider: React.FC = ({ children }) => { + const initialState: State = { + isValid: true, + configuration: { + defaultValue: {}, + data: { + raw: {}, + format: () => ({}), + }, + validate: () => Promise.resolve(true), + }, + templates: { + defaultValue: {}, + data: { + raw: {}, + format: () => ({}), + }, + validate: () => Promise.resolve(true), + }, + fields: { + byId: {}, + rootLevelFields: [], + aliases: {}, + maxNestedDepth: 0, + }, + documentFields: { + status: 'idle', + editor: 'default', + }, + fieldsJsonEditor: { + format: () => ({}), + isValid: true, + }, + search: { + term: '', + result: [], + }, + }; + + const [state, dispatch] = useReducer(reducer, initialState); + + return ( + + {children} + + ); +}; + +export const useMappingsState = () => { + const ctx = useContext(StateContext); + if (ctx === undefined) { + throw new Error('useMappingsState must be used within a '); + } + return ctx; +}; + +export const useDispatch = () => { + const ctx = useContext(DispatchContext); + if (ctx === undefined) { + throw new Error('useDispatch must be used within a '); + } + return ctx; +}; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/reducer.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/reducer.ts index 27f8b1249300..18a8270117ea 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/reducer.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/reducer.ts @@ -3,8 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { OnFormUpdateArg, FormHook } from './shared_imports'; -import { Field, NormalizedFields, NormalizedField, FieldsEditor, SearchResult } from './types'; +import { Field, NormalizedFields, NormalizedField, State, Action } from './types'; import { getFieldMeta, getUniqueId, @@ -17,99 +16,6 @@ import { } from './lib'; import { PARAMETERS_DEFINITION } from './constants'; -export interface MappingsConfiguration { - enabled?: boolean; - throwErrorsForUnmappedFields?: boolean; - date_detection: boolean; - numeric_detection: boolean; - dynamic_date_formats: string[]; - _source: { - enabled?: boolean; - includes?: string[]; - excludes?: string[]; - }; - _meta?: string; -} - -export interface MappingsTemplates { - dynamic_templates: DynamicTemplate[]; -} - -interface DynamicTemplate { - [key: string]: { - mapping: { - [key: string]: any; - }; - match_mapping_type?: string; - match?: string; - unmatch?: string; - match_pattern?: string; - path_match?: string; - path_unmatch?: string; - }; -} - -export interface MappingsFields { - [key: string]: any; -} - -type DocumentFieldsStatus = 'idle' | 'editingField' | 'creatingField'; - -interface DocumentFieldsState { - status: DocumentFieldsStatus; - editor: FieldsEditor; - fieldToEdit?: string; - fieldToAddFieldTo?: string; -} - -interface ConfigurationFormState extends OnFormUpdateArg { - defaultValue: MappingsConfiguration; - submitForm?: FormHook['submit']; -} - -interface TemplatesFormState extends OnFormUpdateArg { - defaultValue: MappingsTemplates; - submitForm?: FormHook['submit']; -} - -export interface State { - isValid: boolean | undefined; - configuration: ConfigurationFormState; - documentFields: DocumentFieldsState; - fields: NormalizedFields; - fieldForm?: OnFormUpdateArg; - fieldsJsonEditor: { - format(): MappingsFields; - isValid: boolean; - }; - search: { - term: string; - result: SearchResult[]; - }; - templates: TemplatesFormState; -} - -export type Action = - | { type: 'editor.replaceMappings'; value: { [key: string]: any } } - | { type: 'configuration.update'; value: Partial } - | { type: 'configuration.save'; value: MappingsConfiguration } - | { type: 'templates.update'; value: Partial } - | { type: 'templates.save'; value: MappingsTemplates } - | { type: 'fieldForm.update'; value: OnFormUpdateArg } - | { type: 'field.add'; value: Field } - | { type: 'field.remove'; value: string } - | { type: 'field.edit'; value: Field } - | { type: 'field.toggleExpand'; value: { fieldId: string; isExpanded?: boolean } } - | { type: 'documentField.createField'; value?: string } - | { type: 'documentField.editField'; value: string } - | { type: 'documentField.changeStatus'; value: DocumentFieldsStatus } - | { type: 'documentField.changeEditor'; value: FieldsEditor } - | { type: 'fieldsJsonEditor.update'; value: { json: { [key: string]: any }; isValid: boolean } } - | { type: 'search:update'; value: string } - | { type: 'validity:update'; value: boolean }; - -export type Dispatch = (action: Action) => void; - export const addFieldToState = (field: Field, state: State): State => { const updatedFields = { ...state.fields }; const id = getUniqueId(); @@ -277,7 +183,7 @@ export const reducer = (state: State, action: Action): State => { }, documentFields: { ...state.documentFields, - status: 'idle', + ...action.value.documentFields, fieldToAddFieldTo: undefined, fieldToEdit: undefined, }, diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/shared_imports.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/shared_imports.ts index 2979015c0745..097d03952795 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/shared_imports.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/shared_imports.ts @@ -49,4 +49,5 @@ export { export { JsonEditor, OnJsonEditorUpdateHandler, + GlobalFlyout, } from '../../../../../../../src/plugins/es_ui_shared/public'; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/types.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/types/document_fields.ts similarity index 65% rename from x-pack/plugins/index_management/public/application/components/mappings_editor/types.ts rename to x-pack/plugins/index_management/public/application/components/mappings_editor/types/document_fields.ts index 5b18af68ed55..a9f6d2ea03bd 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/types.ts +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/types/document_fields.ts @@ -3,10 +3,12 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { ReactNode, OptionHTMLAttributes } from 'react'; +import { ReactNode } from 'react'; -import { FieldConfig } from './shared_imports'; -import { PARAMETERS_DEFINITION } from './constants'; +import { GenericObject } from './mappings_editor'; + +import { FieldConfig } from '../shared_imports'; +import { PARAMETERS_DEFINITION } from '../constants'; export interface DataTypeDefinition { label: string; @@ -203,100 +205,7 @@ export interface NormalizedField extends FieldMeta { export type ChildFieldName = 'properties' | 'fields'; -export type FieldsEditor = 'default' | 'json'; - -export type SelectOption = { - value: unknown; - text: T | ReactNode; -} & OptionHTMLAttributes; - -export interface SuperSelectOption { - value: unknown; - inputDisplay?: ReactNode; - dropdownDisplay?: ReactNode; - disabled?: boolean; - 'data-test-subj'?: string; -} - export interface AliasOption { id: string; label: string; } - -export interface IndexSettingsInterface { - analysis?: { - analyzer: { - [key: string]: { - type: string; - tokenizer: string; - char_filter?: string[]; - filter?: string[]; - position_increment_gap?: number; - }; - }; - }; -} - -/** - * When we define the index settings we can skip - * the "index" property and directly add the "analysis". - * ES always returns the settings wrapped under "index". - */ -export type IndexSettings = IndexSettingsInterface | { index: IndexSettingsInterface }; - -export interface ComboBoxOption { - label: string; - value?: unknown; -} - -export interface SearchResult { - display: JSX.Element; - field: NormalizedField; -} - -export interface SearchMetadata { - /** - * Whether or not the search term match some part of the field path. - */ - matchPath: boolean; - /** - * If the search term matches the field type we will give it a higher score. - */ - matchType: boolean; - /** - * If the last word of the search terms matches the field name - */ - matchFieldName: boolean; - /** - * If the search term matches the beginning of the path we will give it a higher score - */ - matchStartOfPath: boolean; - /** - * If the last word of the search terms fully matches the field name - */ - fullyMatchFieldName: boolean; - /** - * If the search term exactly matches the field type - */ - fullyMatchType: boolean; - /** - * If the search term matches the full field path - */ - fullyMatchPath: boolean; - /** - * The score of the result that will allow us to sort the list - */ - score: number; - /** - * The JSX with tag wrapping the matched string - */ - display: JSX.Element; - /** - * The field path substring that matches the search - */ - stringMatch: string | null; -} - -export interface GenericObject { - [key: string]: any; -} diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/types/index.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/types/index.ts new file mode 100644 index 000000000000..cce2d550a68c --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/types/index.ts @@ -0,0 +1,11 @@ +/* + * 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. + */ + +export * from './mappings_editor'; + +export * from './document_fields'; + +export * from './state'; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/types/mappings_editor.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/types/mappings_editor.ts new file mode 100644 index 000000000000..1ca944024ae2 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/types/mappings_editor.ts @@ -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 { ReactNode, OptionHTMLAttributes } from 'react'; + +import { NormalizedField } from './document_fields'; +import { Mappings } from './state'; + +export type OnUpdateHandler = (arg: OnUpdateHandlerArg) => void; + +export interface OnUpdateHandlerArg { + isValid?: boolean; + getData: () => Mappings | undefined; + validate: () => Promise; +} + +export type FieldsEditor = 'default' | 'json'; + +export interface IndexSettingsInterface { + analysis?: { + analyzer: { + [key: string]: { + type: string; + tokenizer: string; + char_filter?: string[]; + filter?: string[]; + position_increment_gap?: number; + }; + }; + }; +} + +/** + * When we define the index settings we can skip + * the "index" property and directly add the "analysis". + * ES always returns the settings wrapped under "index". + */ +export type IndexSettings = IndexSettingsInterface | { index: IndexSettingsInterface }; + +export type SelectOption = { + value: unknown; + text: T | ReactNode; +} & OptionHTMLAttributes; + +export interface ComboBoxOption { + label: string; + value?: unknown; +} + +export interface SuperSelectOption { + value: unknown; + inputDisplay?: ReactNode; + dropdownDisplay?: ReactNode; + disabled?: boolean; + 'data-test-subj'?: string; +} + +export interface SearchResult { + display: JSX.Element; + field: NormalizedField; +} + +export interface SearchMetadata { + /** + * Whether or not the search term match some part of the field path. + */ + matchPath: boolean; + /** + * If the search term matches the field type we will give it a higher score. + */ + matchType: boolean; + /** + * If the last word of the search terms matches the field name + */ + matchFieldName: boolean; + /** + * If the search term matches the beginning of the path we will give it a higher score + */ + matchStartOfPath: boolean; + /** + * If the last word of the search terms fully matches the field name + */ + fullyMatchFieldName: boolean; + /** + * If the search term exactly matches the field type + */ + fullyMatchType: boolean; + /** + * If the search term matches the full field path + */ + fullyMatchPath: boolean; + /** + * The score of the result that will allow us to sort the list + */ + score: number; + /** + * The JSX with tag wrapping the matched string + */ + display: JSX.Element; + /** + * The field path substring that matches the search + */ + stringMatch: string | null; +} + +export interface GenericObject { + [key: string]: any; +} diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/types/state.ts b/x-pack/plugins/index_management/public/application/components/mappings_editor/types/state.ts new file mode 100644 index 000000000000..34df70374aa8 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/types/state.ts @@ -0,0 +1,107 @@ +/* + * 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 { FormHook, OnFormUpdateArg } from '../shared_imports'; +import { Field, NormalizedFields } from './document_fields'; +import { FieldsEditor, SearchResult } from './mappings_editor'; + +export type Mappings = MappingsTemplates & + MappingsConfiguration & { + properties?: MappingsFields; + }; + +export interface MappingsConfiguration { + enabled?: boolean; + throwErrorsForUnmappedFields?: boolean; + date_detection?: boolean; + numeric_detection?: boolean; + dynamic_date_formats?: string[]; + _source?: { + enabled?: boolean; + includes?: string[]; + excludes?: string[]; + }; + _meta?: string; +} + +export interface MappingsTemplates { + dynamic_templates?: DynamicTemplate[]; +} + +export interface DynamicTemplate { + [key: string]: { + mapping: { + [key: string]: any; + }; + match_mapping_type?: string; + match?: string; + unmatch?: string; + match_pattern?: string; + path_match?: string; + path_unmatch?: string; + }; +} + +export interface MappingsFields { + [key: string]: any; +} + +export type DocumentFieldsStatus = 'idle' | 'editingField' | 'creatingField'; + +export interface DocumentFieldsState { + status: DocumentFieldsStatus; + editor: FieldsEditor; + fieldToEdit?: string; + fieldToAddFieldTo?: string; +} + +export interface ConfigurationFormState extends OnFormUpdateArg { + defaultValue: MappingsConfiguration; + submitForm?: FormHook['submit']; +} + +interface TemplatesFormState extends OnFormUpdateArg { + defaultValue: MappingsTemplates; + submitForm?: FormHook['submit']; +} + +export interface State { + isValid: boolean | undefined; + configuration: ConfigurationFormState; + documentFields: DocumentFieldsState; + fields: NormalizedFields; + fieldForm?: OnFormUpdateArg; + fieldsJsonEditor: { + format(): MappingsFields; + isValid: boolean; + }; + search: { + term: string; + result: SearchResult[]; + }; + templates: TemplatesFormState; +} + +export type Action = + | { type: 'editor.replaceMappings'; value: { [key: string]: any } } + | { type: 'configuration.update'; value: Partial } + | { type: 'configuration.save'; value: MappingsConfiguration } + | { type: 'templates.update'; value: Partial } + | { type: 'templates.save'; value: MappingsTemplates } + | { type: 'fieldForm.update'; value: OnFormUpdateArg } + | { type: 'field.add'; value: Field } + | { type: 'field.remove'; value: string } + | { type: 'field.edit'; value: Field } + | { type: 'field.toggleExpand'; value: { fieldId: string; isExpanded?: boolean } } + | { type: 'documentField.createField'; value?: string } + | { type: 'documentField.editField'; value: string } + | { type: 'documentField.changeStatus'; value: DocumentFieldsStatus } + | { type: 'documentField.changeEditor'; value: FieldsEditor } + | { type: 'fieldsJsonEditor.update'; value: { json: { [key: string]: any }; isValid: boolean } } + | { type: 'search:update'; value: string } + | { type: 'validity:update'; value: boolean }; + +export type Dispatch = (action: Action) => void; diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_state.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/use_state_listener.tsx similarity index 53% rename from x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_state.tsx rename to x-pack/plugins/index_management/public/application/components/mappings_editor/use_state_listener.tsx index ad5056fa73ce..f1ffd5356c97 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/mappings_state.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/use_state_listener.tsx @@ -3,92 +3,32 @@ * 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, { useReducer, useEffect, createContext, useContext, useMemo, useRef } from 'react'; +import { useEffect, useMemo } from 'react'; import { - reducer, + Field, + Mappings, MappingsConfiguration, - MappingsFields, MappingsTemplates, - State, - Dispatch, -} from './reducer'; -import { Field } from './types'; + OnUpdateHandler, +} from './types'; import { normalize, deNormalize, stripUndefinedValues } from './lib'; +import { useMappingsState, useDispatch } from './mappings_state_context'; -type Mappings = MappingsTemplates & - MappingsConfiguration & { - properties?: MappingsFields; - }; - -export interface Types { - Mappings: Mappings; - MappingsConfiguration: MappingsConfiguration; - MappingsFields: MappingsFields; - MappingsTemplates: MappingsTemplates; -} - -export interface OnUpdateHandlerArg { - isValid?: boolean; - getData: () => Mappings | undefined; - validate: () => Promise; -} - -export type OnUpdateHandler = (arg: OnUpdateHandlerArg) => void; - -const StateContext = createContext(undefined); -const DispatchContext = createContext(undefined); - -export interface Props { - children: (params: { state: State }) => React.ReactNode; - value: { +interface Args { + onChange: OnUpdateHandler; + value?: { templates: MappingsTemplates; configuration: MappingsConfiguration; fields: { [key: string]: Field }; }; - onChange: OnUpdateHandler; } -export const MappingsState = React.memo(({ children, onChange, value }: Props) => { - const didMountRef = useRef(false); +export const useMappingsStateListener = ({ onChange, value }: Args) => { + const state = useMappingsState(); + const dispatch = useDispatch(); - const parsedFieldsDefaultValue = useMemo(() => normalize(value.fields), [value.fields]); - - const initialState: State = { - isValid: true, - configuration: { - defaultValue: value.configuration, - data: { - raw: value.configuration, - format: () => value.configuration, - }, - validate: () => Promise.resolve(true), - }, - templates: { - defaultValue: value.templates, - data: { - raw: value.templates, - format: () => value.templates, - }, - validate: () => Promise.resolve(true), - }, - fields: parsedFieldsDefaultValue, - documentFields: { - status: parsedFieldsDefaultValue.rootLevelFields.length === 0 ? 'creatingField' : 'idle', - editor: 'default', - }, - fieldsJsonEditor: { - format: () => ({}), - isValid: true, - }, - search: { - term: '', - result: [], - }, - }; - - const [state, dispatch] = useReducer(reducer, initialState); + const parsedFieldsDefaultValue = useMemo(() => normalize(value?.fields), [value?.fields]); useEffect(() => { // If we are creating a new field, but haven't entered any name @@ -158,46 +98,28 @@ export const MappingsState = React.memo(({ children, onChange, value }: Props) = }, isValid: state.isValid, }); - }, [state, onChange]); + }, [state, onChange, dispatch]); useEffect(() => { /** * If the value has changed that probably means that we have loaded * new data from JSON. We need to update our state with the new mappings. */ - if (didMountRef.current) { - dispatch({ - type: 'editor.replaceMappings', - value: { - configuration: value.configuration, - templates: value.templates, - fields: parsedFieldsDefaultValue, - }, - }); - } else { - didMountRef.current = true; + if (value === undefined) { + return; } - }, [value, parsedFieldsDefaultValue]); - - return ( - - {children({ state })} - - ); -}); - -export const useMappingsState = () => { - const ctx = useContext(StateContext); - if (ctx === undefined) { - throw new Error('useMappingsState must be used within a '); - } - return ctx; -}; -export const useDispatch = () => { - const ctx = useContext(DispatchContext); - if (ctx === undefined) { - throw new Error('useDispatch must be used within a '); - } - return ctx; + dispatch({ + type: 'editor.replaceMappings', + value: { + configuration: value.configuration, + templates: value.templates, + fields: parsedFieldsDefaultValue, + documentFields: { + status: parsedFieldsDefaultValue.rootLevelFields.length === 0 ? 'creatingField' : 'idle', + editor: 'default', + }, + }, + }); + }, [value, parsedFieldsDefaultValue, dispatch]); }; diff --git a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_components.tsx b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_components.tsx index df0cc791384f..ae831f4acf7e 100644 --- a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_components.tsx +++ b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_components.tsx @@ -39,7 +39,7 @@ const i18nTexts = { ), }; -export const StepComponents = ({ defaultValue = [], onChange, esDocsBase }: Props) => { +export const StepComponents = ({ defaultValue, onChange, esDocsBase }: Props) => { const [state, setState] = useState<{ isLoadingComponents: boolean; components: ComponentTemplateListItem[]; diff --git a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_logistics.tsx b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_logistics.tsx index f3d05ac38108..fcc9795617eb 100644 --- a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_logistics.tsx +++ b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_logistics.tsx @@ -3,7 +3,7 @@ * 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, { useEffect } from 'react'; +import React, { useEffect, useCallback } from 'react'; import { EuiFlexGroup, EuiFlexItem, @@ -153,25 +153,18 @@ export const StepLogistics: React.FunctionComponent = React.memo( serializer: formSerializer, deserializer: formDeserializer, }); + const { subscribe, submit, isSubmitted, isValid: isFormValid, getErrors: getFormErrors } = form; /** * When the consumer call validate() on this step, we submit the form so it enters the "isSubmitted" state * and we can display the form errors on top of the forms if there are any. */ - const validate = async () => { - return (await form.submit()).isValid; - }; + const validate = useCallback(async () => { + return (await submit()).isValid; + }, [submit]); useEffect(() => { - onChange({ - isValid: form.isValid, - validate, - getData: form.getFormData, - }); - }, [form.isValid, onChange]); // eslint-disable-line react-hooks/exhaustive-deps - - useEffect(() => { - const subscription = form.subscribe(({ data, isValid }) => { + const subscription = subscribe(({ data, isValid }) => { onChange({ isValid, validate, @@ -179,7 +172,7 @@ export const StepLogistics: React.FunctionComponent = React.memo( }); }); return subscription.unsubscribe; - }, [onChange]); // eslint-disable-line react-hooks/exhaustive-deps + }, [onChange, validate, subscribe]); const { name, indexPatterns, dataStream, order, priority, version } = getFieldsMeta( documentationService.getEsDocsBase() @@ -204,7 +197,7 @@ export const StepLogistics: React.FunctionComponent = React.memo( @@ -220,8 +213,8 @@ export const StepLogistics: React.FunctionComponent = React.memo(
{/* Name */} diff --git a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_review.tsx b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_review.tsx index 0f4b9de4f6cf..1b4f19dda99f 100644 --- a/x-pack/plugins/index_management/public/application/components/template_form/steps/step_review.tsx +++ b/x-pack/plugins/index_management/public/application/components/template_form/steps/step_review.tsx @@ -24,6 +24,7 @@ import { serializers } from '../../../../shared_imports'; import { serializeLegacyTemplate, serializeTemplate } from '../../../../../common/lib'; import { TemplateDeserialized, getTemplateParameter } from '../../../../../common'; +import { SimulateTemplate } from '../../index_templates'; import { WizardSection } from '../template_form'; const { stripEmptyFields } = serializers; @@ -56,6 +57,27 @@ interface Props { navigateToStep: (stepId: WizardSection) => void; } +const PreviewTab = ({ template }: { template: { [key: string]: any } }) => { + return ( +
+ + + +

+ +

+
+ + + + +
+ ); +}; + export const StepReview: React.FunctionComponent = React.memo( ({ template, navigateToStep }) => { const { @@ -286,6 +308,33 @@ export const StepReview: React.FunctionComponent = React.memo( ); }; + const tabs = [ + { + id: 'summary', + name: i18n.translate('xpack.idxMgmt.templateForm.stepReview.summaryTabTitle', { + defaultMessage: 'Summary', + }), + content: , + }, + { + id: 'request', + name: i18n.translate('xpack.idxMgmt.templateForm.stepReview.requestTabTitle', { + defaultMessage: 'Request', + }), + content: , + }, + ]; + + if (!isLegacy) { + tabs.splice(1, 0, { + id: 'preview', + name: i18n.translate('xpack.idxMgmt.templateForm.stepReview.previewTabTitle', { + defaultMessage: 'Preview', + }), + content: , + }); + } + return (
@@ -331,25 +380,7 @@ export const StepReview: React.FunctionComponent = React.memo( ) : null} - , - }, - { - id: 'request', - name: i18n.translate('xpack.idxMgmt.templateForm.stepReview.requestTabTitle', { - defaultMessage: 'Request', - }), - content: , - }, - ]} - /> +
); } diff --git a/x-pack/plugins/index_management/public/application/components/template_form/template_form.tsx b/x-pack/plugins/index_management/public/application/components/template_form/template_form.tsx index f5c9be9292cd..fb0ba0b68fa6 100644 --- a/x-pack/plugins/index_management/public/application/components/template_form/template_form.tsx +++ b/x-pack/plugins/index_management/public/application/components/template_form/template_form.tsx @@ -3,14 +3,19 @@ * 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, { useCallback } from 'react'; +import React, { useState, useCallback } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiSpacer } from '@elastic/eui'; +import { EuiSpacer, EuiButton } from '@elastic/eui'; import { TemplateDeserialized } from '../../../../common'; -import { serializers, Forms } from '../../../shared_imports'; +import { serializers, Forms, GlobalFlyout } from '../../../shared_imports'; import { SectionError } from '../section_error'; +import { + SimulateTemplateFlyoutContent, + SimulateTemplateProps, + simulateTemplateFlyoutProps, +} from '../index_templates'; import { StepLogisticsContainer, StepComponentContainer, StepReviewContainer } from './steps'; import { CommonWizardSteps, @@ -22,8 +27,10 @@ import { documentationService } from '../../services/documentation'; const { stripEmptyFields } = serializers; const { FormWizard, FormWizardStep } = Forms; +const { useGlobalFlyout } = GlobalFlyout; interface Props { + title: string | JSX.Element; onSave: (template: TemplateDeserialized) => void; clearSaveError: () => void; isSaving: boolean; @@ -80,6 +87,7 @@ const wizardSections: { [id: string]: { id: WizardSection; label: string } } = { }; export const TemplateForm = ({ + title, defaultValue, isEditing, isSaving, @@ -88,6 +96,9 @@ export const TemplateForm = ({ clearSaveError, onSave, }: Props) => { + const [wizardContent, setWizardContent] = useState | null>(null); + const { addContent: addContentToGlobalFlyout, closeFlyout } = useGlobalFlyout(); + const indexTemplate = defaultValue ?? { name: '', indexPatterns: [], @@ -189,6 +200,10 @@ export const TemplateForm = ({ [] ); + const onWizardContentChange = useCallback((content: Forms.Content) => { + setWizardContent(content); + }, []); + const onSaveTemplate = useCallback( async (wizardData: WizardContent) => { const template = buildTemplateObject(indexTemplate)(wizardData); @@ -206,44 +221,101 @@ export const TemplateForm = ({ [indexTemplate, buildTemplateObject, onSave, clearSaveError] ); + const getSimulateTemplate = useCallback(async () => { + if (!wizardContent) { + return; + } + const isValid = await wizardContent.validate(); + if (!isValid) { + return; + } + const wizardData = wizardContent.getData(); + const template = buildTemplateObject(indexTemplate)(wizardData); + return template; + }, [buildTemplateObject, indexTemplate, wizardContent]); + + const showPreviewFlyout = () => { + addContentToGlobalFlyout({ + id: 'simulateTemplate', + Component: SimulateTemplateFlyoutContent, + props: { + getTemplate: getSimulateTemplate, + onClose: closeFlyout, + }, + flyoutProps: simulateTemplateFlyoutProps, + }); + }; + + const getRightContentWizardNav = (stepId: WizardSection) => { + if (isLegacy) { + return null; + } + + // Don't show "Preview template" button on logistics and review steps + if (stepId === 'logistics' || stepId === 'review') { + return null; + } + + return ( + + + + ); + }; + return ( - - defaultValue={wizardDefaultValue} - onSave={onSaveTemplate} - isEditing={isEditing} - isSaving={isSaving} - apiError={apiError} - texts={i18nTexts} - > - - - + <> + {/* Form header */} + {title} - {indexTemplate._kbnMeta.isLegacy !== true && ( - - + + + + defaultValue={wizardDefaultValue} + onSave={onSaveTemplate} + isEditing={isEditing} + isSaving={isSaving} + apiError={apiError} + texts={i18nTexts} + onChange={onWizardContentChange} + rightContentNav={getRightContentWizardNav} + > + + - )} - - - + {indexTemplate._kbnMeta.isLegacy !== true && ( + + + + )} - - - + + + + + + + - - - + + + - - - - + + + + + ); }; diff --git a/x-pack/plugins/index_management/public/application/index.tsx b/x-pack/plugins/index_management/public/application/index.tsx index ebc29ac86a17..f881c2e01cef 100644 --- a/x-pack/plugins/index_management/public/application/index.tsx +++ b/x-pack/plugins/index_management/public/application/index.tsx @@ -11,11 +11,14 @@ import { render, unmountComponentAtNode } from 'react-dom'; import { CoreStart } from '../../../../../src/core/public'; import { API_BASE_PATH } from '../../common'; +import { GlobalFlyout } from '../shared_imports'; import { AppContextProvider, AppDependencies } from './app_context'; import { App } from './app'; import { indexManagementStore } from './store'; -import { ComponentTemplatesProvider } from './components'; +import { ComponentTemplatesProvider, MappingsEditorProvider } from './components'; + +const { GlobalFlyoutProvider } = GlobalFlyout; export const renderApp = ( elem: HTMLElement | null, @@ -43,9 +46,13 @@ export const renderApp = ( - - - + + + + + + + , diff --git a/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/tabs/index.ts b/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/tabs/index.ts index 08ebda2b5e43..11a86e78be99 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/tabs/index.ts +++ b/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/tabs/index.ts @@ -5,3 +5,4 @@ */ export { TabSummary } from './tab_summary'; +export { TabPreview } from './tab_preview'; diff --git a/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/tabs/tab_preview.tsx b/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/tabs/tab_preview.tsx new file mode 100644 index 000000000000..ec52bcbab3b0 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/tabs/tab_preview.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiText, EuiSpacer } from '@elastic/eui'; +import { TemplateDeserialized } from '../../../../../../../common'; +import { SimulateTemplate } from '../../../../../components/index_templates'; + +interface Props { + templateDetails: TemplateDeserialized; +} + +export const TabPreview = ({ templateDetails }: Props) => { + return ( +
+ +

+ +

+
+ + + + +
+ ); +}; diff --git a/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/template_details.tsx b/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/template_details.tsx index faeca2f2487a..c03f64880a70 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/template_details.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/template_details.tsx @@ -15,8 +15,6 @@ export const TemplateDetails = (props: Props) => { onClose={props.onClose} data-test-subj="templateDetails" aria-labelledby="templateDetailsFlyoutTitle" - size="m" - maxWidth={500} > diff --git a/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/template_details_content.tsx b/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/template_details_content.tsx index 5b726013a1d9..5bacffc4c240 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/template_details_content.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/template_list/template_details/template_details_content.tsx @@ -29,6 +29,7 @@ import { UIM_TEMPLATE_DETAIL_PANEL_SUMMARY_TAB, UIM_TEMPLATE_DETAIL_PANEL_SETTINGS_TAB, UIM_TEMPLATE_DETAIL_PANEL_ALIASES_TAB, + UIM_TEMPLATE_DETAIL_PANEL_PREVIEW_TAB, } from '../../../../../../common/constants'; import { SendRequestResponse } from '../../../../../shared_imports'; import { TemplateDeleteModal, SectionLoading, SectionError, Error } from '../../../../components'; @@ -37,12 +38,13 @@ import { decodePathFromReactRouter } from '../../../../services/routing'; import { useServices } from '../../../../app_context'; import { TabAliases, TabMappings, TabSettings } from '../../../../components/shared'; import { TemplateTypeIndicator } from '../components'; -import { TabSummary } from './tabs'; +import { TabSummary, TabPreview } from './tabs'; const SUMMARY_TAB_ID = 'summary'; const MAPPINGS_TAB_ID = 'mappings'; const ALIASES_TAB_ID = 'aliases'; const SETTINGS_TAB_ID = 'settings'; +const PREVIEW_TAB_ID = 'preview'; const TABS = [ { @@ -69,6 +71,12 @@ const TABS = [ defaultMessage: 'Aliases', }), }, + { + id: PREVIEW_TAB_ID, + name: i18n.translate('xpack.idxMgmt.templateDetails.previewTabTitle', { + defaultMessage: 'Preview', + }), + }, ]; const tabToUiMetricMap: { [key: string]: string } = { @@ -76,6 +84,7 @@ const tabToUiMetricMap: { [key: string]: string } = { [SETTINGS_TAB_ID]: UIM_TEMPLATE_DETAIL_PANEL_SETTINGS_TAB, [MAPPINGS_TAB_ID]: UIM_TEMPLATE_DETAIL_PANEL_MAPPINGS_TAB, [ALIASES_TAB_ID]: UIM_TEMPLATE_DETAIL_PANEL_ALIASES_TAB, + [PREVIEW_TAB_ID]: UIM_TEMPLATE_DETAIL_PANEL_PREVIEW_TAB, }; export interface Props { @@ -161,6 +170,7 @@ export const TemplateDetailsContent = ({ [SETTINGS_TAB_ID]: , [MAPPINGS_TAB_ID]: , [ALIASES_TAB_ID]: , + [PREVIEW_TAB_ID]: , }; const tabContent = tabToComponentMap[activeTab]; @@ -191,7 +201,13 @@ export const TemplateDetailsContent = ({ {managedTemplateCallout} - {TABS.map((tab) => ( + {TABS.filter((tab) => { + // Legacy index templates don't have the "simulate" template API + if (isLegacy && tab.id === PREVIEW_TAB_ID) { + return false; + } + return true; + }).map((tab) => ( { uiMetricService.trackMetric('click', tabToUiMetricMap[tab.id]); diff --git a/x-pack/plugins/index_management/public/application/sections/template_clone/template_clone.tsx b/x-pack/plugins/index_management/public/application/sections/template_clone/template_clone.tsx index 82835c56a387..2aaecbd64ee2 100644 --- a/x-pack/plugins/index_management/public/application/sections/template_clone/template_clone.tsx +++ b/x-pack/plugins/index_management/public/application/sections/template_clone/template_clone.tsx @@ -6,7 +6,7 @@ import React, { useEffect, useState } from 'react'; import { RouteComponentProps } from 'react-router-dom'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiPageBody, EuiPageContent, EuiSpacer, EuiTitle } from '@elastic/eui'; +import { EuiPageBody, EuiPageContent, EuiTitle } from '@elastic/eui'; import { TemplateDeserialized } from '../../../../common'; import { TemplateForm, SectionLoading, SectionError, Error } from '../../components'; @@ -94,30 +94,30 @@ export const TemplateClone: React.FunctionComponent +

+ +

+ + } defaultValue={templateData} onSave={onSave} isSaving={isSaving} saveError={saveError} clearSaveError={clearSaveError} + isLegacy={isLegacy} /> ); } return ( - - -

- -

-
- - {content} -
+ {content}
); }; diff --git a/x-pack/plugins/index_management/public/application/sections/template_create/template_create.tsx b/x-pack/plugins/index_management/public/application/sections/template_create/template_create.tsx index fb82f52968eb..691d2598d56d 100644 --- a/x-pack/plugins/index_management/public/application/sections/template_create/template_create.tsx +++ b/x-pack/plugins/index_management/public/application/sections/template_create/template_create.tsx @@ -6,7 +6,7 @@ import React, { useEffect, useState } from 'react'; import { RouteComponentProps } from 'react-router-dom'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiPageBody, EuiPageContent, EuiSpacer, EuiTitle } from '@elastic/eui'; +import { EuiPageBody, EuiPageContent, EuiTitle } from '@elastic/eui'; import { useLocation } from 'react-router-dom'; import { parse } from 'query-string'; @@ -51,23 +51,24 @@ export const TemplateCreate: React.FunctionComponent = ({ h return ( - -

- {isLegacy ? ( - - ) : ( - - )} -

-
- +

+ {isLegacy ? ( + + ) : ( + + )} +

+ + } onSave={onSave} isSaving={isSaving} saveError={saveError} diff --git a/x-pack/plugins/index_management/public/application/sections/template_edit/template_edit.tsx b/x-pack/plugins/index_management/public/application/sections/template_edit/template_edit.tsx index 29fd2e02120f..6bdcd03fa5ca 100644 --- a/x-pack/plugins/index_management/public/application/sections/template_edit/template_edit.tsx +++ b/x-pack/plugins/index_management/public/application/sections/template_edit/template_edit.tsx @@ -133,12 +133,24 @@ export const TemplateEdit: React.FunctionComponent )} +

+ +

+ + } defaultValue={template} onSave={onSave} isSaving={isSaving} saveError={saveError} clearSaveError={clearSaveError} isEditing={true} + isLegacy={isLegacy} /> ); @@ -147,19 +159,7 @@ export const TemplateEdit: React.FunctionComponent - - -

- -

-
- - {content} -
+ {content}
); }; diff --git a/x-pack/plugins/index_management/public/application/services/api.ts b/x-pack/plugins/index_management/public/application/services/api.ts index d7874ec2dcf3..546a0115ee4a 100644 --- a/x-pack/plugins/index_management/public/application/services/api.ts +++ b/x-pack/plugins/index_management/public/application/services/api.ts @@ -30,6 +30,7 @@ import { UIM_TEMPLATE_CREATE, UIM_TEMPLATE_UPDATE, UIM_TEMPLATE_CLONE, + UIM_TEMPLATE_SIMULATE, } from '../../../common/constants'; import { TemplateDeserialized, TemplateListItem, DataStream } from '../../../common'; import { IndexMgmtMetricsType } from '../../types'; @@ -286,3 +287,14 @@ export async function updateTemplate(template: TemplateDeserialized) { return result; } + +export function simulateIndexTemplate(template: { [key: string]: any }) { + return sendRequest({ + path: `${API_BASE_PATH}/index_templates/simulate`, + method: 'post', + body: JSON.stringify(template), + }).then((result) => { + uiMetricService.trackMetric('count', UIM_TEMPLATE_SIMULATE); + return result; + }); +} diff --git a/x-pack/plugins/index_management/public/application/services/documentation.ts b/x-pack/plugins/index_management/public/application/services/documentation.ts index 972b4f4b2568..afc9c76f1afb 100644 --- a/x-pack/plugins/index_management/public/application/services/documentation.ts +++ b/x-pack/plugins/index_management/public/application/services/documentation.ts @@ -40,8 +40,10 @@ class DocumentationService { return `${this.esDocsBase}/data-streams.html`; } - public getTemplatesDocumentationLink() { - return `${this.esDocsBase}/indices-templates.html`; + public getTemplatesDocumentationLink(isLegacy = false) { + return isLegacy + ? `${this.esDocsBase}/indices-templates-v1.html` + : `${this.esDocsBase}/indices-templates.html`; } public getIdxMgmtDocumentationLink() { diff --git a/x-pack/plugins/index_management/public/application/services/index.ts b/x-pack/plugins/index_management/public/application/services/index.ts index 2334d32adf13..a78e0bac14ae 100644 --- a/x-pack/plugins/index_management/public/application/services/index.ts +++ b/x-pack/plugins/index_management/public/application/services/index.ts @@ -22,6 +22,7 @@ export { loadIndexMapping, loadIndexData, useLoadIndexTemplates, + simulateIndexTemplate, } from './api'; export { healthToColor } from './health_to_color'; export { sortTable } from './sort_table'; diff --git a/x-pack/plugins/index_management/public/shared_imports.ts b/x-pack/plugins/index_management/public/shared_imports.ts index 3f7fcf424f1f..16dcab18c3ca 100644 --- a/x-pack/plugins/index_management/public/shared_imports.ts +++ b/x-pack/plugins/index_management/public/shared_imports.ts @@ -12,6 +12,7 @@ export { useRequest, Forms, extractQueryParams, + GlobalFlyout, } from '../../../../src/plugins/es_ui_shared/public/'; export { diff --git a/x-pack/plugins/index_management/server/client/elasticsearch.ts b/x-pack/plugins/index_management/server/client/elasticsearch.ts index 9f8bce241ae6..ed5ede07479c 100644 --- a/x-pack/plugins/index_management/server/client/elasticsearch.ts +++ b/x-pack/plugins/index_management/server/client/elasticsearch.ts @@ -182,4 +182,14 @@ export const elasticsearchJsPlugin = (Client: any, config: any, components: any) ], method: 'HEAD', }); + + dataManagement.simulateTemplate = ca({ + urls: [ + { + fmt: '/_index_template/_simulate', + }, + ], + needBody: true, + method: 'POST', + }); }; diff --git a/x-pack/plugins/index_management/server/routes/api/templates/register_simulate_route.ts b/x-pack/plugins/index_management/server/routes/api/templates/register_simulate_route.ts new file mode 100644 index 000000000000..9d078e135fd5 --- /dev/null +++ b/x-pack/plugins/index_management/server/routes/api/templates/register_simulate_route.ts @@ -0,0 +1,42 @@ +/* + * 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 { schema, TypeOf } from '@kbn/config-schema'; + +import { RouteDependencies } from '../../../types'; +import { addBasePath } from '../index'; + +const bodySchema = schema.object({}, { unknowns: 'allow' }); + +export function registerSimulateRoute({ router, license, lib }: RouteDependencies) { + router.post( + { + path: addBasePath('/index_templates/simulate'), + validate: { body: bodySchema }, + }, + license.guardApiRoute(async (ctx, req, res) => { + const { callAsCurrentUser } = ctx.dataManagement!.client; + const template = req.body as TypeOf; + + try { + const templatePreview = await callAsCurrentUser('dataManagement.simulateTemplate', { + body: template, + }); + + return res.ok({ body: templatePreview }); + } catch (e) { + if (lib.isEsError(e)) { + return res.customError({ + statusCode: e.statusCode, + body: e, + }); + } + // Case: default + return res.internalError({ body: e }); + } + }) + ); +} diff --git a/x-pack/plugins/index_management/server/routes/api/templates/register_template_routes.ts b/x-pack/plugins/index_management/server/routes/api/templates/register_template_routes.ts index 2b657346a2f8..e25f2abdfee7 100644 --- a/x-pack/plugins/index_management/server/routes/api/templates/register_template_routes.ts +++ b/x-pack/plugins/index_management/server/routes/api/templates/register_template_routes.ts @@ -10,6 +10,7 @@ import { registerGetAllRoute, registerGetOneRoute } from './register_get_routes' import { registerDeleteRoute } from './register_delete_route'; import { registerCreateRoute } from './register_create_route'; import { registerUpdateRoute } from './register_update_route'; +import { registerSimulateRoute } from './register_simulate_route'; export function registerTemplateRoutes(dependencies: RouteDependencies) { registerGetAllRoute(dependencies); @@ -17,4 +18,5 @@ export function registerTemplateRoutes(dependencies: RouteDependencies) { registerDeleteRoute(dependencies); registerCreateRoute(dependencies); registerUpdateRoute(dependencies); + registerSimulateRoute(dependencies); } From 3883e3e239c9a4595a4c2c49e18ba19538dbb7ac Mon Sep 17 00:00:00 2001 From: James Gowdy Date: Fri, 24 Jul 2020 09:14:10 +0100 Subject: [PATCH 133/142] [ML] Fixing recognizer wizard create job button (#73025) * [ML] Fixing recognizer wizard create job button * updating translations --- .../components/job_settings_form.tsx | 20 ++++++------------- .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 3 files changed, 6 insertions(+), 16 deletions(-) diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/recognize/components/job_settings_form.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/recognize/components/job_settings_form.tsx index 63dec536ea48..e31c6bc7b59e 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/recognize/components/job_settings_form.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/recognize/components/job_settings_form.tsx @@ -258,7 +258,7 @@ export const JobSettingsForm: FC = ({ fill type="submit" isLoading={saveState === SAVE_STATE.SAVING} - disabled={!validationResult.formValid} + disabled={!validationResult.formValid || saveState === SAVE_STATE.SAVING} onClick={() => { onSubmit(formState); }} @@ -266,19 +266,11 @@ export const JobSettingsForm: FC = ({ defaultMessage: 'Create job', })} > - {saveState === SAVE_STATE.NOT_SAVED && ( - - )} - {saveState === SAVE_STATE.SAVING && ( - - )} + diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 846330146cf0..cf789d1e7c45 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -10239,7 +10239,6 @@ "xpack.ml.newJob.recognize.advancedLabel": "高度な設定", "xpack.ml.newJob.recognize.advancedSettingsAriaLabel": "高度な設定", "xpack.ml.newJob.recognize.alreadyExistsLabel": "(既に存在します)", - "xpack.ml.newJob.recognize.analysisRunningLabel": "分析を実行中", "xpack.ml.newJob.recognize.cancelJobOverrideLabel": "閉じる", "xpack.ml.newJob.recognize.createJobButtonAriaLabel": "ジョブを作成", "xpack.ml.newJob.recognize.createJobButtonLabel": "{numberOfJobs, plural, zero {Job} one {Job} other {Jobs}} を作成", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 477858d2e74d..5b81804faf71 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -10244,7 +10244,6 @@ "xpack.ml.newJob.recognize.advancedLabel": "高级", "xpack.ml.newJob.recognize.advancedSettingsAriaLabel": "高级设置", "xpack.ml.newJob.recognize.alreadyExistsLabel": "(已存在)", - "xpack.ml.newJob.recognize.analysisRunningLabel": "分析正在运行", "xpack.ml.newJob.recognize.cancelJobOverrideLabel": "关闭", "xpack.ml.newJob.recognize.createJobButtonAriaLabel": "创建作业", "xpack.ml.newJob.recognize.createJobButtonLabel": "创建{numberOfJobs, plural, zero {作业} one {Job} other {Jobs}}", From c0968f5726ec19ed731f1eee005a9513d4e10dbb Mon Sep 17 00:00:00 2001 From: James Gowdy Date: Fri, 24 Jul 2020 09:15:23 +0100 Subject: [PATCH 134/142] [ML] Fixing unnecessary deleting job polling (#73087) --- .../components/jobs_list_view/jobs_list_view.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js index a3b6cb39815a..e9f3cb0d7d70 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js @@ -63,9 +63,14 @@ export class JobsListView extends Component { this.showDeleteJobModal = () => {}; this.showStartDatafeedModal = () => {}; this.showCreateWatchFlyout = () => {}; + // work around to keep track of whether the component is mounted + // used to block timeouts for results polling + // which can run after unmounting + this._isMounted = false; } componentDidMount() { + this._isMounted = true; this.refreshJobSummaryList(true); if (this.props.isManagementTable !== true) { @@ -87,6 +92,7 @@ export class JobsListView extends Component { if (this.props.isManagementTable === undefined) { deletingJobsRefreshTimeout = null; } + this._isMounted = false; } openAutoStartDatafeedModal() { @@ -232,7 +238,7 @@ export class JobsListView extends Component { }; async refreshJobSummaryList(forceRefresh = false) { - if (forceRefresh === true || this.props.blockRefresh !== true) { + if (this._isMounted && (forceRefresh === true || this.props.blockRefresh !== true)) { // Set loading to true for jobs_list table for initial job loading if (this.state.loading === null) { this.setState({ loading: true }); @@ -283,6 +289,10 @@ export class JobsListView extends Component { } async checkDeletingJobTasks(forceRefresh = false) { + if (this._isMounted === false) { + return; + } + const { jobIds: taskJobIds } = await ml.jobs.deletingJobTasks(); const taskListHasChanged = From 5aa121152141d95505e458e9cd7985f2c6bd0c2c Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Fri, 24 Jul 2020 11:35:10 +0300 Subject: [PATCH 135/142] [Security Solution][Detections] Change detections breadcrumb title (#73059) --- .../detections/pages/detection_engine/rules/utils.test.ts | 2 +- .../detections/pages/detection_engine/rules/utils.ts | 2 +- .../detections/pages/detection_engine/translations.ts | 7 +++++++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/utils.test.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/utils.test.ts index 32f96b519acc..1cbd1ee0f76a 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/utils.test.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/utils.test.ts @@ -23,6 +23,6 @@ describe('getBreadcrumbs', () => { [], getUrlForAppMock ) - ).toEqual([{ href: 'securitySolution:detections', text: 'Detection alerts' }]); + ).toEqual([{ href: 'securitySolution:detections', text: 'Detections' }]); }); }); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/utils.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/utils.ts index 75d1df9406d2..c1b4fa3e2b7d 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/utils.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/utils.ts @@ -57,7 +57,7 @@ export const getBreadcrumbs = ( ): ChromeBreadcrumb[] => { let breadcrumb = [ { - text: i18nDetections.PAGE_TITLE, + text: i18nDetections.BREADCRUMB_TITLE, href: getUrlForApp(`${APP_ID}:${SecurityPageName.detections}`, { path: !isEmpty(search[0]) ? search[0] : '', }), diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/translations.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/translations.ts index 92dc02ac8478..10223716ef33 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/translations.ts @@ -6,6 +6,13 @@ import { i18n } from '@kbn/i18n'; +export const BREADCRUMB_TITLE = i18n.translate( + 'xpack.securitySolution.detectionEngine.detectionsBreadcrumbTitle', + { + defaultMessage: 'Detections', + } +); + export const PAGE_TITLE = i18n.translate( 'xpack.securitySolution.detectionEngine.detectionsPageTitle', { From 680d94c82fd9f97164aa5c78fd07b2fd3e10a679 Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Fri, 24 Jul 2020 08:40:41 -0400 Subject: [PATCH 136/142] [Ingest Manager] Support DEGRADED state in fleet agent event (#73104) --- .../common/openapi/spec_oas3.json | 1 + .../ingest_manager/common/types/models/agent.ts | 1 + .../components/type_labels.tsx | 8 ++++++++ .../ingest_manager/server/types/models/agent.ts | 17 ++++++++++------- 4 files changed, 20 insertions(+), 7 deletions(-) diff --git a/x-pack/plugins/ingest_manager/common/openapi/spec_oas3.json b/x-pack/plugins/ingest_manager/common/openapi/spec_oas3.json index 4b10dab5d1ae..e16edac5ddb7 100644 --- a/x-pack/plugins/ingest_manager/common/openapi/spec_oas3.json +++ b/x-pack/plugins/ingest_manager/common/openapi/spec_oas3.json @@ -4203,6 +4203,7 @@ "FAILED", "STOPPING", "STOPPED", + "DEGRADED", "DATA_DUMP", "ACKNOWLEDGED", "UNKNOWN" diff --git a/x-pack/plugins/ingest_manager/common/types/models/agent.ts b/x-pack/plugins/ingest_manager/common/types/models/agent.ts index d3789c58a2c2..f31d33e73c76 100644 --- a/x-pack/plugins/ingest_manager/common/types/models/agent.ts +++ b/x-pack/plugins/ingest_manager/common/types/models/agent.ts @@ -53,6 +53,7 @@ export interface NewAgentEvent { | 'FAILED' | 'STOPPING' | 'STOPPED' + | 'DEGRADED' // Action results | 'DATA_DUMP' // Actions diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/type_labels.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/type_labels.tsx index e9cb59be3789..43e4d696ded6 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/type_labels.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/type_labels.tsx @@ -95,6 +95,14 @@ export const SUBTYPE_LABEL: { [key in AgentEvent['subtype']]: JSX.Element } = { /> ), + DEGRADED: ( + + + + ), DATA_DUMP: ( Date: Fri, 24 Jul 2020 09:02:23 -0400 Subject: [PATCH 137/142] [INGEST_MANAGER] Make package config name blank for endpoint on Package Config create (#73082) * Make package config name blank for endpoint * Added functional tests on endpoint side --- .../step_define_package_config.tsx | 14 +++++++++++--- .../apps/endpoint/policy_list.ts | 5 +++++ .../ingest_manager_create_package_config_page.ts | 7 +++++++ 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/step_define_package_config.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/step_define_package_config.tsx index a04d023ebcc4..f487b4e5235e 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/step_define_package_config.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/create_package_config_page/step_define_package_config.tsx @@ -47,9 +47,17 @@ export const StepDefinePackageConfig: React.FunctionComponent<{ .sort(); updatePackageConfig({ - name: `${packageInfo.name}-${ - dsWithMatchingNames.length ? dsWithMatchingNames[dsWithMatchingNames.length - 1] + 1 : 1 - }`, + name: + // For Endpoint packages, the user must fill in the name, thus we don't attempt to generate + // a default one here. + // FIXME: Improve package configs name uniqueness - https://github.com/elastic/kibana/issues/72948 + packageInfo.name !== 'endpoint' + ? `${packageInfo.name}-${ + dsWithMatchingNames.length + ? dsWithMatchingNames[dsWithMatchingNames.length - 1] + 1 + : 1 + }` + : '', package: { name: packageInfo.name, title: packageInfo.title, diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/policy_list.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/policy_list.ts index a4b3a51c4951..0c5e15ed4104 100644 --- a/x-pack/test/security_solution_endpoint/apps/endpoint/policy_list.ts +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/policy_list.ts @@ -131,6 +131,11 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { expect(endpointConfig).not.to.be(undefined); }); + it('should have empty value for package configuration name', async () => { + await pageObjects.ingestManagerCreatePackageConfig.selectAgentConfig(); + expect(await pageObjects.ingestManagerCreatePackageConfig.getPackageConfigName()).to.be(''); + }); + it('should redirect user back to Policy List after a successful save', async () => { const newPolicyName = `endpoint policy ${Date.now()}`; await pageObjects.ingestManagerCreatePackageConfig.selectAgentConfig(); diff --git a/x-pack/test/security_solution_endpoint/page_objects/ingest_manager_create_package_config_page.ts b/x-pack/test/security_solution_endpoint/page_objects/ingest_manager_create_package_config_page.ts index dd3fc637a3d6..dfdb528b7362 100644 --- a/x-pack/test/security_solution_endpoint/page_objects/ingest_manager_create_package_config_page.ts +++ b/x-pack/test/security_solution_endpoint/page_objects/ingest_manager_create_package_config_page.ts @@ -62,6 +62,13 @@ export function IngestManagerCreatePackageConfig({ } }, + /** + * Returns the package config name currently populated on the input field + */ + async getPackageConfigName() { + return testSubjects.getAttribute('packageConfigNameInput', 'value'); + }, + /** * Set the name of the package config on the input field * @param name From 46bd08d6d4c068e392aed757ffdc7ddcceb45932 Mon Sep 17 00:00:00 2001 From: Uladzislau Lasitsa Date: Fri, 24 Jul 2020 16:39:03 +0300 Subject: [PATCH 138/142] Removed useless karma test (#73190) --- .../timelion_expression_suggestions.js | 58 ------------------- 1 file changed, 58 deletions(-) delete mode 100644 src/plugins/timelion/public/directives/timelion_expression_suggestions/__tests__/timelion_expression_suggestions.js diff --git a/src/plugins/timelion/public/directives/timelion_expression_suggestions/__tests__/timelion_expression_suggestions.js b/src/plugins/timelion/public/directives/timelion_expression_suggestions/__tests__/timelion_expression_suggestions.js deleted file mode 100644 index 8a35a72ed19e..000000000000 --- a/src/plugins/timelion/public/directives/timelion_expression_suggestions/__tests__/timelion_expression_suggestions.js +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import expect from '@kbn/expect'; -import ngMock from 'ng_mock'; -import '../timelion_expression_suggestions'; - -describe('Timelion expression suggestions directive', function () { - let scope; - let $compile; - - beforeEach(ngMock.module('kibana')); - - beforeEach( - ngMock.inject(function ($injector) { - $compile = $injector.get('$compile'); - scope = $injector.get('$rootScope').$new(); - }) - ); - - describe('attributes', function () { - describe('suggestions', function () { - let element = null; - const template = ``; - - beforeEach(function () { - element = $compile(template)(scope); - scope.$apply(() => { - scope.list = [{ name: 'suggestion1' }, { name: 'suggestion2' }, { name: 'suggestion3' }]; - }); - }); - - it('are rendered', function () { - expect(element.find('[data-test-subj="timelionSuggestionListItem"]').length).to.be( - scope.list.length - ); - }); - }); - }); -}); From 120ce71171c3bb3e20cee5b61f6acbc138e13379 Mon Sep 17 00:00:00 2001 From: Clint Andrew Hall Date: Fri, 24 Jul 2020 15:39:25 -0400 Subject: [PATCH 139/142] Return EUI CSS to Shareable Runtime (#72990) * Add Canvas Styles * Tree-shaking in EUI omits CSS in the runtime Co-authored-by: Elastic Machine --- x-pack/plugins/canvas/shareable_runtime/README.md | 2 +- x-pack/plugins/canvas/shareable_runtime/webpack.config.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/canvas/shareable_runtime/README.md b/x-pack/plugins/canvas/shareable_runtime/README.md index 8fdeb6ca6258..3839e7c4ecb3 100644 --- a/x-pack/plugins/canvas/shareable_runtime/README.md +++ b/x-pack/plugins/canvas/shareable_runtime/README.md @@ -207,7 +207,7 @@ There are a number of options for the build script: ### Prerequisite -Before testing or running this PR locally, you **must** run `node scripts/runtime` from `/canvas` _after_ `yarn kbn bootstrap` and _before_ starting Kibana. It is only built automatically when Kibana is built to avoid slowing down other development activities. +Before testing or running this PR locally, you **must** run `node scripts/shareable_runtime` from `/canvas` _after_ `yarn kbn bootstrap` and _before_ starting Kibana. It is only built automatically when Kibana is built to avoid slowing down other development activities. ### Webpack Dev Server diff --git a/x-pack/plugins/canvas/shareable_runtime/webpack.config.js b/x-pack/plugins/canvas/shareable_runtime/webpack.config.js index 1a5a21985ba7..93dc3dbccd54 100644 --- a/x-pack/plugins/canvas/shareable_runtime/webpack.config.js +++ b/x-pack/plugins/canvas/shareable_runtime/webpack.config.js @@ -55,7 +55,6 @@ module.exports = { options: { presets: [require.resolve('@kbn/babel-preset/webpack_preset')], }, - sideEffects: false, }, { test: /\.tsx?$/, @@ -92,6 +91,7 @@ module.exports = { }, }, ], + sideEffects: true, }, { test: /\.module\.s(a|c)ss$/, From 7f36bd7dccca86d8a812df353bfe5023df72a268 Mon Sep 17 00:00:00 2001 From: Garrett Spong Date: Fri, 24 Jul 2020 15:50:16 -0600 Subject: [PATCH 140/142] [Security Solution][Exceptions] Prevents value list entries from co-existing with non value list entries (#72995) ## Summary Fixes validation issue where value list exception entries could be added alongside non value list exception entries. Once a value list operator (`is in list` or `is not in list`) is selected the `nested` button will disable, and subsequent `and`/ `or`'s will only have the value list operators available to them:

If a value list is not selected in the first exception entry, all subsequent entries will no longer have the value list operators:

Adds validation for empty case to prevent network error when submitted no entries. Add modal:

Edit modal:

### Checklist Delete any items that are not applicable to this PR. - [x] [Unit or functional tests](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility) were updated or added to match the most common scenarios --- .../components/autocomplete/operators.ts | 14 ++++++ .../exceptions/add_exception_modal/index.tsx | 6 +-- .../builder/builder_entry_item.test.tsx | 4 +- .../exceptions/builder/builder_entry_item.tsx | 16 ++++-- .../builder/builder_exception_item.tsx | 3 ++ .../exceptions/builder/helpers.test.tsx | 49 ++++++++++++------- .../components/exceptions/builder/helpers.tsx | 17 +++++-- .../components/exceptions/builder/index.tsx | 21 ++++++-- .../components/exceptions/builder/reducer.ts | 7 ++- .../exceptions/edit_exception_modal/index.tsx | 14 +++++- 10 files changed, 113 insertions(+), 38 deletions(-) diff --git a/x-pack/plugins/security_solution/public/common/components/autocomplete/operators.ts b/x-pack/plugins/security_solution/public/common/components/autocomplete/operators.ts index a81d8cde94e3..c54f58a3fd4b 100644 --- a/x-pack/plugins/security_solution/public/common/components/autocomplete/operators.ts +++ b/x-pack/plugins/security_solution/public/common/components/autocomplete/operators.ts @@ -90,3 +90,17 @@ export const EXCEPTION_OPERATORS: OperatorOption[] = [ isInListOperator, isNotInListOperator, ]; + +export const EXCEPTION_OPERATORS_SANS_LISTS: OperatorOption[] = [ + isOperator, + isNotOperator, + isOneOfOperator, + isNotOneOfOperator, + existsOperator, + doesNotExistOperator, +]; + +export const EXCEPTION_OPERATORS_ONLY_LISTS: OperatorOption[] = [ + isInListOperator, + isNotInListOperator, +]; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx index d2fec1f34755..a4fe52eaacf4 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx @@ -276,8 +276,8 @@ export const AddExceptionModal = memo(function AddExceptionModal({ signalIndexName, ]); - const isSubmitButtonDisabled = useCallback( - () => fetchOrCreateListError || exceptionItemsToAdd.length === 0, + const isSubmitButtonDisabled = useMemo( + () => fetchOrCreateListError || exceptionItemsToAdd.every((item) => item.entries.length === 0), [fetchOrCreateListError, exceptionItemsToAdd] ); @@ -377,7 +377,7 @@ export const AddExceptionModal = memo(function AddExceptionModal({ {i18n.ADD_EXCEPTION} diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_entry_item.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_entry_item.test.tsx index b845848bd14d..3dcc3eb5a832 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_entry_item.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_entry_item.test.tsx @@ -213,7 +213,7 @@ describe('BuilderEntryItem', () => { title: 'logstash-*', fields, }} - showLabel={false} + showLabel={true} listType="detection" addNested={false} onChange={jest.fn()} @@ -245,7 +245,7 @@ describe('BuilderEntryItem', () => { title: 'logstash-*', fields, }} - showLabel={false} + showLabel={true} listType="detection" addNested={false} onChange={jest.fn()} diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_entry_item.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_entry_item.tsx index 736e88ee9fe0..dcc8a0e4fb1b 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_entry_item.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_entry_item.tsx @@ -27,6 +27,7 @@ import { getEntryOnMatchAnyChange, getEntryOnListChange, } from './helpers'; +import { EXCEPTION_OPERATORS_ONLY_LISTS } from '../../autocomplete/operators'; interface EntryItemProps { entry: FormattedBuilderEntry; @@ -35,6 +36,7 @@ interface EntryItemProps { listType: ExceptionListType; addNested: boolean; onChange: (arg: BuilderEntry, i: number) => void; + onlyShowListOperators?: boolean; } export const BuilderEntryItem: React.FC = ({ @@ -44,6 +46,7 @@ export const BuilderEntryItem: React.FC = ({ addNested, showLabel, onChange, + onlyShowListOperators = false, }): JSX.Element => { const handleFieldChange = useCallback( ([newField]: IFieldType[]): void => { @@ -124,11 +127,14 @@ export const BuilderEntryItem: React.FC = ({ ); const renderOperatorInput = (isFirst: boolean): JSX.Element => { - const operatorOptions = getOperatorOptions( - entry, - listType, - entry.field != null && entry.field.type === 'boolean' - ); + const operatorOptions = onlyShowListOperators + ? EXCEPTION_OPERATORS_ONLY_LISTS + : getOperatorOptions( + entry, + listType, + entry.field != null && entry.field.type === 'boolean', + isFirst + ); const comboBox = ( void; onChangeExceptionItem: (item: ExceptionsBuilderExceptionItem, index: number) => void; + onlyShowListOperators?: boolean; } export const ExceptionListItemComponent = React.memo( @@ -58,6 +59,7 @@ export const ExceptionListItemComponent = React.memo( andLogicIncluded, onDeleteExceptionItem, onChangeExceptionItem, + onlyShowListOperators = false, }) => { const handleEntryChange = useCallback( (entry: BuilderEntry, entryIndex: number): void => { @@ -169,6 +171,7 @@ export const ExceptionListItemComponent = React.memo( exceptionItemIndex === 0 && index === 0 && item.nested !== 'child' } onChange={handleEntryChange} + onlyShowListOperators={onlyShowListOperators} /> {getDeleteButton( diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.test.tsx index 8b74d44f29a1..17c94adf4264 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.test.tsx @@ -14,32 +14,33 @@ import { getEntryExistsMock } from '../../../../../../lists/common/schemas/types import { getExceptionListItemSchemaMock } from '../../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; import { getListResponseMock } from '../../../../../../lists/common/schemas/response/list_schema.mock'; import { - isOperator, - isOneOfOperator, - isNotOperator, - isNotOneOfOperator, - existsOperator, doesNotExistOperator, - isInListOperator, EXCEPTION_OPERATORS, + EXCEPTION_OPERATORS_SANS_LISTS, + existsOperator, + isInListOperator, + isNotOneOfOperator, + isNotOperator, + isOneOfOperator, + isOperator, } from '../../autocomplete/operators'; -import { FormattedBuilderEntry, BuilderEntry, ExceptionsBuilderExceptionItem } from '../types'; -import { IIndexPattern, IFieldType } from '../../../../../../../../src/plugins/data/common'; -import { EntryNested, Entry } from '../../../../lists_plugin_deps'; +import { BuilderEntry, ExceptionsBuilderExceptionItem, FormattedBuilderEntry } from '../types'; +import { IFieldType, IIndexPattern } from '../../../../../../../../src/plugins/data/common'; +import { Entry, EntryNested } from '../../../../lists_plugin_deps'; import { - getFilteredIndexPatterns, - getFormattedBuilderEntry, - isEntryNested, - getFormattedBuilderEntries, - getUpdatedEntriesOnDelete, getEntryFromOperator, - getOperatorOptions, getEntryOnFieldChange, - getEntryOnOperatorChange, - getEntryOnMatchChange, - getEntryOnMatchAnyChange, getEntryOnListChange, + getEntryOnMatchAnyChange, + getEntryOnMatchChange, + getEntryOnOperatorChange, + getFilteredIndexPatterns, + getFormattedBuilderEntries, + getFormattedBuilderEntry, + getOperatorOptions, + getUpdatedEntriesOnDelete, + isEntryNested, } from './helpers'; import { OperatorOption } from '../../autocomplete/types'; @@ -672,6 +673,18 @@ describe('Exception builder helpers', () => { const expected: OperatorOption[] = [isOperator, existsOperator]; expect(output).toEqual(expected); }); + + test('it returns list operators if specified to', () => { + const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); + const output = getOperatorOptions(payloadItem, 'detection', false, true); + expect(output).toEqual(EXCEPTION_OPERATORS); + }); + + test('it does not return list operators if specified not to', () => { + const payloadItem: FormattedBuilderEntry = getMockBuilderEntry(); + const output = getOperatorOptions(payloadItem, 'detection', false, false); + expect(output).toEqual(EXCEPTION_OPERATORS_SANS_LISTS); + }); }); describe('#getEntryOnFieldChange', () => { diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.tsx index 2fe2c68941ae..93bae091885c 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.tsx @@ -22,6 +22,7 @@ import { existsOperator, isOneOfOperator, EXCEPTION_OPERATORS, + EXCEPTION_OPERATORS_SANS_LISTS, } from '../../autocomplete/operators'; import { OperatorOption } from '../../autocomplete/types'; import { @@ -40,7 +41,6 @@ import { getEntryValue, getExceptionOperatorSelect } from '../helpers'; * * @param patterns IIndexPattern containing available fields on rule index * @param item exception item entry - * @param addNested boolean noting whether or not UI is currently * set to add a nested field */ export const getFilteredIndexPatterns = ( @@ -295,12 +295,14 @@ export const getEntryFromOperator = ( * * @param item * @param listType - * + * @param isBoolean + * @param includeValueListOperators whether or not to include the 'is in list' and 'is not in list' operators */ export const getOperatorOptions = ( item: FormattedBuilderEntry, listType: ExceptionListType, - isBoolean: boolean + isBoolean: boolean, + includeValueListOperators = true ): OperatorOption[] => { if (item.nested === 'parent' || item.field == null) { return [isOperator]; @@ -309,7 +311,11 @@ export const getOperatorOptions = ( } else if (item.nested != null && listType === 'detection') { return isBoolean ? [isOperator, existsOperator] : [isOperator, isOneOfOperator, existsOperator]; } else { - return isBoolean ? [isOperator, existsOperator] : EXCEPTION_OPERATORS; + return isBoolean + ? [isOperator, existsOperator] + : includeValueListOperators + ? EXCEPTION_OPERATORS + : EXCEPTION_OPERATORS_SANS_LISTS; } }; @@ -547,3 +553,6 @@ export const getDefaultNestedEmptyEntry = (): EmptyNestedEntry => ({ type: OperatorTypeEnum.NESTED, entries: [], }); + +export const containsValueListEntry = (items: ExceptionsBuilderExceptionItem[]): boolean => + items.some((item) => item.entries.some((entry) => entry.type === OperatorTypeEnum.LIST)); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.tsx index 141429f15279..1ec49425ce8f 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.tsx @@ -24,7 +24,11 @@ import { BuilderButtonOptions } from './builder_button_options'; import { getNewExceptionItem, filterExceptionItems } from '../helpers'; import { ExceptionsBuilderExceptionItem, CreateExceptionListItemBuilderSchema } from '../types'; import { State, exceptionsBuilderReducer } from './reducer'; -import { getDefaultEmptyEntry, getDefaultNestedEmptyEntry } from './helpers'; +import { + containsValueListEntry, + getDefaultEmptyEntry, + getDefaultNestedEmptyEntry, +} from './helpers'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import exceptionableFields from '../exceptionable_fields.json'; @@ -44,6 +48,7 @@ const MyButtonsContainer = styled(EuiFlexItem)` const initialState: State = { disableAnd: false, + disableNested: false, disableOr: false, andLogicIncluded: false, addNested: false, @@ -82,12 +87,21 @@ export const ExceptionBuilder = ({ onChange, }: ExceptionBuilderProps) => { const [ - { exceptions, exceptionsToDelete, andLogicIncluded, disableAnd, disableOr, addNested }, + { + exceptions, + exceptionsToDelete, + andLogicIncluded, + disableAnd, + disableNested, + disableOr, + addNested, + }, dispatch, ] = useReducer(exceptionsBuilderReducer(), { ...initialState, disableAnd: isAndDisabled, disableOr: isOrDisabled, + disableNested: isNestedDisabled, }); const setUpdateExceptions = useCallback( @@ -362,6 +376,7 @@ export const ExceptionBuilder = ({ isOnlyItem={exceptions.length === 1} onDeleteExceptionItem={handleDeleteExceptionItem} onChangeExceptionItem={handleExceptionItemChange} + onlyShowListOperators={containsValueListEntry(exceptions)} /> @@ -379,7 +394,7 @@ export const ExceptionBuilder = ({ (state: State, action: Action): St const isAndDisabled = lastEntry != null && lastEntry.type === 'nested' && lastEntry.entries.length === 0; const isOrDisabled = lastEntry != null && lastEntry.type === 'nested'; + const containsValueList = action.exceptions.some( + ({ entries }) => entries.filter(({ type }) => type === OperatorTypeEnum.LIST).length > 0 + ); return { ...state, @@ -67,6 +71,7 @@ export const exceptionsBuilderReducer = () => (state: State, action: Action): St addNested: isAddNested, disableAnd: isAndDisabled, disableOr: isOrDisabled, + disableNested: containsValueList, }; } case 'setDefault': { diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx index 4ad077edf66f..47c3498cb6ab 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { memo, useState, useCallback, useEffect } from 'react'; +import React, { memo, useState, useCallback, useEffect, useMemo } from 'react'; import styled, { css } from 'styled-components'; import { EuiModal, @@ -146,6 +146,11 @@ export const EditExceptionModal = memo(function EditExceptionModal({ } }, [shouldDisableBulkClose]); + const isSubmitButtonDisabled = useMemo( + () => exceptionItemsToAdd.every((item) => item.entries.length === 0), + [exceptionItemsToAdd] + ); + const handleBuilderOnChange = useCallback( ({ exceptionItems, @@ -261,7 +266,12 @@ export const EditExceptionModal = memo(function EditExceptionModal({ {i18n.CANCEL} - + {i18n.EDIT_EXCEPTION_SAVE_BUTTON} From 2a82ff9566423a16e1f976c9d0d2db91acf006a9 Mon Sep 17 00:00:00 2001 From: Mikhail Shustov Date: Sat, 25 Jul 2020 12:59:56 +0300 Subject: [PATCH 141/142] [KP] use new ES client in SO service (#72289) * adapt retryCallCluster for new ES client * review comments * retry on 408 ResponseError * remove legacy retry functions * use Migrator Es client in SO migration * update migration tests * improve ES typings and mocks * migrate decorate ES errors * add repository es client * use new es client in so repository * update repository tests * fix migrator integration tests * declare _seq_no & _primary_term on get response. _source expect to be a string * make _sourceIncludes and refresh compatible with the client * add test for repository_es_client * move ApiResponse to es client mocks * TEMP: handle wait_for as true for deleteByNamespace * add tests for migration_es_client * TEMP: skip test for deleteByNamespace refresh * pass ignore as transport option in mget * log both es client and response errors * fix update method test failures * update deleteByNamespace refresh settings es doesn't support 'refresh: wait_for' for `updateByQuery` endpoint * update repository tests. we do not allow customising wait_for * do not delegate retry logic to es client * fix type errors after master merged * fix repository tests * fix security solutions code SO doesn't throw Error with status code anymore. Always use SO error helpers * switch error conditions to use SO error helpers * cleanup * address comments about mocks * use isResponseError helper * address comments * fix type errors Co-authored-by: pgayvallet --- .../client/configure_client.test.ts | 30 +- .../elasticsearch/client/configure_client.ts | 13 +- src/core/server/elasticsearch/client/index.ts | 2 +- src/core/server/elasticsearch/client/mocks.ts | 34 +- .../client/retry_call_cluster.test.ts | 35 +- .../client/retry_call_cluster.ts | 2 +- src/core/server/elasticsearch/client/types.ts | 80 + .../elasticsearch_service.test.ts | 6 +- src/core/server/elasticsearch/index.ts | 4 + src/core/server/elasticsearch/legacy/index.ts | 1 - .../legacy/retry_call_cluster.test.ts | 147 -- .../legacy/retry_call_cluster.ts | 115 -- .../version_check/ensure_es_version.test.ts | 4 +- .../integration_tests/core_services.test.ts | 4 +- src/core/server/index.ts | 1 + .../__snapshots__/elastic_index.test.ts.snap | 1 - .../migrations/core/elastic_index.test.ts | 608 +++---- .../migrations/core/elastic_index.ts | 119 +- .../saved_objects/migrations/core/index.ts | 1 + .../migrations/core/index_migrator.test.ts | 176 +- .../migrations/core/index_migrator.ts | 27 +- .../migrations/core/migration_context.ts | 27 +- .../core/migration_es_client.test.mock.ts | 22 + .../core/migration_es_client.test.ts | 65 + .../migrations/core/migration_es_client.ts | 90 + .../migrations/kibana/kibana_migrator.test.ts | 53 +- .../migrations/kibana/kibana_migrator.ts | 20 +- .../saved_objects_service.test.ts | 45 +- .../saved_objects/saved_objects_service.ts | 34 +- .../saved_objects/serialization/index.ts | 7 +- .../service/lib/decorate_es_error.test.ts | 101 +- .../service/lib/decorate_es_error.ts | 55 +- .../service/lib/repository.test.js | 1489 ++++++++++------- .../saved_objects/service/lib/repository.ts | 405 ++--- .../lib/repository_es_client.test.mock.ts | 22 + .../service/lib/repository_es_client.test.ts | 64 + .../service/lib/repository_es_client.ts | 56 + .../services/sample_data/routes/list.ts | 4 +- .../{migrations.js => migrations.ts} | 171 +- .../apm/server/lib/apm_telemetry/index.ts | 8 +- .../server/routes/agent/handlers.ts | 2 +- .../exception_lists/create_endpoint_list.ts | 2 +- .../server/endpoint/routes/metadata/index.ts | 12 +- .../endpoint/routes/metadata/metadata.test.ts | 6 +- .../manifest_manager/manifest_manager.ts | 2 +- .../lib/reindexing/reindex_actions.test.ts | 7 +- .../server/lib/reindexing/reindex_actions.ts | 2 +- 47 files changed, 2383 insertions(+), 1798 deletions(-) delete mode 100644 src/core/server/elasticsearch/legacy/retry_call_cluster.test.ts delete mode 100644 src/core/server/elasticsearch/legacy/retry_call_cluster.ts create mode 100644 src/core/server/saved_objects/migrations/core/migration_es_client.test.mock.ts create mode 100644 src/core/server/saved_objects/migrations/core/migration_es_client.test.ts create mode 100644 src/core/server/saved_objects/migrations/core/migration_es_client.ts create mode 100644 src/core/server/saved_objects/service/lib/repository_es_client.test.mock.ts create mode 100644 src/core/server/saved_objects/service/lib/repository_es_client.test.ts create mode 100644 src/core/server/saved_objects/service/lib/repository_es_client.ts rename test/api_integration/apis/saved_objects/{migrations.js => migrations.ts} (68%) diff --git a/src/core/server/elasticsearch/client/configure_client.test.ts b/src/core/server/elasticsearch/client/configure_client.test.ts index 32da142764a7..11e3199a79fd 100644 --- a/src/core/server/elasticsearch/client/configure_client.test.ts +++ b/src/core/server/elasticsearch/client/configure_client.test.ts @@ -118,26 +118,40 @@ describe('configureClient', () => { }); describe('Client logging', () => { - it('logs error when the client emits an error', () => { + it('logs error when the client emits an @elastic/elasticsearch error', () => { + const client = configureClient(config, { logger, scoped: false }); + + const response = createApiResponse({ body: {} }); + client.emit('response', new errors.TimeoutError('message', response), response); + + expect(loggingSystemMock.collect(logger).error).toMatchInlineSnapshot(` + Array [ + Array [ + "[TimeoutError]: message", + ], + ] + `); + }); + + it('logs error when the client emits an ResponseError returned by elasticsearch', () => { const client = configureClient(config, { logger, scoped: false }); const response = createApiResponse({ + statusCode: 400, + headers: {}, body: { error: { - type: 'error message', + type: 'illegal_argument_exception', + reason: 'request [/_path] contains unrecognized parameter: [name]', }, }, }); - client.emit('response', new errors.ResponseError(response), null); - client.emit('response', new Error('some error'), null); + client.emit('response', new errors.ResponseError(response), response); expect(loggingSystemMock.collect(logger).error).toMatchInlineSnapshot(` Array [ Array [ - "ResponseError: error message", - ], - Array [ - "Error: some error", + "[illegal_argument_exception]: request [/_path] contains unrecognized parameter: [name]", ], ] `); diff --git a/src/core/server/elasticsearch/client/configure_client.ts b/src/core/server/elasticsearch/client/configure_client.ts index 5377f8ca1b07..9746ecb538b7 100644 --- a/src/core/server/elasticsearch/client/configure_client.ts +++ b/src/core/server/elasticsearch/client/configure_client.ts @@ -21,6 +21,7 @@ import { stringify } from 'querystring'; import { Client } from '@elastic/elasticsearch'; import { Logger } from '../../logging'; import { parseClientOptions, ElasticsearchClientConfig } from './client_config'; +import { isResponseError } from './errors'; export const configureClient = ( config: ElasticsearchClientConfig, @@ -35,9 +36,15 @@ export const configureClient = ( }; const addLogging = (client: Client, logger: Logger, logQueries: boolean) => { - client.on('response', (err, event) => { - if (err) { - logger.error(`${err.name}: ${err.message}`); + client.on('response', (error, event) => { + if (error) { + const errorMessage = + // error details for response errors provided by elasticsearch + isResponseError(error) + ? `[${event.body.error.type}]: ${event.body.error.reason}` + : `[${error.name}]: ${error.message}`; + + logger.error(errorMessage); } if (event && logQueries) { const params = event.meta.request.params; diff --git a/src/core/server/elasticsearch/client/index.ts b/src/core/server/elasticsearch/client/index.ts index b8125de2ee49..af63dfa6c7f4 100644 --- a/src/core/server/elasticsearch/client/index.ts +++ b/src/core/server/elasticsearch/client/index.ts @@ -17,7 +17,7 @@ * under the License. */ -export { ElasticsearchClient } from './types'; +export * from './types'; export { IScopedClusterClient, ScopedClusterClient } from './scoped_cluster_client'; export { ElasticsearchClientConfig } from './client_config'; export { IClusterClient, ICustomClusterClient, ClusterClient } from './cluster_client'; diff --git a/src/core/server/elasticsearch/client/mocks.ts b/src/core/server/elasticsearch/client/mocks.ts index ec2885dfdf92..c93294404b52 100644 --- a/src/core/server/elasticsearch/client/mocks.ts +++ b/src/core/server/elasticsearch/client/mocks.ts @@ -45,7 +45,7 @@ const createInternalClientMock = (): DeeplyMockedKeys => { .forEach((key) => { const propType = typeof obj[key]; if (propType === 'function') { - obj[key] = jest.fn(); + obj[key] = jest.fn(() => createSuccessTransportRequestPromise({})); } else if (propType === 'object' && obj[key] != null) { mockify(obj[key]); } @@ -70,6 +70,7 @@ const createInternalClientMock = (): DeeplyMockedKeys => { return (mock as unknown) as DeeplyMockedKeys; }; +// TODO fix naming ElasticsearchClientMock export type ElasticSearchClientMock = DeeplyMockedKeys; const createClientMock = (): ElasticSearchClientMock => @@ -124,32 +125,41 @@ export type MockedTransportRequestPromise = TransportRequestPromise & { abort: jest.MockedFunction<() => undefined>; }; -const createMockedClientResponse = (body: T): MockedTransportRequestPromise> => { - const response: ApiResponse = { - body, - statusCode: 200, - warnings: [], - headers: {}, - meta: {} as any, - }; +const createSuccessTransportRequestPromise = ( + body: T, + { statusCode = 200 }: { statusCode?: number } = {} +): MockedTransportRequestPromise> => { + const response = createApiResponse({ body, statusCode }); const promise = Promise.resolve(response); (promise as MockedTransportRequestPromise>).abort = jest.fn(); return promise as MockedTransportRequestPromise>; }; -const createMockedClientError = (err: any): MockedTransportRequestPromise => { +const createErrorTransportRequestPromise = (err: any): MockedTransportRequestPromise => { const promise = Promise.reject(err); (promise as MockedTransportRequestPromise).abort = jest.fn(); return promise as MockedTransportRequestPromise; }; +function createApiResponse(opts: Partial = {}): ApiResponse { + return { + body: {}, + statusCode: 200, + headers: {}, + warnings: [], + meta: {} as any, + ...opts, + }; +} + export const elasticsearchClientMock = { createClusterClient: createClusterClientMock, createCustomClusterClient: createCustomClusterClientMock, createScopedClusterClient: createScopedClusterClientMock, createElasticSearchClient: createClientMock, createInternalClient: createInternalClientMock, - createClientResponse: createMockedClientResponse, - createClientError: createMockedClientError, + createSuccessTransportRequestPromise, + createErrorTransportRequestPromise, + createApiResponse, }; diff --git a/src/core/server/elasticsearch/client/retry_call_cluster.test.ts b/src/core/server/elasticsearch/client/retry_call_cluster.test.ts index a7177c0b2904..3aa47e8b40e2 100644 --- a/src/core/server/elasticsearch/client/retry_call_cluster.test.ts +++ b/src/core/server/elasticsearch/client/retry_call_cluster.test.ts @@ -23,7 +23,8 @@ import { loggingSystemMock } from '../../logging/logging_system.mock'; import { retryCallCluster, migrationRetryCallCluster } from './retry_call_cluster'; const dummyBody = { foo: 'bar' }; -const createErrorReturn = (err: any) => elasticsearchClientMock.createClientError(err); +const createErrorReturn = (err: any) => + elasticsearchClientMock.createErrorTransportRequestPromise(err); describe('retryCallCluster', () => { let client: ReturnType; @@ -33,7 +34,9 @@ describe('retryCallCluster', () => { }); it('returns response from ES API call in case of success', async () => { - const successReturn = elasticsearchClientMock.createClientResponse({ ...dummyBody }); + const successReturn = elasticsearchClientMock.createSuccessTransportRequestPromise({ + ...dummyBody, + }); client.asyncSearch.get.mockReturnValue(successReturn); @@ -42,7 +45,9 @@ describe('retryCallCluster', () => { }); it('retries ES API calls that rejects with `NoLivingConnectionsError`', async () => { - const successReturn = elasticsearchClientMock.createClientResponse({ ...dummyBody }); + const successReturn = elasticsearchClientMock.createSuccessTransportRequestPromise({ + ...dummyBody, + }); client.asyncSearch.get .mockImplementationOnce(() => @@ -57,7 +62,9 @@ describe('retryCallCluster', () => { it('rejects when ES API calls reject with other errors', async () => { client.ping .mockImplementationOnce(() => createErrorReturn(new Error('unknown error'))) - .mockImplementationOnce(() => elasticsearchClientMock.createClientResponse({ ...dummyBody })); + .mockImplementationOnce(() => + elasticsearchClientMock.createSuccessTransportRequestPromise({ ...dummyBody }) + ); await expect(retryCallCluster(() => client.ping())).rejects.toMatchInlineSnapshot( `[Error: unknown error]` @@ -73,7 +80,9 @@ describe('retryCallCluster', () => { createErrorReturn(new errors.NoLivingConnectionsError('no living connections', {} as any)) ) .mockImplementationOnce(() => createErrorReturn(new Error('unknown error'))) - .mockImplementationOnce(() => elasticsearchClientMock.createClientResponse({ ...dummyBody })); + .mockImplementationOnce(() => + elasticsearchClientMock.createSuccessTransportRequestPromise({ ...dummyBody }) + ); await expect(retryCallCluster(() => client.ping())).rejects.toMatchInlineSnapshot( `[Error: unknown error]` @@ -94,7 +103,9 @@ describe('migrationRetryCallCluster', () => { client.ping .mockImplementationOnce(() => createErrorReturn(error)) .mockImplementationOnce(() => createErrorReturn(error)) - .mockImplementationOnce(() => elasticsearchClientMock.createClientResponse({ ...dummyBody })); + .mockImplementationOnce(() => + elasticsearchClientMock.createSuccessTransportRequestPromise({ ...dummyBody }) + ); }; it('retries ES API calls that rejects with `NoLivingConnectionsError`', async () => { @@ -225,7 +236,9 @@ describe('migrationRetryCallCluster', () => { } as any) ) ) - .mockImplementationOnce(() => elasticsearchClientMock.createClientResponse({ ...dummyBody })); + .mockImplementationOnce(() => + elasticsearchClientMock.createSuccessTransportRequestPromise({ ...dummyBody }) + ); await migrationRetryCallCluster(() => client.ping(), logger, 1); @@ -258,7 +271,9 @@ describe('migrationRetryCallCluster', () => { } as any) ) ) - .mockImplementationOnce(() => elasticsearchClientMock.createClientResponse({ ...dummyBody })); + .mockImplementationOnce(() => + elasticsearchClientMock.createSuccessTransportRequestPromise({ ...dummyBody }) + ); await expect( migrationRetryCallCluster(() => client.ping(), logger, 1) @@ -274,7 +289,9 @@ describe('migrationRetryCallCluster', () => { createErrorReturn(new errors.TimeoutError('timeout error', {} as any)) ) .mockImplementationOnce(() => createErrorReturn(new Error('unknown error'))) - .mockImplementationOnce(() => elasticsearchClientMock.createClientResponse({ ...dummyBody })); + .mockImplementationOnce(() => + elasticsearchClientMock.createSuccessTransportRequestPromise({ ...dummyBody }) + ); await expect( migrationRetryCallCluster(() => client.ping(), logger, 1) diff --git a/src/core/server/elasticsearch/client/retry_call_cluster.ts b/src/core/server/elasticsearch/client/retry_call_cluster.ts index 1ad039e51221..792f7f0a7fac 100644 --- a/src/core/server/elasticsearch/client/retry_call_cluster.ts +++ b/src/core/server/elasticsearch/client/retry_call_cluster.ts @@ -27,7 +27,7 @@ const retryResponseStatuses = [ 403, // AuthenticationException 408, // RequestTimeout 410, // Gone -]; +] as const; /** * Retries the provided Elasticsearch API call when a `NoLivingConnectionsError` error is diff --git a/src/core/server/elasticsearch/client/types.ts b/src/core/server/elasticsearch/client/types.ts index 7ce998aab766..285f52e89a59 100644 --- a/src/core/server/elasticsearch/client/types.ts +++ b/src/core/server/elasticsearch/client/types.ts @@ -41,3 +41,83 @@ export type ElasticsearchClient = Omit< ): TransportRequestPromise; }; }; + +interface ShardsResponse { + total: number; + successful: number; + failed: number; + skipped: number; +} + +interface Explanation { + value: number; + description: string; + details: Explanation[]; +} + +interface ShardsInfo { + total: number; + successful: number; + skipped: number; + failed: number; +} + +export interface CountResponse { + _shards: ShardsInfo; + count: number; +} + +/** + * Maintained until elasticsearch provides response typings out of the box + * https://github.com/elastic/elasticsearch-js/pull/970 + */ +export interface SearchResponse { + took: number; + timed_out: boolean; + _scroll_id?: string; + _shards: ShardsResponse; + hits: { + total: number; + max_score: number; + hits: Array<{ + _index: string; + _type: string; + _id: string; + _score: number; + _source: T; + _version?: number; + _explanation?: Explanation; + fields?: any; + highlight?: any; + inner_hits?: any; + matched_queries?: string[]; + sort?: string[]; + }>; + }; + aggregations?: any; +} + +export interface GetResponse { + _index: string; + _type: string; + _id: string; + _version: number; + _routing?: string; + found: boolean; + _source: T; + _seq_no: number; + _primary_term: number; +} + +export interface DeleteDocumentResponse { + _shards: ShardsResponse; + found: boolean; + _index: string; + _type: string; + _id: string; + _version: number; + result: string; + error?: { + type: string; + }; +} diff --git a/src/core/server/elasticsearch/elasticsearch_service.test.ts b/src/core/server/elasticsearch/elasticsearch_service.test.ts index 4375f09f1ce0..49f5c8dd9879 100644 --- a/src/core/server/elasticsearch/elasticsearch_service.test.ts +++ b/src/core/server/elasticsearch/elasticsearch_service.test.ts @@ -227,7 +227,7 @@ describe('#setup', () => { it('esNodeVersionCompatibility$ only starts polling when subscribed to', async (done) => { const mockedClient = mockClusterClientInstance.asInternalUser; mockedClient.nodes.info.mockImplementation(() => - elasticsearchClientMock.createClientError(new Error()) + elasticsearchClientMock.createErrorTransportRequestPromise(new Error()) ); const setupContract = await elasticsearchService.setup(setupDeps); @@ -243,7 +243,7 @@ describe('#setup', () => { it('esNodeVersionCompatibility$ stops polling when unsubscribed from', async (done) => { const mockedClient = mockClusterClientInstance.asInternalUser; mockedClient.nodes.info.mockImplementation(() => - elasticsearchClientMock.createClientError(new Error()) + elasticsearchClientMock.createErrorTransportRequestPromise(new Error()) ); const setupContract = await elasticsearchService.setup(setupDeps); @@ -359,7 +359,7 @@ describe('#stop', () => { const mockedClient = mockClusterClientInstance.asInternalUser; mockedClient.nodes.info.mockImplementation(() => - elasticsearchClientMock.createClientError(new Error()) + elasticsearchClientMock.createErrorTransportRequestPromise(new Error()) ); const setupContract = await elasticsearchService.setup(setupDeps); diff --git a/src/core/server/elasticsearch/index.ts b/src/core/server/elasticsearch/index.ts index 8bb77b5dfdee..32be6e6bf34d 100644 --- a/src/core/server/elasticsearch/index.ts +++ b/src/core/server/elasticsearch/index.ts @@ -36,4 +36,8 @@ export { ElasticsearchClientConfig, ElasticsearchClient, IScopedClusterClient, + SearchResponse, + GetResponse, + DeleteDocumentResponse, + CountResponse, } from './client'; diff --git a/src/core/server/elasticsearch/legacy/index.ts b/src/core/server/elasticsearch/legacy/index.ts index 165980b9f452..a1740faac7dd 100644 --- a/src/core/server/elasticsearch/legacy/index.ts +++ b/src/core/server/elasticsearch/legacy/index.ts @@ -23,6 +23,5 @@ export { } from './cluster_client'; export { ILegacyScopedClusterClient, LegacyScopedClusterClient } from './scoped_cluster_client'; export { LegacyElasticsearchClientConfig } from './elasticsearch_client_config'; -export { retryCallCluster, migrationsRetryCallCluster } from './retry_call_cluster'; export { LegacyElasticsearchError, LegacyElasticsearchErrorHelpers } from './errors'; export * from './api_types'; diff --git a/src/core/server/elasticsearch/legacy/retry_call_cluster.test.ts b/src/core/server/elasticsearch/legacy/retry_call_cluster.test.ts deleted file mode 100644 index 62789a4fe952..000000000000 --- a/src/core/server/elasticsearch/legacy/retry_call_cluster.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ -import * as legacyElasticsearch from 'elasticsearch'; - -import { retryCallCluster, migrationsRetryCallCluster } from './retry_call_cluster'; -import { loggingSystemMock } from '../../logging/logging_system.mock'; - -describe('retryCallCluster', () => { - it('retries ES API calls that rejects with NoConnections', () => { - expect.assertions(1); - const callEsApi = jest.fn(); - let i = 0; - const ErrorConstructor = legacyElasticsearch.errors.NoConnections; - callEsApi.mockImplementation(() => { - return i++ <= 2 ? Promise.reject(new ErrorConstructor()) : Promise.resolve('success'); - }); - const retried = retryCallCluster(callEsApi); - return expect(retried('endpoint')).resolves.toMatchInlineSnapshot(`"success"`); - }); - - it('rejects when ES API calls reject with other errors', async () => { - expect.assertions(3); - const callEsApi = jest.fn(); - let i = 0; - callEsApi.mockImplementation(() => { - i++; - - return i === 1 - ? Promise.reject(new Error('unknown error')) - : i === 2 - ? Promise.resolve('success') - : i === 3 || i === 4 - ? Promise.reject(new legacyElasticsearch.errors.NoConnections()) - : i === 5 - ? Promise.reject(new Error('unknown error')) - : null; - }); - const retried = retryCallCluster(callEsApi); - await expect(retried('endpoint')).rejects.toMatchInlineSnapshot(`[Error: unknown error]`); - await expect(retried('endpoint')).resolves.toMatchInlineSnapshot(`"success"`); - return expect(retried('endpoint')).rejects.toMatchInlineSnapshot(`[Error: unknown error]`); - }); -}); - -describe('migrationsRetryCallCluster', () => { - const errors = [ - 'NoConnections', - 'ConnectionFault', - 'ServiceUnavailable', - 'RequestTimeout', - 'AuthenticationException', - 'AuthorizationException', - 'Gone', - ]; - - const mockLogger = loggingSystemMock.create(); - - beforeEach(() => { - loggingSystemMock.clear(mockLogger); - }); - - errors.forEach((errorName) => { - it('retries ES API calls that rejects with ' + errorName, () => { - expect.assertions(1); - const callEsApi = jest.fn(); - let i = 0; - const ErrorConstructor = (legacyElasticsearch.errors as any)[errorName]; - callEsApi.mockImplementation(() => { - return i++ <= 2 ? Promise.reject(new ErrorConstructor()) : Promise.resolve('success'); - }); - const retried = migrationsRetryCallCluster(callEsApi, mockLogger.get('mock log'), 1); - return expect(retried('endpoint')).resolves.toMatchInlineSnapshot(`"success"`); - }); - }); - - it('retries ES API calls that rejects with snapshot_in_progress_exception', () => { - expect.assertions(1); - const callEsApi = jest.fn(); - let i = 0; - callEsApi.mockImplementation(() => { - return i++ <= 2 - ? Promise.reject({ body: { error: { type: 'snapshot_in_progress_exception' } } }) - : Promise.resolve('success'); - }); - const retried = migrationsRetryCallCluster(callEsApi, mockLogger.get('mock log'), 1); - return expect(retried('endpoint')).resolves.toMatchInlineSnapshot(`"success"`); - }); - - it('rejects when ES API calls reject with other errors', async () => { - expect.assertions(3); - const callEsApi = jest.fn(); - let i = 0; - callEsApi.mockImplementation(() => { - i++; - - return i === 1 - ? Promise.reject(new Error('unknown error')) - : i === 2 - ? Promise.resolve('success') - : i === 3 || i === 4 - ? Promise.reject(new legacyElasticsearch.errors.NoConnections()) - : i === 5 - ? Promise.reject(new Error('unknown error')) - : null; - }); - const retried = migrationsRetryCallCluster(callEsApi, mockLogger.get('mock log'), 1); - await expect(retried('endpoint')).rejects.toMatchInlineSnapshot(`[Error: unknown error]`); - await expect(retried('endpoint')).resolves.toMatchInlineSnapshot(`"success"`); - return expect(retried('endpoint')).rejects.toMatchInlineSnapshot(`[Error: unknown error]`); - }); - - it('logs only once for each unique error message', async () => { - const callEsApi = jest.fn(); - callEsApi.mockRejectedValueOnce(new legacyElasticsearch.errors.NoConnections()); - callEsApi.mockRejectedValueOnce(new legacyElasticsearch.errors.NoConnections()); - callEsApi.mockRejectedValueOnce(new legacyElasticsearch.errors.AuthenticationException()); - callEsApi.mockResolvedValueOnce('done'); - const retried = migrationsRetryCallCluster(callEsApi, mockLogger.get('mock log'), 1); - await retried('endpoint'); - expect(loggingSystemMock.collect(mockLogger).warn).toMatchInlineSnapshot(` - Array [ - Array [ - "Unable to connect to Elasticsearch. Error: No Living connections", - ], - Array [ - "Unable to connect to Elasticsearch. Error: Authentication Exception", - ], - ] - `); - }); -}); diff --git a/src/core/server/elasticsearch/legacy/retry_call_cluster.ts b/src/core/server/elasticsearch/legacy/retry_call_cluster.ts deleted file mode 100644 index 1b05cb2bf13c..000000000000 --- a/src/core/server/elasticsearch/legacy/retry_call_cluster.ts +++ /dev/null @@ -1,115 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { retryWhen, concatMap } from 'rxjs/operators'; -import { defer, throwError, iif, timer } from 'rxjs'; -import * as legacyElasticsearch from 'elasticsearch'; - -import { LegacyCallAPIOptions } from '.'; -import { LegacyAPICaller } from './api_types'; -import { Logger } from '../../logging'; - -const esErrors = legacyElasticsearch.errors; - -/** - * Retries the provided Elasticsearch API call when an error such as - * `AuthenticationException` `NoConnections`, `ConnectionFault`, - * `ServiceUnavailable` or `RequestTimeout` are encountered. The API call will - * be retried once a second, indefinitely, until a successful response or a - * different error is received. - * - * @param apiCaller - * @param log - * @param delay - */ -export function migrationsRetryCallCluster( - apiCaller: LegacyAPICaller, - log: Logger, - delay: number = 2500 -) { - const previousErrors: string[] = []; - return ( - endpoint: string, - clientParams: Record = {}, - options?: LegacyCallAPIOptions - ) => { - return defer(() => apiCaller(endpoint, clientParams, options)) - .pipe( - retryWhen((error$) => - error$.pipe( - concatMap((error) => { - if (!previousErrors.includes(error.message)) { - log.warn(`Unable to connect to Elasticsearch. Error: ${error.message}`); - previousErrors.push(error.message); - } - return iif( - () => { - return ( - error instanceof esErrors.NoConnections || - error instanceof esErrors.ConnectionFault || - error instanceof esErrors.ServiceUnavailable || - error instanceof esErrors.RequestTimeout || - error instanceof esErrors.AuthenticationException || - error instanceof esErrors.AuthorizationException || - // @ts-expect-error - error instanceof esErrors.Gone || - error?.body?.error?.type === 'snapshot_in_progress_exception' - ); - }, - timer(delay), - throwError(error) - ); - }) - ) - ) - ) - .toPromise(); - }; -} - -/** - * Retries the provided Elasticsearch API call when a `NoConnections` error is - * encountered. The API call will be retried once a second, indefinitely, until - * a successful response or a different error is received. - * - * @param apiCaller - */ -export function retryCallCluster(apiCaller: LegacyAPICaller) { - return ( - endpoint: string, - clientParams: Record = {}, - options?: LegacyCallAPIOptions - ) => { - return defer(() => apiCaller(endpoint, clientParams, options)) - .pipe( - retryWhen((errors) => - errors.pipe( - concatMap((error) => - iif( - () => error instanceof legacyElasticsearch.errors.NoConnections, - timer(1000), - throwError(error) - ) - ) - ) - ) - ) - .toPromise(); - }; -} diff --git a/src/core/server/elasticsearch/version_check/ensure_es_version.test.ts b/src/core/server/elasticsearch/version_check/ensure_es_version.test.ts index 21adac081acf..f6313f68abff 100644 --- a/src/core/server/elasticsearch/version_check/ensure_es_version.test.ts +++ b/src/core/server/elasticsearch/version_check/ensure_es_version.test.ts @@ -28,8 +28,8 @@ const mockLogger = mockLoggerFactory.get('mock logger'); const KIBANA_VERSION = '5.1.0'; -const createEsSuccess = elasticsearchClientMock.createClientResponse; -const createEsError = elasticsearchClientMock.createClientError; +const createEsSuccess = elasticsearchClientMock.createSuccessTransportRequestPromise; +const createEsError = elasticsearchClientMock.createErrorTransportRequestPromise; function createNodes(...versions: string[]): NodesInfo { const nodes = {} as any; diff --git a/src/core/server/http/integration_tests/core_services.test.ts b/src/core/server/http/integration_tests/core_services.test.ts index 6338326626d5..6a00db5a6cc4 100644 --- a/src/core/server/http/integration_tests/core_services.test.ts +++ b/src/core/server/http/integration_tests/core_services.test.ts @@ -479,7 +479,7 @@ describe('http service', () => { let elasticsearch: InternalElasticsearchServiceStart; esClient.ping.mockImplementation(() => - elasticsearchClientMock.createClientError( + elasticsearchClientMock.createErrorTransportRequestPromise( new ResponseError({ statusCode: 401, body: { @@ -517,7 +517,7 @@ describe('http service', () => { let elasticsearch: InternalElasticsearchServiceStart; esClient.ping.mockImplementation(() => - elasticsearchClientMock.createClientError( + elasticsearchClientMock.createErrorTransportRequestPromise( new ResponseError({ statusCode: 401, body: { diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 706ec88c6ebf..c846e81573ac 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -109,6 +109,7 @@ export { LegacyAPICaller, FakeRequest, ScopeableRequest, + ElasticsearchClient, } from './elasticsearch'; export * from './elasticsearch/legacy/api_types'; export { diff --git a/src/core/server/saved_objects/migrations/core/__snapshots__/elastic_index.test.ts.snap b/src/core/server/saved_objects/migrations/core/__snapshots__/elastic_index.test.ts.snap index 76bcc6ee219d..6bd567be204d 100644 --- a/src/core/server/saved_objects/migrations/core/__snapshots__/elastic_index.test.ts.snap +++ b/src/core/server/saved_objects/migrations/core/__snapshots__/elastic_index.test.ts.snap @@ -2,7 +2,6 @@ exports[`ElasticIndex write writes documents in bulk to the index 1`] = ` Array [ - "bulk", Object { "body": Array [ Object { diff --git a/src/core/server/saved_objects/migrations/core/elastic_index.test.ts b/src/core/server/saved_objects/migrations/core/elastic_index.test.ts index 393cbb7fbb2a..fb8fb4ef9508 100644 --- a/src/core/server/saved_objects/migrations/core/elastic_index.test.ts +++ b/src/core/server/saved_objects/migrations/core/elastic_index.test.ts @@ -18,47 +18,52 @@ */ import _ from 'lodash'; +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; import * as Index from './elastic_index'; describe('ElasticIndex', () => { + let client: ReturnType; + + beforeEach(() => { + client = elasticsearchClientMock.createElasticSearchClient(); + }); describe('fetchInfo', () => { test('it handles 404', async () => { - const callCluster = jest - .fn() - .mockImplementation(async (path: string, { ignore, index }: any) => { - expect(path).toEqual('indices.get'); - expect(ignore).toEqual([404]); - expect(index).toEqual('.kibana-test'); - return { status: 404 }; - }); + client.indices.get.mockResolvedValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) + ); - const info = await Index.fetchInfo(callCluster as any, '.kibana-test'); + const info = await Index.fetchInfo(client, '.kibana-test'); expect(info).toEqual({ aliases: {}, exists: false, indexName: '.kibana-test', mappings: { dynamic: 'strict', properties: {} }, }); + + expect(client.indices.get).toHaveBeenCalledWith({ index: '.kibana-test' }, { ignore: [404] }); }); test('fails if the index doc type is unsupported', async () => { - const callCluster = jest.fn(async (path: string, { index }: any) => { - return { + client.indices.get.mockImplementation((params) => { + const index = params!.index as string; + return elasticsearchClientMock.createSuccessTransportRequestPromise({ [index]: { aliases: { foo: index }, mappings: { spock: { dynamic: 'strict', properties: { a: 'b' } } }, }, - }; + }); }); - await expect(Index.fetchInfo(callCluster as any, '.baz')).rejects.toThrow( + await expect(Index.fetchInfo(client, '.baz')).rejects.toThrow( /cannot be automatically migrated/ ); }); test('fails if there are multiple root types', async () => { - const callCluster = jest.fn().mockImplementation(async (path: string, { index }: any) => { - return { + client.indices.get.mockImplementation((params) => { + const index = params!.index as string; + return elasticsearchClientMock.createSuccessTransportRequestPromise({ [index]: { aliases: { foo: index }, mappings: { @@ -66,25 +71,26 @@ describe('ElasticIndex', () => { doctor: { dynamic: 'strict', properties: { a: 'b' } }, }, }, - }; + }); }); - await expect(Index.fetchInfo(callCluster, '.baz')).rejects.toThrow( + await expect(Index.fetchInfo(client, '.baz')).rejects.toThrow( /cannot be automatically migrated/ ); }); test('decorates index info with exists and indexName', async () => { - const callCluster = jest.fn().mockImplementation(async (path: string, { index }: any) => { - return { + client.indices.get.mockImplementation((params) => { + const index = params!.index as string; + return elasticsearchClientMock.createSuccessTransportRequestPromise({ [index]: { aliases: { foo: index }, mappings: { dynamic: 'strict', properties: { a: 'b' } }, }, - }; + }); }); - const info = await Index.fetchInfo(callCluster, '.baz'); + const info = await Index.fetchInfo(client, '.baz'); expect(info).toEqual({ aliases: { foo: '.baz' }, mappings: { dynamic: 'strict', properties: { a: 'b' } }, @@ -96,171 +102,120 @@ describe('ElasticIndex', () => { describe('createIndex', () => { test('calls indices.create', async () => { - const callCluster = jest.fn(async (path: string, { body, index }: any) => { - expect(path).toEqual('indices.create'); - expect(body).toEqual({ + await Index.createIndex(client, '.abcd', { foo: 'bar' } as any); + + expect(client.indices.create).toHaveBeenCalledTimes(1); + expect(client.indices.create).toHaveBeenCalledWith({ + body: { mappings: { foo: 'bar' }, - settings: { auto_expand_replicas: '0-1', number_of_shards: 1 }, - }); - expect(index).toEqual('.abcd'); + settings: { + auto_expand_replicas: '0-1', + number_of_shards: 1, + }, + }, + index: '.abcd', }); - - await Index.createIndex(callCluster as any, '.abcd', { foo: 'bar' } as any); - expect(callCluster).toHaveBeenCalled(); }); }); describe('deleteIndex', () => { test('calls indices.delete', async () => { - const callCluster = jest.fn(async (path: string, { index }: any) => { - expect(path).toEqual('indices.delete'); - expect(index).toEqual('.lotr'); - }); + await Index.deleteIndex(client, '.lotr'); - await Index.deleteIndex(callCluster as any, '.lotr'); - expect(callCluster).toHaveBeenCalled(); + expect(client.indices.delete).toHaveBeenCalledTimes(1); + expect(client.indices.delete).toHaveBeenCalledWith({ + index: '.lotr', + }); }); }); describe('claimAlias', () => { - function assertCalled(callCluster: jest.Mock) { - expect(callCluster.mock.calls.map(([path]) => path)).toEqual([ - 'indices.getAlias', - 'indices.updateAliases', - 'indices.refresh', - ]); - } - test('handles unaliased indices', async () => { - const callCluster = jest.fn(async (path: string, arg: any) => { - switch (path) { - case 'indices.getAlias': - expect(arg.ignore).toEqual([404]); - expect(arg.name).toEqual('.hola'); - return { status: 404 }; - case 'indices.updateAliases': - expect(arg.body).toEqual({ - actions: [{ add: { index: '.hola-42', alias: '.hola' } }], - }); - return true; - case 'indices.refresh': - expect(arg.index).toEqual('.hola-42'); - return true; - default: - throw new Error(`Dunnoes what ${path} means.`); - } - }); + client.indices.getAlias.mockResolvedValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) + ); - await Index.claimAlias(callCluster as any, '.hola-42', '.hola'); + await Index.claimAlias(client, '.hola-42', '.hola'); - assertCalled(callCluster); + expect(client.indices.getAlias).toHaveBeenCalledWith( + { + name: '.hola', + }, + { ignore: [404] } + ); + expect(client.indices.updateAliases).toHaveBeenCalledWith({ + body: { + actions: [{ add: { index: '.hola-42', alias: '.hola' } }], + }, + }); + expect(client.indices.refresh).toHaveBeenCalledWith({ + index: '.hola-42', + }); }); test('removes existing alias', async () => { - const callCluster = jest.fn(async (path: string, arg: any) => { - switch (path) { - case 'indices.getAlias': - return { '.my-fanci-index': '.muchacha' }; - case 'indices.updateAliases': - expect(arg.body).toEqual({ - actions: [ - { remove: { index: '.my-fanci-index', alias: '.muchacha' } }, - { add: { index: '.ze-index', alias: '.muchacha' } }, - ], - }); - return true; - case 'indices.refresh': - expect(arg.index).toEqual('.ze-index'); - return true; - default: - throw new Error(`Dunnoes what ${path} means.`); - } - }); + client.indices.getAlias.mockResolvedValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + '.my-fanci-index': '.muchacha', + }) + ); - await Index.claimAlias(callCluster as any, '.ze-index', '.muchacha'); + await Index.claimAlias(client, '.ze-index', '.muchacha'); - assertCalled(callCluster); + expect(client.indices.getAlias).toHaveBeenCalledTimes(1); + expect(client.indices.updateAliases).toHaveBeenCalledWith({ + body: { + actions: [ + { remove: { index: '.my-fanci-index', alias: '.muchacha' } }, + { add: { index: '.ze-index', alias: '.muchacha' } }, + ], + }, + }); + expect(client.indices.refresh).toHaveBeenCalledWith({ + index: '.ze-index', + }); }); test('allows custom alias actions', async () => { - const callCluster = jest.fn(async (path: string, arg: any) => { - switch (path) { - case 'indices.getAlias': - return { '.my-fanci-index': '.muchacha' }; - case 'indices.updateAliases': - expect(arg.body).toEqual({ - actions: [ - { remove_index: { index: 'awww-snap!' } }, - { remove: { index: '.my-fanci-index', alias: '.muchacha' } }, - { add: { index: '.ze-index', alias: '.muchacha' } }, - ], - }); - return true; - case 'indices.refresh': - expect(arg.index).toEqual('.ze-index'); - return true; - default: - throw new Error(`Dunnoes what ${path} means.`); - } - }); + client.indices.getAlias.mockResolvedValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + '.my-fanci-index': '.muchacha', + }) + ); - await Index.claimAlias(callCluster as any, '.ze-index', '.muchacha', [ + await Index.claimAlias(client, '.ze-index', '.muchacha', [ { remove_index: { index: 'awww-snap!' } }, ]); - assertCalled(callCluster); + expect(client.indices.getAlias).toHaveBeenCalledTimes(1); + expect(client.indices.updateAliases).toHaveBeenCalledWith({ + body: { + actions: [ + { remove_index: { index: 'awww-snap!' } }, + { remove: { index: '.my-fanci-index', alias: '.muchacha' } }, + { add: { index: '.ze-index', alias: '.muchacha' } }, + ], + }, + }); + expect(client.indices.refresh).toHaveBeenCalledWith({ + index: '.ze-index', + }); }); }); describe('convertToAlias', () => { test('it creates the destination index, then reindexes to it', async () => { - const callCluster = jest.fn(async (path: string, arg: any) => { - switch (path) { - case 'indices.create': - expect(arg.body).toEqual({ - mappings: { - dynamic: 'strict', - properties: { foo: { type: 'keyword' } }, - }, - settings: { auto_expand_replicas: '0-1', number_of_shards: 1 }, - }); - expect(arg.index).toEqual('.ze-index'); - return true; - case 'reindex': - expect(arg).toMatchObject({ - body: { - dest: { index: '.ze-index' }, - source: { index: '.muchacha' }, - script: { - source: `ctx._id = ctx._source.type + ':' + ctx._id`, - lang: 'painless', - }, - }, - refresh: true, - waitForCompletion: false, - }); - return { task: 'abc' }; - case 'tasks.get': - expect(arg.taskId).toEqual('abc'); - return { completed: true }; - case 'indices.getAlias': - return { '.my-fanci-index': '.muchacha' }; - case 'indices.updateAliases': - expect(arg.body).toEqual({ - actions: [ - { remove_index: { index: '.muchacha' } }, - { remove: { alias: '.muchacha', index: '.my-fanci-index' } }, - { add: { index: '.ze-index', alias: '.muchacha' } }, - ], - }); - return true; - case 'indices.refresh': - expect(arg.index).toEqual('.ze-index'); - return true; - default: - throw new Error(`Dunnoes what ${path} means.`); - } - }); + client.indices.getAlias.mockResolvedValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + '.my-fanci-index': '.muchacha', + }) + ); + client.reindex.mockResolvedValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ task: 'abc' }) + ); + client.tasks.get.mockResolvedValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ completed: true }) + ); const info = { aliases: {}, @@ -271,61 +226,77 @@ describe('ElasticIndex', () => { properties: { foo: { type: 'keyword' } }, }, }; + await Index.convertToAlias( - callCluster as any, + client, info, '.muchacha', 10, `ctx._id = ctx._source.type + ':' + ctx._id` ); - expect(callCluster.mock.calls.map(([path]) => path)).toEqual([ - 'indices.create', - 'reindex', - 'tasks.get', - 'indices.getAlias', - 'indices.updateAliases', - 'indices.refresh', - ]); + expect(client.indices.create).toHaveBeenCalledWith({ + body: { + mappings: { + dynamic: 'strict', + properties: { foo: { type: 'keyword' } }, + }, + settings: { auto_expand_replicas: '0-1', number_of_shards: 1 }, + }, + index: '.ze-index', + }); + + expect(client.reindex).toHaveBeenCalledWith({ + body: { + dest: { index: '.ze-index' }, + source: { index: '.muchacha', size: 10 }, + script: { + source: `ctx._id = ctx._source.type + ':' + ctx._id`, + lang: 'painless', + }, + }, + refresh: true, + wait_for_completion: false, + }); + + expect(client.tasks.get).toHaveBeenCalledWith({ + task_id: 'abc', + }); + + expect(client.indices.updateAliases).toHaveBeenCalledWith({ + body: { + actions: [ + { remove_index: { index: '.muchacha' } }, + { remove: { alias: '.muchacha', index: '.my-fanci-index' } }, + { add: { index: '.ze-index', alias: '.muchacha' } }, + ], + }, + }); + + expect(client.indices.refresh).toHaveBeenCalledWith({ + index: '.ze-index', + }); }); test('throws error if re-index task fails', async () => { - const callCluster = jest.fn(async (path: string, arg: any) => { - switch (path) { - case 'indices.create': - expect(arg.body).toEqual({ - mappings: { - dynamic: 'strict', - properties: { foo: { type: 'keyword' } }, - }, - settings: { auto_expand_replicas: '0-1', number_of_shards: 1 }, - }); - expect(arg.index).toEqual('.ze-index'); - return true; - case 'reindex': - expect(arg).toMatchObject({ - body: { - dest: { index: '.ze-index' }, - source: { index: '.muchacha' }, - }, - refresh: true, - waitForCompletion: false, - }); - return { task: 'abc' }; - case 'tasks.get': - expect(arg.taskId).toEqual('abc'); - return { - completed: true, - error: { - type: 'search_phase_execution_exception', - reason: 'all shards failed', - failed_shards: [], - }, - }; - default: - throw new Error(`Dunnoes what ${path} means.`); - } - }); + client.indices.getAlias.mockResolvedValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + '.my-fanci-index': '.muchacha', + }) + ); + client.reindex.mockResolvedValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ task: 'abc' }) + ); + client.tasks.get.mockResolvedValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + completed: true, + error: { + type: 'search_phase_execution_exception', + reason: 'all shards failed', + failed_shards: [], + }, + }) + ); const info = { aliases: {}, @@ -336,22 +307,44 @@ describe('ElasticIndex', () => { properties: { foo: { type: 'keyword' } }, }, }; - await expect(Index.convertToAlias(callCluster as any, info, '.muchacha', 10)).rejects.toThrow( + + await expect(Index.convertToAlias(client, info, '.muchacha', 10)).rejects.toThrow( /Re-index failed \[search_phase_execution_exception\] all shards failed/ ); - expect(callCluster.mock.calls.map(([path]) => path)).toEqual([ - 'indices.create', - 'reindex', - 'tasks.get', - ]); + expect(client.indices.create).toHaveBeenCalledWith({ + body: { + mappings: { + dynamic: 'strict', + properties: { foo: { type: 'keyword' } }, + }, + settings: { auto_expand_replicas: '0-1', number_of_shards: 1 }, + }, + index: '.ze-index', + }); + + expect(client.reindex).toHaveBeenCalledWith({ + body: { + dest: { index: '.ze-index' }, + source: { index: '.muchacha', size: 10 }, + }, + refresh: true, + wait_for_completion: false, + }); + + expect(client.tasks.get).toHaveBeenCalledWith({ + task_id: 'abc', + }); }); }); describe('write', () => { test('writes documents in bulk to the index', async () => { + client.bulk.mockResolvedValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ items: [] }) + ); + const index = '.myalias'; - const callCluster = jest.fn().mockResolvedValue({ items: [] }); const docs = [ { _id: 'niceguy:fredrogers', @@ -375,19 +368,20 @@ describe('ElasticIndex', () => { }, ]; - await Index.write(callCluster, index, docs); + await Index.write(client, index, docs); - expect(callCluster).toHaveBeenCalled(); - expect(callCluster.mock.calls[0]).toMatchSnapshot(); + expect(client.bulk).toHaveBeenCalled(); + expect(client.bulk.mock.calls[0]).toMatchSnapshot(); }); test('fails if any document fails', async () => { - const index = '.myalias'; - const callCluster = jest.fn(() => - Promise.resolve({ + client.bulk.mockResolvedValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ items: [{ index: { error: { type: 'shazm', reason: 'dern' } } }], }) ); + + const index = '.myalias'; const docs = [ { _id: 'niceguy:fredrogers', @@ -400,23 +394,20 @@ describe('ElasticIndex', () => { }, ]; - await expect(Index.write(callCluster as any, index, docs)).rejects.toThrow(/dern/); - expect(callCluster).toHaveBeenCalled(); + await expect(Index.write(client as any, index, docs)).rejects.toThrow(/dern/); + expect(client.bulk).toHaveBeenCalledTimes(1); }); }); describe('reader', () => { test('returns docs in batches', async () => { const index = '.myalias'; - const callCluster = jest.fn(); - const batch1 = [ { _id: 'such:1', _source: { type: 'such', such: { num: 1 } }, }, ]; - const batch2 = [ { _id: 'aaa:2', @@ -432,42 +423,56 @@ describe('ElasticIndex', () => { }, ]; - callCluster - .mockResolvedValueOnce({ + client.search = jest.fn().mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ _scroll_id: 'x', _shards: { success: 1, total: 1 }, hits: { hits: _.cloneDeep(batch1) }, }) - .mockResolvedValueOnce({ - _scroll_id: 'y', - _shards: { success: 1, total: 1 }, - hits: { hits: _.cloneDeep(batch2) }, - }) - .mockResolvedValueOnce({ - _scroll_id: 'z', - _shards: { success: 1, total: 1 }, - hits: { hits: [] }, - }) - .mockResolvedValue({}); - - const read = Index.reader(callCluster, index, { batchSize: 100, scrollDuration: '5m' }); + ); + client.scroll = jest + .fn() + .mockReturnValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + _scroll_id: 'y', + _shards: { success: 1, total: 1 }, + hits: { hits: _.cloneDeep(batch2) }, + }) + ) + .mockReturnValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + _scroll_id: 'z', + _shards: { success: 1, total: 1 }, + hits: { hits: [] }, + }) + ); + + const read = Index.reader(client, index, { batchSize: 100, scrollDuration: '5m' }); expect(await read()).toEqual(batch1); expect(await read()).toEqual(batch2); expect(await read()).toEqual([]); - // Check order of calls, as well as args - expect(callCluster.mock.calls).toEqual([ - ['search', { body: { size: 100 }, index, scroll: '5m' }], - ['scroll', { scroll: '5m', scrollId: 'x' }], - ['scroll', { scroll: '5m', scrollId: 'y' }], - ['clearScroll', { scrollId: 'z' }], - ]); + expect(client.search).toHaveBeenCalledWith({ + body: { size: 100 }, + index, + scroll: '5m', + }); + expect(client.scroll).toHaveBeenCalledWith({ + scroll: '5m', + scroll_id: 'x', + }); + expect(client.scroll).toHaveBeenCalledWith({ + scroll: '5m', + scroll_id: 'y', + }); + expect(client.clearScroll).toHaveBeenCalledWith({ + scroll_id: 'z', + }); }); test('returns all root-level properties', async () => { const index = '.myalias'; - const callCluster = jest.fn(); const batch = [ { _id: 'such:1', @@ -480,19 +485,22 @@ describe('ElasticIndex', () => { }, ]; - callCluster - .mockResolvedValueOnce({ + client.search = jest.fn().mockReturnValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ _scroll_id: 'x', _shards: { success: 1, total: 1 }, hits: { hits: _.cloneDeep(batch) }, }) - .mockResolvedValue({ + ); + client.scroll = jest.fn().mockReturnValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ _scroll_id: 'z', _shards: { success: 1, total: 1 }, hits: { hits: [] }, - }); + }) + ); - const read = Index.reader(callCluster, index, { + const read = Index.reader(client, index, { batchSize: 100, scrollDuration: '5m', }); @@ -502,11 +510,14 @@ describe('ElasticIndex', () => { test('fails if not all shards were successful', async () => { const index = '.myalias'; - const callCluster = jest.fn(); - callCluster.mockResolvedValue({ _shards: { successful: 1, total: 2 } }); + client.search = jest.fn().mockReturnValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + _shards: { successful: 1, total: 2 }, + }) + ); - const read = Index.reader(callCluster, index, { + const read = Index.reader(client, index, { batchSize: 100, scrollDuration: '5m', }); @@ -516,7 +527,6 @@ describe('ElasticIndex', () => { test('handles shards not being returned', async () => { const index = '.myalias'; - const callCluster = jest.fn(); const batch = [ { _id: 'such:1', @@ -529,11 +539,20 @@ describe('ElasticIndex', () => { }, ]; - callCluster - .mockResolvedValueOnce({ _scroll_id: 'x', hits: { hits: _.cloneDeep(batch) } }) - .mockResolvedValue({ _scroll_id: 'z', hits: { hits: [] } }); + client.search = jest.fn().mockReturnValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + _scroll_id: 'x', + hits: { hits: _.cloneDeep(batch) }, + }) + ); + client.scroll = jest.fn().mockReturnValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + _scroll_id: 'z', + hits: { hits: [] }, + }) + ); - const read = Index.reader(callCluster, index, { + const read = Index.reader(client, index, { batchSize: 100, scrollDuration: '5m', }); @@ -550,23 +569,24 @@ describe('ElasticIndex', () => { count, migrations, }: any) { - const callCluster = jest.fn(async (path: string) => { - if (path === 'indices.get') { - return { - [index]: { mappings }, - }; - } - if (path === 'count') { - return { count, _shards: { success: 1, total: 1 } }; - } - throw new Error(`Unknown command ${path}.`); - }); - const hasMigrations = await Index.migrationsUpToDate(callCluster as any, index, migrations); - return { hasMigrations, callCluster }; + client.indices.get = jest.fn().mockReturnValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + [index]: { mappings }, + }) + ); + client.count = jest.fn().mockReturnValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + count, + _shards: { success: 1, total: 1 }, + }) + ); + + const hasMigrations = await Index.migrationsUpToDate(client, index, migrations); + return { hasMigrations }; } test('is false if the index mappings do not contain migrationVersion', async () => { - const { hasMigrations, callCluster } = await testMigrationsUpToDate({ + const { hasMigrations } = await testMigrationsUpToDate({ index: '.myalias', mappings: { properties: { @@ -578,17 +598,18 @@ describe('ElasticIndex', () => { }); expect(hasMigrations).toBeFalsy(); - expect(callCluster.mock.calls[0]).toEqual([ - 'indices.get', + expect(client.indices.get).toHaveBeenCalledWith( { - ignore: [404], index: '.myalias', }, - ]); + { + ignore: [404], + } + ); }); test('is true if there are no migrations defined', async () => { - const { hasMigrations, callCluster } = await testMigrationsUpToDate({ + const { hasMigrations } = await testMigrationsUpToDate({ index: '.myalias', mappings: { properties: { @@ -604,12 +625,11 @@ describe('ElasticIndex', () => { }); expect(hasMigrations).toBeTruthy(); - expect(callCluster).toHaveBeenCalled(); - expect(callCluster.mock.calls[0][0]).toEqual('indices.get'); + expect(client.indices.get).toHaveBeenCalledTimes(1); }); test('is true if there are no documents out of date', async () => { - const { hasMigrations, callCluster } = await testMigrationsUpToDate({ + const { hasMigrations } = await testMigrationsUpToDate({ index: '.myalias', mappings: { properties: { @@ -625,13 +645,12 @@ describe('ElasticIndex', () => { }); expect(hasMigrations).toBeTruthy(); - expect(callCluster).toHaveBeenCalled(); - expect(callCluster.mock.calls[0][0]).toEqual('indices.get'); - expect(callCluster.mock.calls[1][0]).toEqual('count'); + expect(client.indices.get).toHaveBeenCalledTimes(1); + expect(client.count).toHaveBeenCalledTimes(1); }); test('is false if there are documents out of date', async () => { - const { hasMigrations, callCluster } = await testMigrationsUpToDate({ + const { hasMigrations } = await testMigrationsUpToDate({ index: '.myalias', mappings: { properties: { @@ -647,12 +666,12 @@ describe('ElasticIndex', () => { }); expect(hasMigrations).toBeFalsy(); - expect(callCluster.mock.calls[0][0]).toEqual('indices.get'); - expect(callCluster.mock.calls[1][0]).toEqual('count'); + expect(client.indices.get).toHaveBeenCalledTimes(1); + expect(client.count).toHaveBeenCalledTimes(1); }); test('counts docs that are out of date', async () => { - const { callCluster } = await testMigrationsUpToDate({ + await testMigrationsUpToDate({ index: '.myalias', mappings: { properties: { @@ -686,23 +705,20 @@ describe('ElasticIndex', () => { }; } - expect(callCluster.mock.calls[1]).toEqual([ - 'count', - { - body: { - query: { - bool: { - should: [ - shouldClause('dashy', '23.2.5'), - shouldClause('bashy', '99.9.3'), - shouldClause('flashy', '3.4.5'), - ], - }, + expect(client.count).toHaveBeenCalledWith({ + body: { + query: { + bool: { + should: [ + shouldClause('dashy', '23.2.5'), + shouldClause('bashy', '99.9.3'), + shouldClause('flashy', '3.4.5'), + ], }, }, - index: '.myalias', }, - ]); + index: '.myalias', + }); }); }); }); diff --git a/src/core/server/saved_objects/migrations/core/elastic_index.ts b/src/core/server/saved_objects/migrations/core/elastic_index.ts index e87c3e3ff0d6..d5093bfd8dc4 100644 --- a/src/core/server/saved_objects/migrations/core/elastic_index.ts +++ b/src/core/server/saved_objects/migrations/core/elastic_index.ts @@ -23,9 +23,12 @@ */ import _ from 'lodash'; +import { MigrationEsClient } from './migration_es_client'; +import { CountResponse, SearchResponse } from '../../../elasticsearch'; import { IndexMapping } from '../../mappings'; import { SavedObjectsMigrationVersion } from '../../types'; -import { AliasAction, CallCluster, NotFound, RawDoc, ShardsInfo } from './call_cluster'; +import { AliasAction, RawDoc, ShardsInfo } from './call_cluster'; +import { SavedObjectsRawDocSource } from '../../serialization'; const settings = { number_of_shards: 1, auto_expand_replicas: '0-1' }; @@ -40,13 +43,10 @@ export interface FullIndexInfo { * A slight enhancement to indices.get, that adds indexName, and validates that the * index mappings are somewhat what we expect. */ -export async function fetchInfo(callCluster: CallCluster, index: string): Promise { - const result = await callCluster('indices.get', { - ignore: [404], - index, - }); +export async function fetchInfo(client: MigrationEsClient, index: string): Promise { + const { body, statusCode } = await client.indices.get({ index }, { ignore: [404] }); - if ((result as NotFound).status === 404) { + if (statusCode === 404) { return { aliases: {}, exists: false, @@ -55,7 +55,7 @@ export async function fetchInfo(callCluster: CallCluster, index: string): Promis }; } - const [indexName, indexInfo] = Object.entries(result)[0]; + const [indexName, indexInfo] = Object.entries(body)[0]; return assertIsSupportedIndex({ ...indexInfo, exists: true, indexName }); } @@ -71,7 +71,7 @@ export async function fetchInfo(callCluster: CallCluster, index: string): Promis * @prop {string} scrollDuration - The scroll duration used for scrolling through the index */ export function reader( - callCluster: CallCluster, + client: MigrationEsClient, index: string, { batchSize = 10, scrollDuration = '15m' }: { batchSize: number; scrollDuration: string } ) { @@ -80,19 +80,24 @@ export function reader( const nextBatch = () => scrollId !== undefined - ? callCluster('scroll', { scroll, scrollId }) - : callCluster('search', { body: { size: batchSize }, index, scroll }); - - const close = async () => scrollId && (await callCluster('clearScroll', { scrollId })); + ? client.scroll>({ + scroll, + scroll_id: scrollId, + }) + : client.search>({ + body: { size: batchSize }, + index, + scroll, + }); + + const close = async () => scrollId && (await client.clearScroll({ scroll_id: scrollId })); return async function read() { const result = await nextBatch(); - assertResponseIncludeAllShards(result); - - const docs = result.hits.hits; - - scrollId = result._scroll_id; + assertResponseIncludeAllShards(result.body); + scrollId = result.body._scroll_id; + const docs = result.body.hits.hits; if (!docs.length) { await close(); } @@ -109,8 +114,8 @@ export function reader( * @param {string} index * @param {RawDoc[]} docs */ -export async function write(callCluster: CallCluster, index: string, docs: RawDoc[]) { - const result = await callCluster('bulk', { +export async function write(client: MigrationEsClient, index: string, docs: RawDoc[]) { + const { body } = await client.bulk({ body: docs.reduce((acc: object[], doc: RawDoc) => { acc.push({ index: { @@ -125,7 +130,7 @@ export async function write(callCluster: CallCluster, index: string, docs: RawDo }, []), }); - const err = _.find(result.items, 'index.error.reason'); + const err = _.find(body.items, 'index.error.reason'); if (!err) { return; @@ -150,15 +155,15 @@ export async function write(callCluster: CallCluster, index: string, docs: RawDo * @param {SavedObjectsMigrationVersion} migrationVersion - The latest versions of the migrations */ export async function migrationsUpToDate( - callCluster: CallCluster, + client: MigrationEsClient, index: string, migrationVersion: SavedObjectsMigrationVersion, retryCount: number = 10 ): Promise { try { - const indexInfo = await fetchInfo(callCluster, index); + const indexInfo = await fetchInfo(client, index); - if (!_.get(indexInfo, 'mappings.properties.migrationVersion')) { + if (!indexInfo.mappings.properties?.migrationVersion) { return false; } @@ -167,7 +172,7 @@ export async function migrationsUpToDate( return true; } - const response = await callCluster('count', { + const { body } = await client.count({ body: { query: { bool: { @@ -175,7 +180,11 @@ export async function migrationsUpToDate( bool: { must: [ { exists: { field: type } }, - { bool: { must_not: { term: { [`migrationVersion.${type}`]: latestVersion } } } }, + { + bool: { + must_not: { term: { [`migrationVersion.${type}`]: latestVersion } }, + }, + }, ], }, })), @@ -185,9 +194,9 @@ export async function migrationsUpToDate( index, }); - assertResponseIncludeAllShards(response); + assertResponseIncludeAllShards(body); - return response.count === 0; + return body.count === 0; } catch (e) { // retry for Service Unavailable if (e.status !== 503 || retryCount === 0) { @@ -196,23 +205,23 @@ export async function migrationsUpToDate( await new Promise((r) => setTimeout(r, 1000)); - return await migrationsUpToDate(callCluster, index, migrationVersion, retryCount - 1); + return await migrationsUpToDate(client, index, migrationVersion, retryCount - 1); } } export async function createIndex( - callCluster: CallCluster, + client: MigrationEsClient, index: string, mappings?: IndexMapping ) { - await callCluster('indices.create', { + await client.indices.create({ body: { mappings, settings }, index, }); } -export async function deleteIndex(callCluster: CallCluster, index: string) { - await callCluster('indices.delete', { index }); +export async function deleteIndex(client: MigrationEsClient, index: string) { + await client.indices.delete({ index }); } /** @@ -225,20 +234,20 @@ export async function deleteIndex(callCluster: CallCluster, index: string) { * @param {string} alias - The name of the index being converted to an alias */ export async function convertToAlias( - callCluster: CallCluster, + client: MigrationEsClient, info: FullIndexInfo, alias: string, batchSize: number, script?: string ) { - await callCluster('indices.create', { + await client.indices.create({ body: { mappings: info.mappings, settings }, index: info.indexName, }); - await reindex(callCluster, alias, info.indexName, batchSize, script); + await reindex(client, alias, info.indexName, batchSize, script); - await claimAlias(callCluster, info.indexName, alias, [{ remove_index: { index: alias } }]); + await claimAlias(client, info.indexName, alias, [{ remove_index: { index: alias } }]); } /** @@ -252,22 +261,22 @@ export async function convertToAlias( * @param {AliasAction[]} aliasActions - Optional actions to be added to the updateAliases call */ export async function claimAlias( - callCluster: CallCluster, + client: MigrationEsClient, index: string, alias: string, aliasActions: AliasAction[] = [] ) { - const result = await callCluster('indices.getAlias', { ignore: [404], name: alias }); - const aliasInfo = (result as NotFound).status === 404 ? {} : result; + const { body, statusCode } = await client.indices.getAlias({ name: alias }, { ignore: [404] }); + const aliasInfo = statusCode === 404 ? {} : body; const removeActions = Object.keys(aliasInfo).map((key) => ({ remove: { index: key, alias } })); - await callCluster('indices.updateAliases', { + await client.indices.updateAliases({ body: { actions: aliasActions.concat(removeActions).concat({ add: { index, alias } }), }, }); - await callCluster('indices.refresh', { index }); + await client.indices.refresh({ index }); } /** @@ -318,7 +327,7 @@ function assertResponseIncludeAllShards({ _shards }: { _shards: ShardsInfo }) { * Reindexes from source to dest, polling for the reindex completion. */ async function reindex( - callCluster: CallCluster, + client: MigrationEsClient, source: string, dest: string, batchSize: number, @@ -329,7 +338,7 @@ async function reindex( // polling interval, as the request is fairly efficent, and we don't // want to block index migrations for too long on this. const pollInterval = 250; - const { task } = await callCluster('reindex', { + const { body: reindexBody } = await client.reindex({ body: { dest: { index: dest }, source: { index: source, size: batchSize }, @@ -341,23 +350,25 @@ async function reindex( : undefined, }, refresh: true, - waitForCompletion: false, + wait_for_completion: false, }); + const task = reindexBody.task; + let completed = false; while (!completed) { await new Promise((r) => setTimeout(r, pollInterval)); - completed = await callCluster('tasks.get', { - taskId: task, - }).then((result) => { - if (result.error) { - const e = result.error; - throw new Error(`Re-index failed [${e.type}] ${e.reason} :: ${JSON.stringify(e)}`); - } - - return result.completed; + const { body } = await client.tasks.get({ + task_id: task, }); + + if (body.error) { + const e = body.error; + throw new Error(`Re-index failed [${e.type}] ${e.reason} :: ${JSON.stringify(e)}`); + } + + completed = body.completed; } } diff --git a/src/core/server/saved_objects/migrations/core/index.ts b/src/core/server/saved_objects/migrations/core/index.ts index f7274740ea5f..c9d3d2a71c9a 100644 --- a/src/core/server/saved_objects/migrations/core/index.ts +++ b/src/core/server/saved_objects/migrations/core/index.ts @@ -23,3 +23,4 @@ export { buildActiveMappings } from './build_active_mappings'; export { CallCluster } from './call_cluster'; export { LogFn, SavedObjectsMigrationLogger } from './migration_logger'; export { MigrationResult, MigrationStatus } from './migration_coordinator'; +export { createMigrationEsClient, MigrationEsClient } from './migration_es_client'; diff --git a/src/core/server/saved_objects/migrations/core/index_migrator.test.ts b/src/core/server/saved_objects/migrations/core/index_migrator.test.ts index f8b203bf66d6..78601d033f8d 100644 --- a/src/core/server/saved_objects/migrations/core/index_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/core/index_migrator.test.ts @@ -18,18 +18,22 @@ */ import _ from 'lodash'; +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; import { SavedObjectUnsanitizedDoc, SavedObjectsSerializer } from '../../serialization'; import { SavedObjectTypeRegistry } from '../../saved_objects_type_registry'; import { IndexMigrator } from './index_migrator'; +import { MigrationOpts } from './migration_context'; import { loggingSystemMock } from '../../../logging/logging_system.mock'; describe('IndexMigrator', () => { - let testOpts: any; + let testOpts: jest.Mocked & { + client: ReturnType; + }; beforeEach(() => { testOpts = { batchSize: 10, - callCluster: jest.fn(), + client: elasticsearchClientMock.createElasticSearchClient(), index: '.kibana', log: loggingSystemMock.create().get(), mappingProperties: {}, @@ -44,15 +48,15 @@ describe('IndexMigrator', () => { }); test('creates the index if it does not exist', async () => { - const { callCluster } = testOpts; + const { client } = testOpts; - testOpts.mappingProperties = { foo: { type: 'long' } }; + testOpts.mappingProperties = { foo: { type: 'long' } as any }; - withIndex(callCluster, { index: { status: 404 }, alias: { status: 404 } }); + withIndex(client, { index: { statusCode: 404 }, alias: { statusCode: 404 } }); await new IndexMigrator(testOpts).migrate(); - expect(callCluster).toHaveBeenCalledWith('indices.create', { + expect(client.indices.create).toHaveBeenCalledWith({ body: { mappings: { dynamic: 'strict', @@ -91,9 +95,9 @@ describe('IndexMigrator', () => { }); test('returns stats about the migration', async () => { - const { callCluster } = testOpts; + const { client } = testOpts; - withIndex(callCluster, { index: { status: 404 }, alias: { status: 404 } }); + withIndex(client, { index: { statusCode: 404 }, alias: { statusCode: 404 } }); const result = await new IndexMigrator(testOpts).migrate(); @@ -105,9 +109,9 @@ describe('IndexMigrator', () => { }); test('fails if there are multiple root doc types', async () => { - const { callCluster } = testOpts; + const { client } = testOpts; - withIndex(callCluster, { + withIndex(client, { index: { '.kibana_1': { aliases: {}, @@ -129,9 +133,9 @@ describe('IndexMigrator', () => { }); test('fails if root doc type is not "doc"', async () => { - const { callCluster } = testOpts; + const { client } = testOpts; - withIndex(callCluster, { + withIndex(client, { index: { '.kibana_1': { aliases: {}, @@ -152,11 +156,11 @@ describe('IndexMigrator', () => { }); test('retains unknown core field mappings from the previous index', async () => { - const { callCluster } = testOpts; + const { client } = testOpts; - testOpts.mappingProperties = { foo: { type: 'text' } }; + testOpts.mappingProperties = { foo: { type: 'text' } as any }; - withIndex(callCluster, { + withIndex(client, { index: { '.kibana_1': { aliases: {}, @@ -171,7 +175,7 @@ describe('IndexMigrator', () => { await new IndexMigrator(testOpts).migrate(); - expect(callCluster).toHaveBeenCalledWith('indices.create', { + expect(client.indices.create).toHaveBeenCalledWith({ body: { mappings: { dynamic: 'strict', @@ -211,11 +215,11 @@ describe('IndexMigrator', () => { }); test('disables complex field mappings from unknown types in the previous index', async () => { - const { callCluster } = testOpts; + const { client } = testOpts; - testOpts.mappingProperties = { foo: { type: 'text' } }; + testOpts.mappingProperties = { foo: { type: 'text' } as any }; - withIndex(callCluster, { + withIndex(client, { index: { '.kibana_1': { aliases: {}, @@ -230,7 +234,7 @@ describe('IndexMigrator', () => { await new IndexMigrator(testOpts).migrate(); - expect(callCluster).toHaveBeenCalledWith('indices.create', { + expect(client.indices.create).toHaveBeenCalledWith({ body: { mappings: { dynamic: 'strict', @@ -270,31 +274,31 @@ describe('IndexMigrator', () => { }); test('points the alias at the dest index', async () => { - const { callCluster } = testOpts; + const { client } = testOpts; - withIndex(callCluster, { index: { status: 404 }, alias: { status: 404 } }); + withIndex(client, { index: { statusCode: 404 }, alias: { statusCode: 404 } }); await new IndexMigrator(testOpts).migrate(); - expect(callCluster).toHaveBeenCalledWith('indices.create', expect.any(Object)); - expect(callCluster).toHaveBeenCalledWith('indices.updateAliases', { + expect(client.indices.create).toHaveBeenCalledWith(expect.any(Object)); + expect(client.indices.updateAliases).toHaveBeenCalledWith({ body: { actions: [{ add: { alias: '.kibana', index: '.kibana_1' } }] }, }); }); test('removes previous indices from the alias', async () => { - const { callCluster } = testOpts; + const { client } = testOpts; testOpts.documentMigrator.migrationVersion = { dashboard: '2.4.5', }; - withIndex(callCluster, { numOutOfDate: 1 }); + withIndex(client, { numOutOfDate: 1 }); await new IndexMigrator(testOpts).migrate(); - expect(callCluster).toHaveBeenCalledWith('indices.create', expect.any(Object)); - expect(callCluster).toHaveBeenCalledWith('indices.updateAliases', { + expect(client.indices.create).toHaveBeenCalledWith(expect.any(Object)); + expect(client.indices.updateAliases).toHaveBeenCalledWith({ body: { actions: [ { remove: { alias: '.kibana', index: '.kibana_1' } }, @@ -306,7 +310,7 @@ describe('IndexMigrator', () => { test('transforms all docs from the original index', async () => { let count = 0; - const { callCluster } = testOpts; + const { client } = testOpts; const migrateDoc = jest.fn((doc: SavedObjectUnsanitizedDoc) => { return { ...doc, @@ -319,7 +323,7 @@ describe('IndexMigrator', () => { migrate: migrateDoc, }; - withIndex(callCluster, { + withIndex(client, { numOutOfDate: 1, docs: [ [{ _id: 'foo:1', _source: { type: 'foo', foo: { name: 'Bar' } } }], @@ -344,30 +348,27 @@ describe('IndexMigrator', () => { migrationVersion: {}, references: [], }); - const bulkCalls = callCluster.mock.calls.filter(([action]: any) => action === 'bulk'); - expect(bulkCalls.length).toEqual(2); - expect(bulkCalls[0]).toEqual([ - 'bulk', - { - body: [ - { index: { _id: 'foo:1', _index: '.kibana_2' } }, - { foo: { name: 1 }, type: 'foo', migrationVersion: {}, references: [] }, - ], - }, - ]); - expect(bulkCalls[1]).toEqual([ - 'bulk', - { - body: [ - { index: { _id: 'foo:2', _index: '.kibana_2' } }, - { foo: { name: 2 }, type: 'foo', migrationVersion: {}, references: [] }, - ], - }, - ]); + + expect(client.bulk).toHaveBeenCalledTimes(2); + expect(client.bulk).toHaveBeenNthCalledWith(1, { + body: [ + { index: { _id: 'foo:1', _index: '.kibana_2' } }, + { foo: { name: 1 }, type: 'foo', migrationVersion: {}, references: [] }, + ], + }); + expect(client.bulk).toHaveBeenNthCalledWith(2, { + body: [ + { index: { _id: 'foo:2', _index: '.kibana_2' } }, + { foo: { name: 2 }, type: 'foo', migrationVersion: {}, references: [] }, + ], + }); }); }); -function withIndex(callCluster: jest.Mock, opts: any = {}) { +function withIndex( + client: ReturnType, + opts: any = {} +) { const defaultIndex = { '.kibana_1': { aliases: { '.kibana': {} }, @@ -386,39 +387,56 @@ function withIndex(callCluster: jest.Mock, opts: any = {}) { const { alias = defaultAlias } = opts; const { index = defaultIndex } = opts; const { docs = [] } = opts; - const searchResult = (i: number) => - Promise.resolve({ - _scroll_id: i, - _shards: { - successful: 1, - total: 1, - }, - hits: { - hits: docs[i] || [], - }, - }); + const searchResult = (i: number) => ({ + _scroll_id: i, + _shards: { + successful: 1, + total: 1, + }, + hits: { + hits: docs[i] || [], + }, + }); let scrollCallCounter = 1; - callCluster.mockImplementation((method) => { - if (method === 'indices.get') { - return Promise.resolve(index); - } else if (method === 'indices.getAlias') { - return Promise.resolve(alias); - } else if (method === 'reindex') { - return Promise.resolve({ task: 'zeid', _shards: { successful: 1, total: 1 } }); - } else if (method === 'tasks.get') { - return Promise.resolve({ completed: true }); - } else if (method === 'search') { - return searchResult(0); - } else if (method === 'bulk') { - return Promise.resolve({ items: [] }); - } else if (method === 'count') { - return Promise.resolve({ count: numOutOfDate, _shards: { successful: 1, total: 1 } }); - } else if (method === 'scroll' && scrollCallCounter <= docs.length) { + client.indices.get.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise(index, { + statusCode: index.statusCode, + }) + ); + client.indices.getAlias.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise(alias, { + statusCode: index.statusCode, + }) + ); + client.reindex.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + task: 'zeid', + _shards: { successful: 1, total: 1 }, + }) + ); + client.tasks.get.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ completed: true }) + ); + client.search.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise(searchResult(0)) + ); + client.bulk.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ items: [] }) + ); + client.count.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + count: numOutOfDate, + _shards: { successful: 1, total: 1 }, + }) + ); + client.scroll.mockImplementation(() => { + if (scrollCallCounter <= docs.length) { const result = searchResult(scrollCallCounter); scrollCallCounter++; - return result; + return elasticsearchClientMock.createSuccessTransportRequestPromise(result); } + return elasticsearchClientMock.createSuccessTransportRequestPromise({}); }); } diff --git a/src/core/server/saved_objects/migrations/core/index_migrator.ts b/src/core/server/saved_objects/migrations/core/index_migrator.ts index e588eb787732..ceca27fa8772 100644 --- a/src/core/server/saved_objects/migrations/core/index_migrator.ts +++ b/src/core/server/saved_objects/migrations/core/index_migrator.ts @@ -16,7 +16,6 @@ * specific language governing permissions and limitations * under the License. */ - import { diffMappings } from './build_active_mappings'; import * as Index from './elastic_index'; import { migrateRawDocs } from './migrate_raw_docs'; @@ -71,11 +70,11 @@ export class IndexMigrator { * Determines what action the migration system needs to take (none, patch, migrate). */ async function requiresMigration(context: Context): Promise { - const { callCluster, alias, documentMigrator, dest, log } = context; + const { client, alias, documentMigrator, dest, log } = context; // Have all of our known migrations been run against the index? const hasMigrations = await Index.migrationsUpToDate( - callCluster, + client, alias, documentMigrator.migrationVersion ); @@ -85,7 +84,7 @@ async function requiresMigration(context: Context): Promise { } // Is our index aliased? - const refreshedSource = await Index.fetchInfo(callCluster, alias); + const refreshedSource = await Index.fetchInfo(client, alias); if (!refreshedSource.aliases[alias]) { return true; @@ -109,19 +108,19 @@ async function requiresMigration(context: Context): Promise { */ async function migrateIndex(context: Context): Promise { const startTime = Date.now(); - const { callCluster, alias, source, dest, log } = context; + const { client, alias, source, dest, log } = context; await deleteIndexTemplates(context); log.info(`Creating index ${dest.indexName}.`); - await Index.createIndex(callCluster, dest.indexName, dest.mappings); + await Index.createIndex(client, dest.indexName, dest.mappings); await migrateSourceToDest(context); log.info(`Pointing alias ${alias} to ${dest.indexName}.`); - await Index.claimAlias(callCluster, dest.indexName, alias); + await Index.claimAlias(client, dest.indexName, alias); const result: MigrationResult = { status: 'migrated', @@ -139,12 +138,12 @@ async function migrateIndex(context: Context): Promise { * If the obsoleteIndexTemplatePattern option is specified, this will delete any index templates * that match it. */ -async function deleteIndexTemplates({ callCluster, log, obsoleteIndexTemplatePattern }: Context) { +async function deleteIndexTemplates({ client, log, obsoleteIndexTemplatePattern }: Context) { if (!obsoleteIndexTemplatePattern) { return; } - const templates = await callCluster('cat.templates', { + const { body: templates } = await client.cat.templates>({ format: 'json', name: obsoleteIndexTemplatePattern, }); @@ -157,7 +156,7 @@ async function deleteIndexTemplates({ callCluster, log, obsoleteIndexTemplatePat log.info(`Removing index templates: ${templateNames}`); - return Promise.all(templateNames.map((name) => callCluster('indices.deleteTemplate', { name }))); + return Promise.all(templateNames.map((name) => client.indices.deleteTemplate({ name }))); } /** @@ -166,7 +165,7 @@ async function deleteIndexTemplates({ callCluster, log, obsoleteIndexTemplatePat * a situation where the alias moves out from under us as we're migrating docs. */ async function migrateSourceToDest(context: Context) { - const { callCluster, alias, dest, source, batchSize } = context; + const { client, alias, dest, source, batchSize } = context; const { scrollDuration, documentMigrator, log, serializer } = context; if (!source.exists) { @@ -176,10 +175,10 @@ async function migrateSourceToDest(context: Context) { if (!source.aliases[alias]) { log.info(`Reindexing ${alias} to ${source.indexName}`); - await Index.convertToAlias(callCluster, source, alias, batchSize, context.convertToAliasScript); + await Index.convertToAlias(client, source, alias, batchSize, context.convertToAliasScript); } - const read = Index.reader(callCluster, source.indexName, { batchSize, scrollDuration }); + const read = Index.reader(client, source.indexName, { batchSize, scrollDuration }); log.info(`Migrating ${source.indexName} saved objects to ${dest.indexName}`); @@ -193,7 +192,7 @@ async function migrateSourceToDest(context: Context) { log.debug(`Migrating saved objects ${docs.map((d) => d._id).join(', ')}`); await Index.write( - callCluster, + client, dest.indexName, await migrateRawDocs(serializer, documentMigrator.migrate, docs, log) ); diff --git a/src/core/server/saved_objects/migrations/core/migration_context.ts b/src/core/server/saved_objects/migrations/core/migration_context.ts index adf1851a1aa7..0ea362d65623 100644 --- a/src/core/server/saved_objects/migrations/core/migration_context.ts +++ b/src/core/server/saved_objects/migrations/core/migration_context.ts @@ -25,6 +25,7 @@ */ import { Logger } from 'src/core/server/logging'; +import { MigrationEsClient } from './migration_es_client'; import { SavedObjectsSerializer } from '../../serialization'; import { SavedObjectsTypeMappingDefinitions, @@ -32,16 +33,15 @@ import { IndexMapping, } from '../../mappings'; import { buildActiveMappings } from './build_active_mappings'; -import { CallCluster } from './call_cluster'; import { VersionedTransformer } from './document_migrator'; -import { fetchInfo, FullIndexInfo } from './elastic_index'; +import * as Index from './elastic_index'; import { SavedObjectsMigrationLogger, MigrationLogger } from './migration_logger'; export interface MigrationOpts { batchSize: number; pollInterval: number; scrollDuration: string; - callCluster: CallCluster; + client: MigrationEsClient; index: string; log: Logger; mappingProperties: SavedObjectsTypeMappingDefinitions; @@ -56,11 +56,14 @@ export interface MigrationOpts { obsoleteIndexTemplatePattern?: string; } +/** + * @internal + */ export interface Context { - callCluster: CallCluster; + client: MigrationEsClient; alias: string; - source: FullIndexInfo; - dest: FullIndexInfo; + source: Index.FullIndexInfo; + dest: Index.FullIndexInfo; documentMigrator: VersionedTransformer; log: SavedObjectsMigrationLogger; batchSize: number; @@ -76,13 +79,13 @@ export interface Context { * and various info needed to migrate the source index. */ export async function migrationContext(opts: MigrationOpts): Promise { - const { log, callCluster } = opts; + const { log, client } = opts; const alias = opts.index; - const source = createSourceContext(await fetchInfo(callCluster, alias), alias); + const source = createSourceContext(await Index.fetchInfo(client, alias), alias); const dest = createDestContext(source, alias, opts.mappingProperties); return { - callCluster, + client, alias, source, dest, @@ -97,7 +100,7 @@ export async function migrationContext(opts: MigrationOpts): Promise { }; } -function createSourceContext(source: FullIndexInfo, alias: string) { +function createSourceContext(source: Index.FullIndexInfo, alias: string) { if (source.exists && source.indexName === alias) { return { ...source, @@ -109,10 +112,10 @@ function createSourceContext(source: FullIndexInfo, alias: string) { } function createDestContext( - source: FullIndexInfo, + source: Index.FullIndexInfo, alias: string, typeMappingDefinitions: SavedObjectsTypeMappingDefinitions -): FullIndexInfo { +): Index.FullIndexInfo { const targetMappings = disableUnknownTypeMappingFields( buildActiveMappings(typeMappingDefinitions), source.mappings diff --git a/src/core/server/saved_objects/migrations/core/migration_es_client.test.mock.ts b/src/core/server/saved_objects/migrations/core/migration_es_client.test.mock.ts new file mode 100644 index 000000000000..8ebed25d87cb --- /dev/null +++ b/src/core/server/saved_objects/migrations/core/migration_es_client.test.mock.ts @@ -0,0 +1,22 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +export const migrationRetryCallClusterMock = jest.fn((fn) => fn()); +jest.doMock('../../../elasticsearch/client/retry_call_cluster', () => ({ + migrationRetryCallCluster: migrationRetryCallClusterMock, +})); diff --git a/src/core/server/saved_objects/migrations/core/migration_es_client.test.ts b/src/core/server/saved_objects/migrations/core/migration_es_client.test.ts new file mode 100644 index 000000000000..40c06677c4a5 --- /dev/null +++ b/src/core/server/saved_objects/migrations/core/migration_es_client.test.ts @@ -0,0 +1,65 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { migrationRetryCallClusterMock } from './migration_es_client.test.mock'; + +import { createMigrationEsClient, MigrationEsClient } from './migration_es_client'; +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { loggerMock } from '../../../logging/logger.mock'; +import { SavedObjectsErrorHelpers } from '../../service/lib/errors'; + +describe('MigrationEsClient', () => { + let client: ReturnType; + let migrationEsClient: MigrationEsClient; + + beforeEach(() => { + client = elasticsearchClientMock.createElasticSearchClient(); + migrationEsClient = createMigrationEsClient(client, loggerMock.create()); + migrationRetryCallClusterMock.mockClear(); + }); + + it('delegates call to ES client method', async () => { + expect(migrationEsClient.bulk).toStrictEqual(expect.any(Function)); + await migrationEsClient.bulk({ body: [] }); + expect(client.bulk).toHaveBeenCalledTimes(1); + }); + + it('wraps a method call in migrationRetryCallClusterMock', async () => { + await migrationEsClient.bulk({ body: [] }); + expect(migrationRetryCallClusterMock).toHaveBeenCalledTimes(1); + }); + + it('sets maxRetries: 0 to delegate retry logic to migrationRetryCallCluster', async () => { + expect(migrationEsClient.bulk).toStrictEqual(expect.any(Function)); + await migrationEsClient.bulk({ body: [] }); + expect(client.bulk).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ maxRetries: 0 }) + ); + }); + + it('do not transform elasticsearch errors into saved objects errors', async () => { + expect.assertions(1); + client.bulk = jest.fn().mockRejectedValue(new Error('reason')); + try { + await migrationEsClient.bulk({ body: [] }); + } catch (e) { + expect(SavedObjectsErrorHelpers.isSavedObjectsClientError(e)).toBe(false); + } + }); +}); diff --git a/src/core/server/saved_objects/migrations/core/migration_es_client.ts b/src/core/server/saved_objects/migrations/core/migration_es_client.ts new file mode 100644 index 000000000000..ff859057f8fe --- /dev/null +++ b/src/core/server/saved_objects/migrations/core/migration_es_client.ts @@ -0,0 +1,90 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import type { TransportRequestOptions } from '@elastic/elasticsearch/lib/Transport'; +import { get } from 'lodash'; +import { set } from '@elastic/safer-lodash-set'; + +import { ElasticsearchClient } from '../../../elasticsearch'; +import { migrationRetryCallCluster } from '../../../elasticsearch/client/retry_call_cluster'; +import { Logger } from '../../../logging'; + +const methods = [ + 'bulk', + 'cat.templates', + 'clearScroll', + 'count', + 'indices.create', + 'indices.delete', + 'indices.deleteTemplate', + 'indices.get', + 'indices.getAlias', + 'indices.refresh', + 'indices.updateAliases', + 'reindex', + 'search', + 'scroll', + 'tasks.get', +] as const; + +type MethodName = typeof methods[number]; + +export interface MigrationEsClient { + bulk: ElasticsearchClient['bulk']; + cat: { + templates: ElasticsearchClient['cat']['templates']; + }; + clearScroll: ElasticsearchClient['clearScroll']; + count: ElasticsearchClient['count']; + indices: { + create: ElasticsearchClient['indices']['create']; + delete: ElasticsearchClient['indices']['delete']; + deleteTemplate: ElasticsearchClient['indices']['deleteTemplate']; + get: ElasticsearchClient['indices']['get']; + getAlias: ElasticsearchClient['indices']['getAlias']; + refresh: ElasticsearchClient['indices']['refresh']; + updateAliases: ElasticsearchClient['indices']['updateAliases']; + }; + reindex: ElasticsearchClient['reindex']; + search: ElasticsearchClient['search']; + scroll: ElasticsearchClient['scroll']; + tasks: { + get: ElasticsearchClient['tasks']['get']; + }; +} + +export function createMigrationEsClient( + client: ElasticsearchClient, + log: Logger, + delay?: number +): MigrationEsClient { + return methods.reduce((acc: MigrationEsClient, key: MethodName) => { + set(acc, key, async (params?: unknown, options?: TransportRequestOptions) => { + const fn = get(client, key); + if (!fn) { + throw new Error(`unknown ElasticsearchClient client method [${key}]`); + } + return await migrationRetryCallCluster( + () => fn(params, { maxRetries: 0, ...options }), + log, + delay + ); + }); + return acc; + }, {} as MigrationEsClient); +} diff --git a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.test.ts b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.test.ts index 01b0d1cd0ba3..c3ed97a89af8 100644 --- a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.test.ts @@ -18,6 +18,7 @@ */ import { take } from 'rxjs/operators'; +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; import { KibanaMigratorOptions, KibanaMigrator } from './kibana_migrator'; import { loggingSystemMock } from '../../../logging/logging_system.mock'; import { SavedObjectTypeRegistry } from '../../saved_objects_type_registry'; @@ -66,26 +67,44 @@ describe('KibanaMigrator', () => { describe('runMigrations', () => { it('only runs migrations once if called multiple times', async () => { const options = mockOptions(); - const clusterStub = jest.fn(() => ({ status: 404 })); - options.callCluster = clusterStub; + options.client.cat.templates.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise( + { templates: [] }, + { statusCode: 404 } + ) + ); + options.client.indices.get.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) + ); + options.client.indices.getAlias.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) + ); + const migrator = new KibanaMigrator(options); + await migrator.runMigrations(); await migrator.runMigrations(); - // callCluster with "cat.templates" is called by "deleteIndexTemplates" function - // and should only be done once - const callClusterCommands = clusterStub.mock.calls - .map(([callClusterPath]) => callClusterPath) - .filter((callClusterPath) => callClusterPath === 'cat.templates'); - expect(callClusterCommands.length).toBe(1); + expect(options.client.cat.templates).toHaveBeenCalledTimes(1); }); it('emits results on getMigratorResult$()', async () => { const options = mockOptions(); - const clusterStub = jest.fn(() => ({ status: 404 })); - options.callCluster = clusterStub; + options.client.cat.templates.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise( + { templates: [] }, + { statusCode: 404 } + ) + ); + options.client.indices.get.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) + ); + options.client.indices.getAlias.mockReturnValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) + ); + const migrator = new KibanaMigrator(options); const migratorStatus = migrator.getStatus$().pipe(take(3)).toPromise(); await migrator.runMigrations(); @@ -107,9 +126,12 @@ describe('KibanaMigrator', () => { }); }); -function mockOptions(): KibanaMigratorOptions { - const callCluster = jest.fn(); - return { +type MockedOptions = KibanaMigratorOptions & { + client: ReturnType; +}; + +const mockOptions = () => { + const options: MockedOptions = { logger: loggingSystemMock.create().get(), kibanaVersion: '8.2.3', savedObjectValidations: {}, @@ -148,6 +170,7 @@ function mockOptions(): KibanaMigratorOptions { scrollDuration: '10m', skip: false, }, - callCluster, + client: elasticsearchClientMock.createElasticSearchClient(), }; -} + return options; +}; diff --git a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts index 69b57a498936..85b909930880 100644 --- a/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts +++ b/src/core/server/saved_objects/migrations/kibana/kibana_migrator.ts @@ -24,25 +24,21 @@ import { KibanaConfigType } from 'src/core/server/kibana_config'; import { BehaviorSubject } from 'rxjs'; + import { Logger } from '../../../logging'; import { IndexMapping, SavedObjectsTypeMappingDefinitions } from '../../mappings'; import { SavedObjectUnsanitizedDoc, SavedObjectsSerializer } from '../../serialization'; import { docValidator, PropertyValidators } from '../../validation'; -import { - buildActiveMappings, - CallCluster, - IndexMigrator, - MigrationResult, - MigrationStatus, -} from '../core'; +import { buildActiveMappings, IndexMigrator, MigrationResult, MigrationStatus } from '../core'; import { DocumentMigrator, VersionedTransformer } from '../core/document_migrator'; +import { MigrationEsClient } from '../core/'; import { createIndexMap } from '../core/build_index_map'; import { SavedObjectsMigrationConfigType } from '../../saved_objects_config'; import { ISavedObjectTypeRegistry } from '../../saved_objects_type_registry'; import { SavedObjectsType } from '../../types'; export interface KibanaMigratorOptions { - callCluster: CallCluster; + client: MigrationEsClient; typeRegistry: ISavedObjectTypeRegistry; savedObjectsConfig: SavedObjectsMigrationConfigType; kibanaConfig: KibanaConfigType; @@ -62,7 +58,7 @@ export interface KibanaMigratorStatus { * Manages the shape of mappings and documents in the Kibana index. */ export class KibanaMigrator { - private readonly callCluster: CallCluster; + private readonly client: MigrationEsClient; private readonly savedObjectsConfig: SavedObjectsMigrationConfigType; private readonly documentMigrator: VersionedTransformer; private readonly kibanaConfig: KibanaConfigType; @@ -80,7 +76,7 @@ export class KibanaMigrator { * Creates an instance of KibanaMigrator. */ constructor({ - callCluster, + client, typeRegistry, kibanaConfig, savedObjectsConfig, @@ -88,7 +84,7 @@ export class KibanaMigrator { kibanaVersion, logger, }: KibanaMigratorOptions) { - this.callCluster = callCluster; + this.client = client; this.kibanaConfig = kibanaConfig; this.savedObjectsConfig = savedObjectsConfig; this.typeRegistry = typeRegistry; @@ -153,7 +149,7 @@ export class KibanaMigrator { const migrators = Object.keys(indexMap).map((index) => { return new IndexMigrator({ batchSize: this.savedObjectsConfig.batchSize, - callCluster: this.callCluster, + client: this.client, documentMigrator: this.documentMigrator, index, log: this.log, diff --git a/src/core/server/saved_objects/saved_objects_service.test.ts b/src/core/server/saved_objects/saved_objects_service.test.ts index e8b2cf0b583b..8df6a07318c4 100644 --- a/src/core/server/saved_objects/saved_objects_service.test.ts +++ b/src/core/server/saved_objects/saved_objects_service.test.ts @@ -25,18 +25,20 @@ import { } from './saved_objects_service.test.mocks'; import { BehaviorSubject } from 'rxjs'; import { ByteSizeValue } from '@kbn/config-schema'; +import { errors as esErrors } from '@elastic/elasticsearch'; + import { SavedObjectsService } from './saved_objects_service'; import { mockCoreContext } from '../core_context.mock'; -import * as legacyElasticsearch from 'elasticsearch'; import { Env } from '../config'; import { configServiceMock } from '../mocks'; import { elasticsearchServiceMock } from '../elasticsearch/elasticsearch_service.mock'; +import { elasticsearchClientMock } from '../elasticsearch/client/mocks'; import { legacyServiceMock } from '../legacy/legacy_service.mock'; import { httpServiceMock } from '../http/http_service.mock'; +import { httpServerMock } from '../http/http_server.mocks'; import { SavedObjectsClientFactoryProvider } from './service/lib'; import { NodesVersionCompatibility } from '../elasticsearch/version_check/ensure_es_version'; import { SavedObjectsRepository } from './service/lib/repository'; -import { KibanaRequest } from '../http'; jest.mock('./service/lib/repository'); @@ -70,7 +72,7 @@ describe('SavedObjectsService', () => { const createStartDeps = (pluginsInitialized: boolean = true) => { return { pluginsInitialized, - elasticsearch: elasticsearchServiceMock.createStart(), + elasticsearch: elasticsearchServiceMock.createInternalStart(), }; }; @@ -161,26 +163,27 @@ describe('SavedObjectsService', () => { }); describe('#start()', () => { - it('creates a KibanaMigrator which retries NoConnections errors from callAsInternalUser', async () => { + it('creates a KibanaMigrator which retries NoLivingConnectionsError errors from ES client', async () => { const coreContext = createCoreContext(); const soService = new SavedObjectsService(coreContext); const coreSetup = createSetupDeps(); const coreStart = createStartDeps(); - let i = 0; - coreStart.elasticsearch.legacy.client.callAsInternalUser = jest + coreStart.elasticsearch.client.asInternalUser.indices.create = jest .fn() - .mockImplementation(() => - i++ <= 2 - ? Promise.reject(new legacyElasticsearch.errors.NoConnections()) - : Promise.resolve('success') + .mockImplementationOnce(() => + Promise.reject(new esErrors.NoLivingConnectionsError('reason', {} as any)) + ) + .mockImplementationOnce(() => + elasticsearchClientMock.createSuccessTransportRequestPromise('success') ); await soService.setup(coreSetup); await soService.start(coreStart, 1); - return expect(KibanaMigratorMock.mock.calls[0][0].callCluster()).resolves.toMatch('success'); + const response = await KibanaMigratorMock.mock.calls[0][0].client.indices.create(); + return expect(response.body).toBe('success'); }); it('skips KibanaMigrator migrations when pluginsInitialized=false', async () => { @@ -291,22 +294,15 @@ describe('SavedObjectsService', () => { const coreStart = createStartDeps(); const { createScopedRepository } = await soService.start(coreStart); - const req = {} as KibanaRequest; + const req = httpServerMock.createKibanaRequest(); createScopedRepository(req); - expect(coreStart.elasticsearch.legacy.client.asScoped).toHaveBeenCalledWith(req); - - const [ - { - value: { callAsCurrentUser }, - }, - ] = coreStart.elasticsearch.legacy.client.asScoped.mock.results; + expect(coreStart.elasticsearch.client.asScoped).toHaveBeenCalledWith(req); const [ - [, , , callCluster, includedHiddenTypes], + [, , , , includedHiddenTypes], ] = (SavedObjectsRepository.createRepository as jest.Mocked).mock.calls; - expect(callCluster).toBe(callAsCurrentUser); expect(includedHiddenTypes).toEqual([]); }); @@ -318,7 +314,7 @@ describe('SavedObjectsService', () => { const coreStart = createStartDeps(); const { createScopedRepository } = await soService.start(coreStart); - const req = {} as KibanaRequest; + const req = httpServerMock.createKibanaRequest(); createScopedRepository(req, ['someHiddenType']); const [ @@ -341,11 +337,10 @@ describe('SavedObjectsService', () => { createInternalRepository(); const [ - [, , , callCluster, includedHiddenTypes], + [, , , client, includedHiddenTypes], ] = (SavedObjectsRepository.createRepository as jest.Mocked).mock.calls; - expect(coreStart.elasticsearch.legacy.client.callAsInternalUser).toBe(callCluster); - expect(callCluster).toBe(coreStart.elasticsearch.legacy.client.callAsInternalUser); + expect(coreStart.elasticsearch.client.asInternalUser).toBe(client); expect(includedHiddenTypes).toEqual([]); }); diff --git a/src/core/server/saved_objects/saved_objects_service.ts b/src/core/server/saved_objects/saved_objects_service.ts index c2d4f49d7ee2..f05e912b12ad 100644 --- a/src/core/server/saved_objects/saved_objects_service.ts +++ b/src/core/server/saved_objects/saved_objects_service.ts @@ -30,13 +30,12 @@ import { KibanaMigrator, IKibanaMigrator } from './migrations'; import { CoreContext } from '../core_context'; import { LegacyServiceDiscoverPlugins } from '../legacy'; import { - LegacyAPICaller, - ElasticsearchServiceStart, - ILegacyClusterClient, + ElasticsearchClient, + IClusterClient, InternalElasticsearchServiceSetup, + InternalElasticsearchServiceStart, } from '../elasticsearch'; import { KibanaConfigType } from '../kibana_config'; -import { migrationsRetryCallCluster } from '../elasticsearch/legacy'; import { SavedObjectsConfigType, SavedObjectsMigrationConfigType, @@ -57,7 +56,7 @@ import { SavedObjectsSerializer } from './serialization'; import { registerRoutes } from './routes'; import { ServiceStatus } from '../status'; import { calculateStatus$ } from './status'; - +import { createMigrationEsClient } from './migrations/core/'; /** * Saved Objects is Kibana's data persistence mechanism allowing plugins to * use Elasticsearch for storing and querying state. The SavedObjectsServiceSetup API exposes methods @@ -284,7 +283,7 @@ interface WrappedClientFactoryWrapper { /** @internal */ export interface SavedObjectsStartDeps { - elasticsearch: ElasticsearchServiceStart; + elasticsearch: InternalElasticsearchServiceStart; pluginsInitialized?: boolean; } @@ -383,12 +382,12 @@ export class SavedObjectsService .atPath('kibana') .pipe(first()) .toPromise(); - const client = elasticsearch.legacy.client; + const client = elasticsearch.client; const migrator = this.createMigrator( kibanaConfig, this.config.migration, - client, + elasticsearch.client, migrationsRetryDelay ); @@ -434,21 +433,24 @@ export class SavedObjectsService await migrator.runMigrations(); } - const createRepository = (callCluster: LegacyAPICaller, includedHiddenTypes: string[] = []) => { + const createRepository = ( + esClient: ElasticsearchClient, + includedHiddenTypes: string[] = [] + ) => { return SavedObjectsRepository.createRepository( migrator, this.typeRegistry, kibanaConfig.index, - callCluster, + esClient, includedHiddenTypes ); }; const repositoryFactory: SavedObjectsRepositoryFactory = { createInternalRepository: (includedHiddenTypes?: string[]) => - createRepository(client.callAsInternalUser, includedHiddenTypes), + createRepository(client.asInternalUser, includedHiddenTypes), createScopedRepository: (req: KibanaRequest, includedHiddenTypes?: string[]) => - createRepository(client.asScoped(req).callAsCurrentUser, includedHiddenTypes), + createRepository(client.asScoped(req).asCurrentUser, includedHiddenTypes), }; const clientProvider = new SavedObjectsClientProvider({ @@ -484,7 +486,7 @@ export class SavedObjectsService private createMigrator( kibanaConfig: KibanaConfigType, savedObjectsConfig: SavedObjectsMigrationConfigType, - esClient: ILegacyClusterClient, + client: IClusterClient, migrationsRetryDelay?: number ): KibanaMigrator { return new KibanaMigrator({ @@ -494,11 +496,7 @@ export class SavedObjectsService savedObjectsConfig, savedObjectValidations: this.validations, kibanaConfig, - callCluster: migrationsRetryCallCluster( - esClient.callAsInternalUser, - this.logger, - migrationsRetryDelay - ), + client: createMigrationEsClient(client.asInternalUser, this.logger, migrationsRetryDelay), }); } } diff --git a/src/core/server/saved_objects/serialization/index.ts b/src/core/server/saved_objects/serialization/index.ts index f7f4e7570434..812a0770ad98 100644 --- a/src/core/server/saved_objects/serialization/index.ts +++ b/src/core/server/saved_objects/serialization/index.ts @@ -22,5 +22,10 @@ * the raw document format as stored in ElasticSearch. */ -export { SavedObjectUnsanitizedDoc, SavedObjectSanitizedDoc, SavedObjectsRawDoc } from './types'; +export { + SavedObjectUnsanitizedDoc, + SavedObjectSanitizedDoc, + SavedObjectsRawDoc, + SavedObjectsRawDocSource, +} from './types'; export { SavedObjectsSerializer } from './serializer'; diff --git a/src/core/server/saved_objects/service/lib/decorate_es_error.test.ts b/src/core/server/saved_objects/service/lib/decorate_es_error.test.ts index 1fdebd87397e..623610eebd8d 100644 --- a/src/core/server/saved_objects/service/lib/decorate_es_error.test.ts +++ b/src/core/server/saved_objects/service/lib/decorate_es_error.test.ts @@ -17,75 +17,93 @@ * under the License. */ -import { errors as esErrors } from 'elasticsearch'; - +import { errors as esErrors } from '@elastic/elasticsearch'; +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; import { decorateEsError } from './decorate_es_error'; import { SavedObjectsErrorHelpers } from './errors'; describe('savedObjectsClient/decorateEsError', () => { it('always returns the same error it receives', () => { - const error = new Error(); + const error = new esErrors.ResponseError(elasticsearchClientMock.createApiResponse()); expect(decorateEsError(error)).toBe(error); }); - it('makes es.ConnectionFault a SavedObjectsClient/EsUnavailable error', () => { - const error = new esErrors.ConnectionFault(); + it('makes ConnectionError a SavedObjectsClient/EsUnavailable error', () => { + const error = new esErrors.ConnectionError( + 'reason', + elasticsearchClientMock.createApiResponse() + ); expect(SavedObjectsErrorHelpers.isEsUnavailableError(error)).toBe(false); expect(decorateEsError(error)).toBe(error); expect(SavedObjectsErrorHelpers.isEsUnavailableError(error)).toBe(true); }); - it('makes es.ServiceUnavailable a SavedObjectsClient/EsUnavailable error', () => { - const error = new esErrors.ServiceUnavailable(); + it('makes ServiceUnavailable a SavedObjectsClient/EsUnavailable error', () => { + const error = new esErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ statusCode: 503 }) + ); expect(SavedObjectsErrorHelpers.isEsUnavailableError(error)).toBe(false); expect(decorateEsError(error)).toBe(error); expect(SavedObjectsErrorHelpers.isEsUnavailableError(error)).toBe(true); }); - it('makes es.NoConnections a SavedObjectsClient/EsUnavailable error', () => { - const error = new esErrors.NoConnections(); + it('makes NoLivingConnectionsError a SavedObjectsClient/EsUnavailable error', () => { + const error = new esErrors.NoLivingConnectionsError( + 'reason', + elasticsearchClientMock.createApiResponse() + ); expect(SavedObjectsErrorHelpers.isEsUnavailableError(error)).toBe(false); expect(decorateEsError(error)).toBe(error); expect(SavedObjectsErrorHelpers.isEsUnavailableError(error)).toBe(true); }); - it('makes es.RequestTimeout a SavedObjectsClient/EsUnavailable error', () => { - const error = new esErrors.RequestTimeout(); + it('makes TimeoutError a SavedObjectsClient/EsUnavailable error', () => { + const error = new esErrors.TimeoutError('reason', elasticsearchClientMock.createApiResponse()); expect(SavedObjectsErrorHelpers.isEsUnavailableError(error)).toBe(false); expect(decorateEsError(error)).toBe(error); expect(SavedObjectsErrorHelpers.isEsUnavailableError(error)).toBe(true); }); - it('makes es.Conflict a SavedObjectsClient/Conflict error', () => { - const error = new esErrors.Conflict(); + it('makes Conflict a SavedObjectsClient/Conflict error', () => { + const error = new esErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ statusCode: 409 }) + ); expect(SavedObjectsErrorHelpers.isConflictError(error)).toBe(false); expect(decorateEsError(error)).toBe(error); expect(SavedObjectsErrorHelpers.isConflictError(error)).toBe(true); }); - it('makes es.AuthenticationException a SavedObjectsClient/NotAuthorized error', () => { - const error = new esErrors.AuthenticationException(); + it('makes NotAuthorized a SavedObjectsClient/NotAuthorized error', () => { + const error = new esErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ statusCode: 401 }) + ); expect(SavedObjectsErrorHelpers.isNotAuthorizedError(error)).toBe(false); expect(decorateEsError(error)).toBe(error); expect(SavedObjectsErrorHelpers.isNotAuthorizedError(error)).toBe(true); }); - it('makes es.Forbidden a SavedObjectsClient/Forbidden error', () => { - const error = new esErrors.Forbidden(); + it('makes Forbidden a SavedObjectsClient/Forbidden error', () => { + const error = new esErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ statusCode: 403 }) + ); expect(SavedObjectsErrorHelpers.isForbiddenError(error)).toBe(false); expect(decorateEsError(error)).toBe(error); expect(SavedObjectsErrorHelpers.isForbiddenError(error)).toBe(true); }); - it('makes es.RequestEntityTooLarge a SavedObjectsClient/RequestEntityTooLarge error', () => { - const error = new esErrors.RequestEntityTooLarge(); + it('makes RequestEntityTooLarge a SavedObjectsClient/RequestEntityTooLarge error', () => { + const error = new esErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ statusCode: 413 }) + ); expect(SavedObjectsErrorHelpers.isRequestEntityTooLargeError(error)).toBe(false); expect(decorateEsError(error)).toBe(error); expect(SavedObjectsErrorHelpers.isRequestEntityTooLargeError(error)).toBe(true); }); - it('discards es.NotFound errors and returns a generic NotFound error', () => { - const error = new esErrors.NotFound(); + it('discards NotFound errors and returns a generic NotFound error', () => { + const error = new esErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ statusCode: 404 }) + ); expect(SavedObjectsErrorHelpers.isNotFoundError(error)).toBe(false); const genericError = decorateEsError(error); expect(genericError).not.toBe(error); @@ -93,8 +111,10 @@ describe('savedObjectsClient/decorateEsError', () => { expect(SavedObjectsErrorHelpers.isNotFoundError(genericError)).toBe(true); }); - it('makes es.BadRequest a SavedObjectsClient/BadRequest error', () => { - const error = new esErrors.BadRequest(); + it('makes BadRequest a SavedObjectsClient/BadRequest error', () => { + const error = new esErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ statusCode: 400 }) + ); expect(SavedObjectsErrorHelpers.isBadRequestError(error)).toBe(false); expect(decorateEsError(error)).toBe(error); expect(SavedObjectsErrorHelpers.isBadRequestError(error)).toBe(true); @@ -102,10 +122,16 @@ describe('savedObjectsClient/decorateEsError', () => { describe('when es.BadRequest has a reason', () => { it('makes a SavedObjectsClient/esCannotExecuteScriptError error when script context is disabled', () => { - const error = new esErrors.BadRequest(); - (error as Record).body = { - error: { reason: 'cannot execute scripts using [update] context' }, - }; + const error = new esErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 400, + body: { + error: { + reason: 'cannot execute scripts using [update] context', + }, + }, + }) + ); expect(SavedObjectsErrorHelpers.isEsCannotExecuteScriptError(error)).toBe(false); expect(decorateEsError(error)).toBe(error); expect(SavedObjectsErrorHelpers.isEsCannotExecuteScriptError(error)).toBe(true); @@ -113,10 +139,16 @@ describe('savedObjectsClient/decorateEsError', () => { }); it('makes a SavedObjectsClient/esCannotExecuteScriptError error when inline scripts are disabled', () => { - const error = new esErrors.BadRequest(); - (error as Record).body = { - error: { reason: 'cannot execute [inline] scripts' }, - }; + const error = new esErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ + statusCode: 400, + body: { + error: { + reason: 'cannot execute [inline] scripts', + }, + }, + }) + ); expect(SavedObjectsErrorHelpers.isEsCannotExecuteScriptError(error)).toBe(false); expect(decorateEsError(error)).toBe(error); expect(SavedObjectsErrorHelpers.isEsCannotExecuteScriptError(error)).toBe(true); @@ -124,8 +156,9 @@ describe('savedObjectsClient/decorateEsError', () => { }); it('makes a SavedObjectsClient/BadRequest error for any other reason', () => { - const error = new esErrors.BadRequest(); - (error as Record).body = { error: { reason: 'some other reason' } }; + const error = new esErrors.ResponseError( + elasticsearchClientMock.createApiResponse({ statusCode: 400 }) + ); expect(SavedObjectsErrorHelpers.isBadRequestError(error)).toBe(false); expect(decorateEsError(error)).toBe(error); expect(SavedObjectsErrorHelpers.isBadRequestError(error)).toBe(true); @@ -133,7 +166,7 @@ describe('savedObjectsClient/decorateEsError', () => { }); it('returns other errors as Boom errors', () => { - const error = new Error(); + const error = new esErrors.ResponseError(elasticsearchClientMock.createApiResponse()); expect(error).not.toHaveProperty('isBoom'); expect(decorateEsError(error)).toBe(error); expect(error).toHaveProperty('isBoom'); diff --git a/src/core/server/saved_objects/service/lib/decorate_es_error.ts b/src/core/server/saved_objects/service/lib/decorate_es_error.ts index 7d1575798c35..cf8a16cdaae6 100644 --- a/src/core/server/saved_objects/service/lib/decorate_es_error.ts +++ b/src/core/server/saved_objects/service/lib/decorate_es_error.ts @@ -17,65 +17,66 @@ * under the License. */ -import * as legacyElasticsearch from 'elasticsearch'; +import { errors as esErrors } from '@elastic/elasticsearch'; import { get } from 'lodash'; -const { - ConnectionFault, - ServiceUnavailable, - NoConnections, - RequestTimeout, - Conflict, - // @ts-expect-error - 401: NotAuthorized, - // @ts-expect-error - 403: Forbidden, - // @ts-expect-error - 413: RequestEntityTooLarge, - NotFound, - BadRequest, -} = legacyElasticsearch.errors; +const responseErrors = { + isServiceUnavailable: (statusCode: number) => statusCode === 503, + isConflict: (statusCode: number) => statusCode === 409, + isNotAuthorized: (statusCode: number) => statusCode === 401, + isForbidden: (statusCode: number) => statusCode === 403, + isRequestEntityTooLarge: (statusCode: number) => statusCode === 413, + isNotFound: (statusCode: number) => statusCode === 404, + isBadRequest: (statusCode: number) => statusCode === 400, +}; +const { ConnectionError, NoLivingConnectionsError, TimeoutError } = esErrors; const SCRIPT_CONTEXT_DISABLED_REGEX = /(?:cannot execute scripts using \[)([a-z]*)(?:\] context)/; const INLINE_SCRIPTS_DISABLED_MESSAGE = 'cannot execute [inline] scripts'; import { SavedObjectsErrorHelpers } from './errors'; -export function decorateEsError(error: Error) { +type EsErrors = + | esErrors.ConnectionError + | esErrors.NoLivingConnectionsError + | esErrors.TimeoutError + | esErrors.ResponseError; + +export function decorateEsError(error: EsErrors) { if (!(error instanceof Error)) { throw new Error('Expected an instance of Error'); } const { reason } = get(error, 'body.error', { reason: undefined }) as { reason?: string }; if ( - error instanceof ConnectionFault || - error instanceof ServiceUnavailable || - error instanceof NoConnections || - error instanceof RequestTimeout + error instanceof ConnectionError || + error instanceof NoLivingConnectionsError || + error instanceof TimeoutError || + responseErrors.isServiceUnavailable(error.statusCode) ) { return SavedObjectsErrorHelpers.decorateEsUnavailableError(error, reason); } - if (error instanceof Conflict) { + if (responseErrors.isConflict(error.statusCode)) { return SavedObjectsErrorHelpers.decorateConflictError(error, reason); } - if (error instanceof NotAuthorized) { + if (responseErrors.isNotAuthorized(error.statusCode)) { return SavedObjectsErrorHelpers.decorateNotAuthorizedError(error, reason); } - if (error instanceof Forbidden) { + if (responseErrors.isForbidden(error.statusCode)) { return SavedObjectsErrorHelpers.decorateForbiddenError(error, reason); } - if (error instanceof RequestEntityTooLarge) { + if (responseErrors.isRequestEntityTooLarge(error.statusCode)) { return SavedObjectsErrorHelpers.decorateRequestEntityTooLargeError(error, reason); } - if (error instanceof NotFound) { + if (responseErrors.isNotFound(error.statusCode)) { return SavedObjectsErrorHelpers.createGenericNotFoundError(); } - if (error instanceof BadRequest) { + if (responseErrors.isBadRequest(error.statusCode)) { if ( SCRIPT_CONTEXT_DISABLED_REGEX.test(reason || '') || reason === INLINE_SCRIPTS_DISABLED_MESSAGE diff --git a/src/core/server/saved_objects/service/lib/repository.test.js b/src/core/server/saved_objects/service/lib/repository.test.js index d563edbe66c9..b902179b012f 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -24,6 +24,7 @@ import { SavedObjectsSerializer } from '../../serialization'; import { encodeHitVersion } from '../../version'; import { SavedObjectTypeRegistry } from '../../saved_objects_type_registry'; import { DocumentMigrator } from '../../migrations/core/document_migrator'; +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; jest.mock('./search_dsl/search_dsl', () => ({ getSearchDsl: jest.fn() })); @@ -40,7 +41,7 @@ const createUnsupportedTypeError = (...args) => SavedObjectsErrorHelpers.createUnsupportedTypeError(...args).output.payload; describe('SavedObjectsRepository', () => { - let callAdminCluster; + let client; let savedObjectsRepository; let migrator; @@ -170,26 +171,11 @@ describe('SavedObjectsRepository', () => { }); const getMockMgetResponse = (objects, namespace) => ({ - status: 200, docs: objects.map((obj) => obj.found === false ? obj : getMockGetResponse({ ...obj, namespace }) ), }); - const expectClusterCalls = (...actions) => { - for (let i = 0; i < actions.length; i++) { - expect(callAdminCluster).toHaveBeenNthCalledWith(i + 1, actions[i], expect.any(Object)); - } - expect(callAdminCluster).toHaveBeenCalledTimes(actions.length); - }; - const expectClusterCallArgs = (args, n = 1) => { - expect(callAdminCluster).toHaveBeenNthCalledWith( - n, - expect.any(String), - expect.objectContaining(args) - ); - }; - expect.extend({ toBeDocumentWithoutError(received, type, id) { if (received.type === type && received.id === id && !received.error) { @@ -215,7 +201,7 @@ describe('SavedObjectsRepository', () => { }; beforeEach(() => { - callAdminCluster = jest.fn(); + client = elasticsearchClientMock.createElasticSearchClient(); migrator = { migrateDocument: jest.fn().mockImplementation(documentMigrator.migrate), runMigrations: async () => ({ status: 'skipped' }), @@ -240,7 +226,7 @@ describe('SavedObjectsRepository', () => { savedObjectsRepository = new SavedObjectsRepository({ index: '.kibana-test', mappings, - callCluster: callAdminCluster, + client, migrator, typeRegistry: registry, serializer, @@ -248,7 +234,7 @@ describe('SavedObjectsRepository', () => { }); savedObjectsRepository._getCurrentTime = jest.fn(() => mockTimestamp); - getSearchDslNS.getSearchDsl.mockReset(); + getSearchDslNS.getSearchDsl.mockClear(); }); const mockMigrationVersion = { foo: '2.3.4' }; @@ -274,25 +260,29 @@ describe('SavedObjectsRepository', () => { // mock a document that exists in two namespaces const mockResponse = getMockGetResponse({ type, id }); mockResponse._source.namespaces = [currentNs1, currentNs2]; - callAdminCluster.mockResolvedValueOnce(mockResponse); // this._callCluster('get', ...) + client.get.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(mockResponse) + ); }; const addToNamespacesSuccess = async (type, id, namespaces, options) => { - mockGetResponse(type, id); // this._callCluster('get', ...) - callAdminCluster.mockResolvedValue({ - _id: `${type}:${id}`, - ...mockVersionProps, - result: 'updated', - }); // this._writeToCluster('update', ...) + mockGetResponse(type, id); + client.update.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + _id: `${type}:${id}`, + ...mockVersionProps, + result: 'updated', + }) + ); const result = await savedObjectsRepository.addToNamespaces(type, id, namespaces, options); - expect(callAdminCluster).toHaveBeenCalledTimes(2); + expect(client.get).toHaveBeenCalledTimes(1); + expect(client.update).toHaveBeenCalledTimes(1); return result; }; - describe('cluster calls', () => { + describe('client calls', () => { it(`should use ES get action then update action`, async () => { await addToNamespacesSuccess(type, id, [newNs1, newNs2]); - expectClusterCalls('get', 'update'); }); it(`defaults to the version of the existing document`, async () => { @@ -301,25 +291,28 @@ describe('SavedObjectsRepository', () => { if_seq_no: mockVersionProps._seq_no, if_primary_term: mockVersionProps._primary_term, }; - expectClusterCallArgs(versionProperties, 2); + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining(versionProperties), + expect.anything() + ); }); it(`accepts version`, async () => { await addToNamespacesSuccess(type, id, [newNs1, newNs2], { version: encodeHitVersion({ _seq_no: 100, _primary_term: 200 }), }); - expectClusterCallArgs({ if_seq_no: 100, if_primary_term: 200 }, 2); + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ if_seq_no: 100, if_primary_term: 200 }), + expect.anything() + ); }); it(`defaults to a refresh setting of wait_for`, async () => { await addToNamespacesSuccess(type, id, [newNs1, newNs2]); - expectClusterCallArgs({ refresh: 'wait_for' }, 2); - }); - - it(`accepts a custom refresh setting`, async () => { - const refresh = 'foo'; - await addToNamespacesSuccess(type, id, [newNs1, newNs2], { refresh }); - expectClusterCallArgs({ refresh }, 2); + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ refresh: 'wait_for' }), + expect.anything() + ); }); }); @@ -337,19 +330,19 @@ describe('SavedObjectsRepository', () => { it(`throws when type is invalid`, async () => { await expectNotFoundError('unknownType', id, [newNs1, newNs2]); - expect(callAdminCluster).not.toHaveBeenCalled(); + expect(client.update).not.toHaveBeenCalled(); }); it(`throws when type is hidden`, async () => { await expectNotFoundError(HIDDEN_TYPE, id, [newNs1, newNs2]); - expect(callAdminCluster).not.toHaveBeenCalled(); + expect(client.update).not.toHaveBeenCalled(); }); it(`throws when type is not multi-namespace`, async () => { const test = async (type) => { const message = `${type} doesn't support multiple namespaces`; await expectBadRequestError(type, id, [newNs1, newNs2], message); - expect(callAdminCluster).not.toHaveBeenCalled(); + expect(client.update).not.toHaveBeenCalled(); }; await test('index-pattern'); await test(NAMESPACE_AGNOSTIC_TYPE); @@ -359,48 +352,43 @@ describe('SavedObjectsRepository', () => { const test = async (namespaces) => { const message = 'namespaces must be a non-empty array of strings'; await expectBadRequestError(type, id, namespaces, message); - expect(callAdminCluster).not.toHaveBeenCalled(); + expect(client.update).not.toHaveBeenCalled(); }; await test([]); }); it(`throws when ES is unable to find the document during get`, async () => { - callAdminCluster.mockResolvedValue({ found: false }); // this._callCluster('get', ...) + client.get.mockResolvedValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({ found: false }) + ); await expectNotFoundError(type, id, [newNs1, newNs2]); - expectClusterCalls('get'); + expect(client.get).toHaveBeenCalledTimes(1); }); it(`throws when ES is unable to find the index during get`, async () => { - callAdminCluster.mockResolvedValue({ status: 404 }); // this._callCluster('get', ...) + client.get.mockResolvedValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) + ); await expectNotFoundError(type, id, [newNs1, newNs2]); - expectClusterCalls('get'); + expect(client.get).toHaveBeenCalledTimes(1); }); it(`throws when the document exists, but not in this namespace`, async () => { - mockGetResponse(type, id); // this._callCluster('get', ...) + mockGetResponse(type, id); await expectNotFoundError(type, id, [newNs1, newNs2], { namespace: 'some-other-namespace', }); - expectClusterCalls('get'); + expect(client.get).toHaveBeenCalledTimes(1); }); it(`throws when ES is unable to find the document during update`, async () => { - mockGetResponse(type, id); // this._callCluster('get', ...) - callAdminCluster.mockResolvedValue({ status: 404 }); // this._writeToCluster('update', ...) - await expectNotFoundError(type, id, [newNs1, newNs2]); - expectClusterCalls('get', 'update'); - }); - }); - - describe('migration', () => { - it(`waits until migrations are complete before proceeding`, async () => { - let callAdminClusterCount = 0; - migrator.runMigrations = jest.fn(async () => - // runMigrations should resolve before callAdminCluster is initiated - expect(callAdminCluster).toHaveBeenCalledTimes(callAdminClusterCount++) + mockGetResponse(type, id); + client.update.mockResolvedValue( + elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) ); - await expect(addToNamespacesSuccess(type, id, [newNs1, newNs2])).resolves.toBeDefined(); - expect(migrator.runMigrations).toHaveReturnedTimes(2); + await expectNotFoundError(type, id, [newNs1, newNs2]); + expect(client.get).toHaveBeenCalledTimes(1); + expect(client.update).toHaveBeenCalledTimes(1); }); }); @@ -457,17 +445,21 @@ describe('SavedObjectsRepository', () => { objects.filter(({ type, id }) => registry.isMultiNamespace(type) && id); if (multiNamespaceObjects?.length) { const response = getMockMgetResponse(multiNamespaceObjects, options?.namespace); - callAdminCluster.mockResolvedValueOnce(response); // this._callCluster('mget', ...) + client.mget.mockResolvedValue( + elasticsearchClientMock.createSuccessTransportRequestPromise(response) + ); } const response = getMockBulkCreateResponse(objects, options?.namespace); - callAdminCluster.mockResolvedValue(response); // this._writeToCluster('bulk', ...) + client.bulk.mockResolvedValue( + elasticsearchClientMock.createSuccessTransportRequestPromise(response) + ); const result = await savedObjectsRepository.bulkCreate(objects, options); - expect(callAdminCluster).toHaveBeenCalledTimes(multiNamespaceObjects?.length ? 2 : 1); + expect(client.mget).toHaveBeenCalledTimes(multiNamespaceObjects?.length ? 1 : 0); return result; }; // bulk create calls have two objects for each source -- the action, and the source - const expectClusterCallArgsAction = ( + const expectClientCallArgsAction = ( objects, { method, _index = expect.any(String), getId = () => expect.any(String) } ) => { @@ -476,7 +468,10 @@ describe('SavedObjectsRepository', () => { body.push({ [method]: { _index, _id: getId(type, id) } }); body.push(expect.any(Object)); } - expectClusterCallArgs({ body }); + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); }; const expectObjArgs = ({ type, attributes, references }, overrides) => [ @@ -498,53 +493,60 @@ describe('SavedObjectsRepository', () => { ...mockTimestampFields, }); - describe('cluster calls', () => { + describe('client calls', () => { it(`should use the ES bulk action by default`, async () => { await bulkCreateSuccess([obj1, obj2]); - expectClusterCalls('bulk'); + expect(client.bulk).toHaveBeenCalledTimes(1); }); it(`should use the ES mget action before bulk action for any types that are multi-namespace, when overwrite=true`, async () => { const objects = [obj1, { ...obj2, type: MULTI_NAMESPACE_TYPE }]; await bulkCreateSuccess(objects, { overwrite: true }); - expectClusterCalls('mget', 'bulk'); + expect(client.bulk).toHaveBeenCalledTimes(1); + expect(client.mget).toHaveBeenCalledTimes(1); const docs = [expect.objectContaining({ _id: `${MULTI_NAMESPACE_TYPE}:${obj2.id}` })]; - expectClusterCallArgs({ body: { docs } }, 1); + expect(client.mget.mock.calls[0][0].body).toEqual({ docs }); }); it(`should use the ES create method if ID is undefined and overwrite=true`, async () => { const objects = [obj1, obj2].map((obj) => ({ ...obj, id: undefined })); await bulkCreateSuccess(objects, { overwrite: true }); - expectClusterCallArgsAction(objects, { method: 'create' }); + expectClientCallArgsAction(objects, { method: 'create' }); }); it(`should use the ES create method if ID is undefined and overwrite=false`, async () => { const objects = [obj1, obj2].map((obj) => ({ ...obj, id: undefined })); await bulkCreateSuccess(objects); - expectClusterCallArgsAction(objects, { method: 'create' }); + expectClientCallArgsAction(objects, { method: 'create' }); }); it(`should use the ES index method if ID is defined and overwrite=true`, async () => { await bulkCreateSuccess([obj1, obj2], { overwrite: true }); - expectClusterCallArgsAction([obj1, obj2], { method: 'index' }); + expectClientCallArgsAction([obj1, obj2], { method: 'index' }); }); it(`should use the ES create method if ID is defined and overwrite=false`, async () => { await bulkCreateSuccess([obj1, obj2]); - expectClusterCallArgsAction([obj1, obj2], { method: 'create' }); + expectClientCallArgsAction([obj1, obj2], { method: 'create' }); }); it(`formats the ES request`, async () => { await bulkCreateSuccess([obj1, obj2]); const body = [...expectObjArgs(obj1), ...expectObjArgs(obj2)]; - expectClusterCallArgs({ body }); + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); }); it(`adds namespace to request body for any types that are single-namespace`, async () => { await bulkCreateSuccess([obj1, obj2], { namespace }); const expected = expect.objectContaining({ namespace }); const body = [expect.any(Object), expected, expect.any(Object), expected]; - expectClusterCallArgs({ body }); + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); }); it(`doesn't add namespace to request body for any types that are not single-namespace`, async () => { @@ -555,7 +557,10 @@ describe('SavedObjectsRepository', () => { await bulkCreateSuccess(objects, { namespace }); const expected = expect.not.objectContaining({ namespace: expect.anything() }); const body = [expect.any(Object), expected, expect.any(Object), expected]; - expectClusterCallArgs({ body }); + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); }); it(`adds namespaces to request body for any types that are multi-namespace`, async () => { @@ -565,8 +570,12 @@ describe('SavedObjectsRepository', () => { await bulkCreateSuccess(objects, { namespace, overwrite: true }); const expected = expect.objectContaining({ namespaces }); const body = [expect.any(Object), expected, expect.any(Object), expected]; - expectClusterCallArgs({ body }, 2); - callAdminCluster.mockReset(); + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + client.bulk.mockClear(); + client.mget.mockClear(); }; await test(undefined); await test(namespace); @@ -578,8 +587,11 @@ describe('SavedObjectsRepository', () => { await bulkCreateSuccess(objects, { namespace, overwrite: true }); const expected = expect.not.objectContaining({ namespaces: expect.anything() }); const body = [expect.any(Object), expected, expect.any(Object), expected]; - expectClusterCallArgs({ body }); - callAdminCluster.mockReset(); + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + client.bulk.mockClear(); }; await test(undefined); await test(namespace); @@ -587,35 +599,32 @@ describe('SavedObjectsRepository', () => { it(`defaults to a refresh setting of wait_for`, async () => { await bulkCreateSuccess([obj1, obj2]); - expectClusterCallArgs({ refresh: 'wait_for' }); - }); - - it(`accepts a custom refresh setting`, async () => { - const refresh = 'foo'; - await bulkCreateSuccess([obj1, obj2], { refresh }); - expectClusterCallArgs({ refresh }); + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ refresh: 'wait_for' }), + expect.anything() + ); }); it(`should use default index`, async () => { await bulkCreateSuccess([obj1, obj2]); - expectClusterCallArgsAction([obj1, obj2], { method: 'create', _index: '.kibana-test' }); + expectClientCallArgsAction([obj1, obj2], { method: 'create', _index: '.kibana-test' }); }); it(`should use custom index`, async () => { await bulkCreateSuccess([obj1, obj2].map((x) => ({ ...x, type: CUSTOM_INDEX_TYPE }))); - expectClusterCallArgsAction([obj1, obj2], { method: 'create', _index: 'custom' }); + expectClientCallArgsAction([obj1, obj2], { method: 'create', _index: 'custom' }); }); it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => { const getId = (type, id) => `${namespace}:${type}:${id}`; await bulkCreateSuccess([obj1, obj2], { namespace }); - expectClusterCallArgsAction([obj1, obj2], { method: 'create', getId }); + expectClientCallArgsAction([obj1, obj2], { method: 'create', getId }); }); it(`doesn't prepend namespace to the id when providing no namespace for single-namespace type`, async () => { const getId = (type, id) => `${type}:${id}`; await bulkCreateSuccess([obj1, obj2]); - expectClusterCallArgsAction([obj1, obj2], { method: 'create', getId }); + expectClientCallArgsAction([obj1, obj2], { method: 'create', getId }); }); it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => { @@ -625,7 +634,7 @@ describe('SavedObjectsRepository', () => { { ...obj2, type: MULTI_NAMESPACE_TYPE }, ]; await bulkCreateSuccess(objects, { namespace }); - expectClusterCallArgsAction(objects, { method: 'create', getId }); + expectClientCallArgsAction(objects, { method: 'create', getId }); }); }); @@ -645,14 +654,19 @@ describe('SavedObjectsRepository', () => { } else { response = getMockBulkCreateResponse([obj1, obj2]); } - callAdminCluster.mockResolvedValue(response); // this._writeToCluster('bulk', ...) + client.bulk.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(response) + ); const objects = [obj1, obj, obj2]; const result = await savedObjectsRepository.bulkCreate(objects); - expectClusterCalls('bulk'); + expect(client.bulk).toHaveBeenCalled(); const objCall = esError ? expectObjArgs(obj) : []; const body = [...expectObjArgs(obj1), ...objCall, ...expectObjArgs(obj2)]; - expectClusterCallArgs({ body }); + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); expect(result).toEqual({ saved_objects: [expectSuccess(obj1), expectedError, expectSuccess(obj2)], }); @@ -682,17 +696,29 @@ describe('SavedObjectsRepository', () => { }, ], }; - callAdminCluster.mockResolvedValueOnce(response1); // this._callCluster('mget', ...) + client.mget.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(response1) + ); const response2 = getMockBulkCreateResponse([obj1, obj2]); - callAdminCluster.mockResolvedValue(response2); // this._writeToCluster('bulk', ...) + client.bulk.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(response2) + ); const options = { overwrite: true }; const result = await savedObjectsRepository.bulkCreate([obj1, obj, obj2], options); - expectClusterCalls('mget', 'bulk'); + expect(client.bulk).toHaveBeenCalled(); + expect(client.mget).toHaveBeenCalled(); + const body1 = { docs: [expect.objectContaining({ _id: `${obj.type}:${obj.id}` })] }; - expectClusterCallArgs({ body: body1 }, 1); + expect(client.mget).toHaveBeenCalledWith( + expect.objectContaining({ body: body1 }), + expect.anything() + ); const body2 = [...expectObjArgs(obj1), ...expectObjArgs(obj2)]; - expectClusterCallArgs({ body: body2 }, 2); + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body: body2 }), + expect.anything() + ); expect(result).toEqual({ saved_objects: [expectSuccess(obj1), expectErrorConflict(obj), expectSuccess(obj2)], }); @@ -721,14 +747,6 @@ describe('SavedObjectsRepository', () => { }); describe('migration', () => { - it(`waits until migrations are complete before proceeding`, async () => { - migrator.runMigrations = jest.fn(async () => - expect(callAdminCluster).not.toHaveBeenCalled() - ); - await expect(bulkCreateSuccess([obj1, obj2])).resolves.toBeDefined(); - expect(migrator.runMigrations).toHaveBeenCalledTimes(1); - }); - it(`migrates the docs and serializes the migrated docs`, async () => { migrator.migrateDocument.mockImplementation(mockMigrateDocument); await bulkCreateSuccess([obj1, obj2]); @@ -793,9 +811,7 @@ describe('SavedObjectsRepository', () => { }); }); - it(`should return objects in the same order regardless of type`, async () => { - // TODO - }); + it.todo(`should return objects in the same order regardless of type`); it(`handles a mix of successful creates and errors`, async () => { const obj = { @@ -804,9 +820,11 @@ describe('SavedObjectsRepository', () => { }; const objects = [obj1, obj, obj2]; const response = getMockBulkCreateResponse([obj1, obj2]); - callAdminCluster.mockResolvedValue(response); // this._writeToCluster('bulk', ...) + client.bulk.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(response) + ); const result = await savedObjectsRepository.bulkCreate(objects); - expect(callAdminCluster).toHaveBeenCalledTimes(1); + expect(client.bulk).toHaveBeenCalledTimes(1); expect(result).toEqual({ saved_objects: [expectSuccessResult(obj1), expectError(obj), expectSuccessResult(obj2)], }); @@ -817,7 +835,9 @@ describe('SavedObjectsRepository', () => { // we returned raw ID's when an object without an id was created. const namespace = 'myspace'; const response = getMockBulkCreateResponse([obj1, obj2], namespace); - callAdminCluster.mockResolvedValueOnce(response); // this._writeToCluster('bulk', ...) + client.bulk.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(response) + ); // Bulk create one object with id unspecified, and one with id specified const result = await savedObjectsRepository.bulkCreate([{ ...obj1, id: undefined }, obj2], { @@ -884,69 +904,78 @@ describe('SavedObjectsRepository', () => { ); const bulkGetSuccess = async (objects, options) => { const response = getMockMgetResponse(objects, options?.namespace); - callAdminCluster.mockReturnValue(response); + client.mget.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(response) + ); const result = await bulkGet(objects, options); - expect(callAdminCluster).toHaveBeenCalledTimes(1); + expect(client.mget).toHaveBeenCalledTimes(1); return result; }; - const _expectClusterCallArgs = ( + const _expectClientCallArgs = ( objects, { _index = expect.any(String), getId = () => expect.any(String) } ) => { - expectClusterCallArgs({ - body: { - docs: objects.map(({ type, id }) => - expect.objectContaining({ - _index, - _id: getId(type, id), - }) - ), - }, - }); + expect(client.mget).toHaveBeenCalledWith( + expect.objectContaining({ + body: { + docs: objects.map(({ type, id }) => + expect.objectContaining({ + _index, + _id: getId(type, id), + }) + ), + }, + }), + expect.anything() + ); }; - describe('cluster calls', () => { + describe('client calls', () => { it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => { const getId = (type, id) => `${namespace}:${type}:${id}`; await bulkGetSuccess([obj1, obj2], { namespace }); - _expectClusterCallArgs([obj1, obj2], { getId }); + _expectClientCallArgs([obj1, obj2], { getId }); }); it(`doesn't prepend namespace to the id when providing no namespace for single-namespace type`, async () => { const getId = (type, id) => `${type}:${id}`; await bulkGetSuccess([obj1, obj2]); - _expectClusterCallArgs([obj1, obj2], { getId }); + _expectClientCallArgs([obj1, obj2], { getId }); }); it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => { const getId = (type, id) => `${type}:${id}`; let objects = [obj1, obj2].map((obj) => ({ ...obj, type: NAMESPACE_AGNOSTIC_TYPE })); await bulkGetSuccess(objects, { namespace }); - _expectClusterCallArgs(objects, { getId }); + _expectClientCallArgs(objects, { getId }); - callAdminCluster.mockReset(); + client.mget.mockClear(); objects = [obj1, obj2].map((obj) => ({ ...obj, type: MULTI_NAMESPACE_TYPE })); await bulkGetSuccess(objects, { namespace }); - _expectClusterCallArgs(objects, { getId }); + _expectClientCallArgs(objects, { getId }); }); }); describe('errors', () => { const bulkGetErrorInvalidType = async ([obj1, obj, obj2]) => { const response = getMockMgetResponse([obj1, obj2]); - callAdminCluster.mockResolvedValue(response); + client.mget.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(response) + ); const result = await bulkGet([obj1, obj, obj2]); - expectClusterCalls('mget'); + expect(client.mget).toHaveBeenCalled(); expect(result).toEqual({ saved_objects: [expectSuccess(obj1), expectErrorInvalidType(obj), expectSuccess(obj2)], }); }; const bulkGetErrorNotFound = async ([obj1, obj, obj2], options, response) => { - callAdminCluster.mockResolvedValue(response); + client.mget.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(response) + ); const result = await bulkGet([obj1, obj, obj2], options); - expectClusterCalls('mget'); + expect(client.mget).toHaveBeenCalled(); expect(result).toEqual({ saved_objects: [expectSuccess(obj1), expectErrorNotFound(obj), expectSuccess(obj2)], }); @@ -982,16 +1011,6 @@ describe('SavedObjectsRepository', () => { }); }); - describe('migration', () => { - it(`waits until migrations are complete before proceeding`, async () => { - migrator.runMigrations = jest.fn(async () => - expect(callAdminCluster).not.toHaveBeenCalled() - ); - await expect(bulkGetSuccess([obj1, obj2])).resolves.toBeDefined(); - expect(migrator.runMigrations).toHaveBeenCalledTimes(1); - }); - }); - describe('returns', () => { const expectSuccessResult = ({ type, id }, doc) => ({ type, @@ -1007,14 +1026,16 @@ describe('SavedObjectsRepository', () => { it(`returns early for empty objects argument`, async () => { const result = await bulkGet([]); expect(result).toEqual({ saved_objects: [] }); - expect(callAdminCluster).not.toHaveBeenCalled(); + expect(client.mget).not.toHaveBeenCalled(); }); it(`formats the ES response`, async () => { const response = getMockMgetResponse([obj1, obj2]); - callAdminCluster.mockResolvedValue(response); + client.mget.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(response) + ); const result = await bulkGet([obj1, obj2]); - expect(callAdminCluster).toHaveBeenCalledTimes(1); + expect(client.mget).toHaveBeenCalledTimes(1); expect(result).toEqual({ saved_objects: [ expectSuccessResult(obj1, response.docs[0]), @@ -1025,10 +1046,12 @@ describe('SavedObjectsRepository', () => { it(`handles a mix of successful gets and errors`, async () => { const response = getMockMgetResponse([obj1, obj2]); - callAdminCluster.mockResolvedValue(response); + client.mget.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(response) + ); const obj = { type: 'unknownType', id: 'three' }; const result = await bulkGet([obj1, obj, obj2]); - expect(callAdminCluster).toHaveBeenCalledTimes(1); + expect(client.mget).toHaveBeenCalledTimes(1); expect(result).toEqual({ saved_objects: [ expectSuccessResult(obj1, response.docs[0]), @@ -1081,20 +1104,23 @@ describe('SavedObjectsRepository', () => { const multiNamespaceObjects = objects.filter(({ type }) => registry.isMultiNamespace(type)); if (multiNamespaceObjects?.length) { const response = getMockMgetResponse(multiNamespaceObjects, options?.namespace); - callAdminCluster.mockResolvedValueOnce(response); // this._callCluster('mget', ...) + client.mget.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(response) + ); } const response = getMockBulkUpdateResponse(objects, options?.namespace); - callAdminCluster.mockResolvedValue(response); // this._writeToCluster('bulk', ...) + client.bulk.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(response) + ); const result = await savedObjectsRepository.bulkUpdate(objects, options); - expect(callAdminCluster).toHaveBeenCalledTimes(multiNamespaceObjects?.length ? 2 : 1); + expect(client.mget).toHaveBeenCalledTimes(multiNamespaceObjects?.length ? 1 : 0); return result; }; // bulk create calls have two objects for each source -- the action, and the source - const expectClusterCallArgsAction = ( + const expectClientCallArgsAction = ( objects, - { method, _index = expect.any(String), getId = () => expect.any(String), overrides }, - n + { method, _index = expect.any(String), getId = () => expect.any(String), overrides } ) => { const body = []; for (const { type, id } of objects) { @@ -1107,7 +1133,10 @@ describe('SavedObjectsRepository', () => { }); body.push(expect.any(Object)); } - expectClusterCallArgs({ body }, n); + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); }; const expectObjArgs = ({ type, attributes }) => [ @@ -1120,44 +1149,58 @@ describe('SavedObjectsRepository', () => { }, ]; - describe('cluster calls', () => { + describe('client calls', () => { it(`should use the ES bulk action by default`, async () => { await bulkUpdateSuccess([obj1, obj2]); - expectClusterCalls('bulk'); + expect(client.bulk).toHaveBeenCalled(); }); it(`should use the ES mget action before bulk action for any types that are multi-namespace`, async () => { const objects = [obj1, { ...obj2, type: MULTI_NAMESPACE_TYPE }]; await bulkUpdateSuccess(objects); - expectClusterCalls('mget', 'bulk'); + expect(client.bulk).toHaveBeenCalled(); + expect(client.mget).toHaveBeenCalled(); + const docs = [expect.objectContaining({ _id: `${MULTI_NAMESPACE_TYPE}:${obj2.id}` })]; - expectClusterCallArgs({ body: { docs } }, 1); + expect(client.mget).toHaveBeenCalledWith( + expect.objectContaining({ body: { docs } }), + expect.anything() + ); }); it(`formats the ES request`, async () => { await bulkUpdateSuccess([obj1, obj2]); const body = [...expectObjArgs(obj1), ...expectObjArgs(obj2)]; - expectClusterCallArgs({ body }); + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); }); it(`formats the ES request for any types that are multi-namespace`, async () => { const _obj2 = { ...obj2, type: MULTI_NAMESPACE_TYPE }; await bulkUpdateSuccess([obj1, _obj2]); const body = [...expectObjArgs(obj1), ...expectObjArgs(_obj2)]; - expectClusterCallArgs({ body }, 2); + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); }); it(`doesnt call Elasticsearch if there are no valid objects to update`, async () => { const objects = [obj1, obj2].map((x) => ({ ...x, type: 'unknownType' })); await savedObjectsRepository.bulkUpdate(objects); - expect(callAdminCluster).toHaveBeenCalledTimes(0); + expect(client.bulk).toHaveBeenCalledTimes(0); }); it(`defaults to no references`, async () => { await bulkUpdateSuccess([obj1, obj2]); const expected = { doc: expect.not.objectContaining({ references: expect.anything() }) }; const body = [expect.any(Object), expected, expect.any(Object), expected]; - expectClusterCallArgs({ body }); + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); }); it(`accepts custom references array`, async () => { @@ -1166,8 +1209,11 @@ describe('SavedObjectsRepository', () => { await bulkUpdateSuccess(objects); const expected = { doc: expect.objectContaining({ references }) }; const body = [expect.any(Object), expected, expect.any(Object), expected]; - expectClusterCallArgs({ body }); - callAdminCluster.mockReset(); + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + client.bulk.mockClear(); }; await test(references); await test(['string']); @@ -1180,8 +1226,11 @@ describe('SavedObjectsRepository', () => { await bulkUpdateSuccess(objects); const expected = { doc: expect.not.objectContaining({ references: expect.anything() }) }; const body = [expect.any(Object), expected, expect.any(Object), expected]; - expectClusterCallArgs({ body }); - callAdminCluster.mockReset(); + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + client.bulk.mockClear(); }; await test('string'); await test(123); @@ -1191,13 +1240,10 @@ describe('SavedObjectsRepository', () => { it(`defaults to a refresh setting of wait_for`, async () => { await bulkUpdateSuccess([obj1, obj2]); - expectClusterCallArgs({ refresh: 'wait_for' }); - }); - - it(`accepts a custom refresh setting`, async () => { - const refresh = 'foo'; - await bulkUpdateSuccess([obj1, obj2], { refresh }); - expectClusterCallArgs({ refresh }); + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ refresh: 'wait_for' }), + expect.anything() + ); }); it(`defaults to the version of the existing document for multi-namespace types`, async () => { @@ -1211,13 +1257,13 @@ describe('SavedObjectsRepository', () => { if_seq_no: mockVersionProps._seq_no, if_primary_term: mockVersionProps._primary_term, }; - expectClusterCallArgsAction(objects, { method: 'update', overrides }, 2); + expectClientCallArgsAction(objects, { method: 'update', overrides }); }); it(`defaults to no version for types that are not multi-namespace`, async () => { const objects = [obj1, { ...obj2, type: NAMESPACE_AGNOSTIC_TYPE }]; await bulkUpdateSuccess(objects); - expectClusterCallArgsAction(objects, { method: 'update' }); + expectClientCallArgsAction(objects, { method: 'update' }); }); it(`accepts version`, async () => { @@ -1229,27 +1275,27 @@ describe('SavedObjectsRepository', () => { ]; await bulkUpdateSuccess(objects); const overrides = { if_seq_no: 100, if_primary_term: 200 }; - expectClusterCallArgsAction(objects, { method: 'update', overrides }, 2); + expectClientCallArgsAction(objects, { method: 'update', overrides }, 2); }); it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => { const getId = (type, id) => `${namespace}:${type}:${id}`; await bulkUpdateSuccess([obj1, obj2], { namespace }); - expectClusterCallArgsAction([obj1, obj2], { method: 'update', getId }); + expectClientCallArgsAction([obj1, obj2], { method: 'update', getId }); }); it(`doesn't prepend namespace to the id when providing no namespace for single-namespace type`, async () => { const getId = (type, id) => `${type}:${id}`; await bulkUpdateSuccess([obj1, obj2]); - expectClusterCallArgsAction([obj1, obj2], { method: 'update', getId }); + expectClientCallArgsAction([obj1, obj2], { method: 'update', getId }); }); it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => { const getId = (type, id) => `${type}:${id}`; const objects1 = [{ ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }]; await bulkUpdateSuccess(objects1, { namespace }); - expectClusterCallArgsAction(objects1, { method: 'update', getId }); - callAdminCluster.mockReset(); + expectClientCallArgsAction(objects1, { method: 'update', getId }); + client.bulk.mockClear(); const overrides = { // bulkUpdate uses a preflight `get` request for multi-namespace saved objects, and specifies that version on `update` // we aren't testing for this here, but we need to include Jest assertions so this test doesn't fail @@ -1258,7 +1304,7 @@ describe('SavedObjectsRepository', () => { }; const objects2 = [{ ...obj2, type: MULTI_NAMESPACE_TYPE }]; await bulkUpdateSuccess(objects2, { namespace }); - expectClusterCallArgsAction(objects2, { method: 'update', getId, overrides }, 2); + expectClientCallArgsAction(objects2, { method: 'update', getId, overrides }, 2); }); }); @@ -1274,27 +1320,44 @@ describe('SavedObjectsRepository', () => { if (esError) { mockResponse.items[1].update = { error: esError }; } - callAdminCluster.mockResolvedValue(mockResponse); // this._writeToCluster('bulk', ...) + client.bulk.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(mockResponse) + ); const result = await savedObjectsRepository.bulkUpdate(objects); - expectClusterCalls('bulk'); + expect(client.bulk).toHaveBeenCalled(); const objCall = esError ? expectObjArgs(obj) : []; const body = [...expectObjArgs(obj1), ...objCall, ...expectObjArgs(obj2)]; - expectClusterCallArgs({ body }); + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); expect(result).toEqual({ saved_objects: [expectSuccess(obj1), expectedError, expectSuccess(obj2)], }); }; const bulkUpdateMultiError = async ([obj1, _obj, obj2], options, mgetResponse) => { - callAdminCluster.mockResolvedValueOnce(mgetResponse); // this._callCluster('mget', ...) + client.mget.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(mgetResponse, { + statusCode: mgetResponse.statusCode, + }) + ); + const bulkResponse = getMockBulkUpdateResponse([obj1, obj2], namespace); - callAdminCluster.mockResolvedValue(bulkResponse); // this._writeToCluster('bulk', ...) + client.bulk.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(bulkResponse) + ); const result = await savedObjectsRepository.bulkUpdate([obj1, _obj, obj2], options); - expectClusterCalls('mget', 'bulk'); + expect(client.bulk).toHaveBeenCalled(); + expect(client.mget).toHaveBeenCalled(); const body = [...expectObjArgs(obj1), ...expectObjArgs(obj2)]; - expectClusterCallArgs({ body }, 2); + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + expect(result).toEqual({ saved_objects: [expectSuccess(obj1), expectErrorNotFound(_obj), expectSuccess(obj2)], }); @@ -1318,7 +1381,7 @@ describe('SavedObjectsRepository', () => { it(`returns error when ES is unable to find the index (mget)`, async () => { const _obj = { ...obj, type: MULTI_NAMESPACE_TYPE }; - const mgetResponse = { status: 404 }; + const mgetResponse = { statusCode: 404 }; await bulkUpdateMultiError([obj1, _obj, obj2], { namespace }, mgetResponse); }); @@ -1350,16 +1413,6 @@ describe('SavedObjectsRepository', () => { }); }); - describe('migration', () => { - it(`waits until migrations are complete before proceeding`, async () => { - migrator.runMigrations = jest.fn(async () => - expect(callAdminCluster).not.toHaveBeenCalled() - ); - await expect(bulkUpdateSuccess([obj1, obj2])).resolves.toBeDefined(); - expect(migrator.runMigrations).toHaveReturnedTimes(1); - }); - }); - describe('returns', () => { const expectSuccessResult = ({ type, id, attributes, references, namespaces }) => ({ type, @@ -1393,9 +1446,12 @@ describe('SavedObjectsRepository', () => { }; const objects = [obj1, obj, obj2]; const mockResponse = getMockBulkUpdateResponse(objects); - callAdminCluster.mockResolvedValue(mockResponse); // this._writeToCluster('bulk', ...) + client.bulk.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(mockResponse) + ); + const result = await savedObjectsRepository.bulkUpdate(objects); - expect(callAdminCluster).toHaveBeenCalledTimes(1); + expect(client.bulk).toHaveBeenCalledTimes(1); expect(result).toEqual({ saved_objects: [expectSuccessResult(obj1), expectError(obj), expectSuccessResult(obj2)], }); @@ -1416,10 +1472,12 @@ describe('SavedObjectsRepository', () => { describe('#create', () => { beforeEach(() => { - callAdminCluster.mockImplementation((method, params) => ({ - _id: params.id, - ...mockVersionProps, - })); + client.create.mockImplementation((params) => + elasticsearchClientMock.createSuccessTransportRequestPromise({ + _id: params.id, + ...mockVersionProps, + }) + ); }); const type = 'index-pattern'; @@ -1436,52 +1494,49 @@ describe('SavedObjectsRepository', () => { const createSuccess = async (type, attributes, options) => { const result = await savedObjectsRepository.create(type, attributes, options); - expect(callAdminCluster).toHaveBeenCalledTimes( - registry.isMultiNamespace(type) && options.overwrite ? 2 : 1 + expect(client.get).toHaveBeenCalledTimes( + registry.isMultiNamespace(type) && options.overwrite ? 1 : 0 ); return result; }; - describe('cluster calls', () => { + describe('client calls', () => { it(`should use the ES create action if ID is undefined and overwrite=true`, async () => { await createSuccess(type, attributes, { overwrite: true }); - expectClusterCalls('create'); + expect(client.create).toHaveBeenCalled(); }); it(`should use the ES create action if ID is undefined and overwrite=false`, async () => { await createSuccess(type, attributes); - expectClusterCalls('create'); + expect(client.create).toHaveBeenCalled(); }); it(`should use the ES index action if ID is defined and overwrite=true`, async () => { await createSuccess(type, attributes, { id, overwrite: true }); - expectClusterCalls('index'); + expect(client.index).toHaveBeenCalled(); }); it(`should use the ES create action if ID is defined and overwrite=false`, async () => { await createSuccess(type, attributes, { id }); - expectClusterCalls('create'); + expect(client.create).toHaveBeenCalled(); }); it(`should use the ES get action then index action if type is multi-namespace, ID is defined, and overwrite=true`, async () => { await createSuccess(MULTI_NAMESPACE_TYPE, attributes, { id, overwrite: true }); - expectClusterCalls('get', 'index'); + expect(client.get).toHaveBeenCalled(); + expect(client.index).toHaveBeenCalled(); }); it(`defaults to empty references array`, async () => { await createSuccess(type, attributes, { id }); - expectClusterCallArgs({ - body: expect.objectContaining({ references: [] }), - }); + expect(client.create.mock.calls[0][0].body.references).toEqual([]); }); it(`accepts custom references array`, async () => { const test = async (references) => { await createSuccess(type, attributes, { id, references }); - expectClusterCallArgs({ - body: expect.objectContaining({ references }), - }); - callAdminCluster.mockReset(); + expect(client.create.mock.calls[0][0].body.references).toEqual(references); + client.create.mockClear(); }; await test(references); await test(['string']); @@ -1491,10 +1546,8 @@ describe('SavedObjectsRepository', () => { it(`doesn't accept custom references if not an array`, async () => { const test = async (references) => { await createSuccess(type, attributes, { id, references }); - expectClusterCallArgs({ - body: expect.not.objectContaining({ references: expect.anything() }), - }); - callAdminCluster.mockReset(); + expect(client.create.mock.calls[0][0].body.references).not.toBeDefined(); + client.create.mockClear(); }; await test('string'); await test(123); @@ -1504,49 +1557,75 @@ describe('SavedObjectsRepository', () => { it(`defaults to a refresh setting of wait_for`, async () => { await createSuccess(type, attributes); - expectClusterCallArgs({ refresh: 'wait_for' }); - }); - - it(`accepts a custom refresh setting`, async () => { - const refresh = 'foo'; - await createSuccess(type, attributes, { refresh }); - expectClusterCallArgs({ refresh }); + expect(client.create).toHaveBeenCalledWith( + expect.objectContaining({ refresh: 'wait_for' }), + expect.anything() + ); }); it(`should use default index`, async () => { await createSuccess(type, attributes, { id }); - expectClusterCallArgs({ index: '.kibana-test' }); + expect(client.create).toHaveBeenCalledWith( + expect.objectContaining({ index: '.kibana-test' }), + expect.anything() + ); }); it(`should use custom index`, async () => { await createSuccess(CUSTOM_INDEX_TYPE, attributes, { id }); - expectClusterCallArgs({ index: 'custom' }); + expect(client.create).toHaveBeenCalledWith( + expect.objectContaining({ index: 'custom' }), + expect.anything() + ); }); it(`self-generates an id if none is provided`, async () => { await createSuccess(type, attributes); - expectClusterCallArgs({ - id: expect.objectContaining(/index-pattern:[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}/), - }); + expect(client.create).toHaveBeenCalledWith( + expect.objectContaining({ + id: expect.objectContaining(/index-pattern:[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}/), + }), + expect.anything() + ); }); it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => { await createSuccess(type, attributes, { id, namespace }); - expectClusterCallArgs({ id: `${namespace}:${type}:${id}` }); + expect(client.create).toHaveBeenCalledWith( + expect.objectContaining({ + id: `${namespace}:${type}:${id}`, + }), + expect.anything() + ); }); it(`doesn't prepend namespace to the id when providing no namespace for single-namespace type`, async () => { await createSuccess(type, attributes, { id }); - expectClusterCallArgs({ id: `${type}:${id}` }); + expect(client.create).toHaveBeenCalledWith( + expect.objectContaining({ + id: `${type}:${id}`, + }), + expect.anything() + ); }); it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => { await createSuccess(NAMESPACE_AGNOSTIC_TYPE, attributes, { id, namespace }); - expectClusterCallArgs({ id: `${NAMESPACE_AGNOSTIC_TYPE}:${id}` }); - callAdminCluster.mockReset(); + expect(client.create).toHaveBeenCalledWith( + expect.objectContaining({ + id: `${NAMESPACE_AGNOSTIC_TYPE}:${id}`, + }), + expect.anything() + ); + client.create.mockClear(); await createSuccess(MULTI_NAMESPACE_TYPE, attributes, { id, namespace }); - expectClusterCallArgs({ id: `${MULTI_NAMESPACE_TYPE}:${id}` }); + expect(client.create).toHaveBeenCalledWith( + expect.objectContaining({ + id: `${MULTI_NAMESPACE_TYPE}:${id}`, + }), + expect.anything() + ); }); }); @@ -1555,14 +1634,14 @@ describe('SavedObjectsRepository', () => { await expect(savedObjectsRepository.create('unknownType', attributes)).rejects.toThrowError( createUnsupportedTypeError('unknownType') ); - expect(callAdminCluster).not.toHaveBeenCalled(); + expect(client.create).not.toHaveBeenCalled(); }); it(`throws when type is hidden`, async () => { await expect(savedObjectsRepository.create(HIDDEN_TYPE, attributes)).rejects.toThrowError( createUnsupportedTypeError(HIDDEN_TYPE) ); - expect(callAdminCluster).not.toHaveBeenCalled(); + expect(client.create).not.toHaveBeenCalled(); }); it(`throws when there is a conflict with an existing multi-namespace saved object (get)`, async () => { @@ -1571,7 +1650,9 @@ describe('SavedObjectsRepository', () => { id, namespace: 'bar-namespace', }); - callAdminCluster.mockResolvedValue(response); // this._callCluster('get', ...) + client.get.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(response) + ); await expect( savedObjectsRepository.create(MULTI_NAMESPACE_TYPE, attributes, { id, @@ -1579,16 +1660,12 @@ describe('SavedObjectsRepository', () => { namespace, }) ).rejects.toThrowError(createConflictError(MULTI_NAMESPACE_TYPE, id)); - expectClusterCalls('get'); + expect(client.get).toHaveBeenCalled(); }); - it(`throws when automatic index creation fails`, async () => { - // TODO - }); + it.todo(`throws when automatic index creation fails`); - it(`throws when an unexpected failure occurs`, async () => { - // TODO - }); + it.todo(`throws when an unexpected failure occurs`); }); describe('migration', () => { @@ -1596,14 +1673,6 @@ describe('SavedObjectsRepository', () => { migrator.migrateDocument.mockImplementation(mockMigrateDocument); }); - it(`waits until migrations are complete before proceeding`, async () => { - migrator.runMigrations = jest.fn(async () => - expect(callAdminCluster).not.toHaveBeenCalled() - ); - await expect(createSuccess(type, attributes, { id, namespace })).resolves.toBeDefined(); - expect(migrator.runMigrations).toHaveBeenCalledTimes(1); - }); - it(`migrates a document and serializes the migrated doc`, async () => { const migrationVersion = mockMigrationVersion; await createSuccess(type, attributes, { id, references, migrationVersion }); @@ -1628,7 +1697,7 @@ describe('SavedObjectsRepository', () => { await createSuccess(NAMESPACE_AGNOSTIC_TYPE, attributes, { id, namespace }); expectMigrationArgs({ namespace: expect.anything() }, false, 1); - callAdminCluster.mockReset(); + client.create.mockClear(); await createSuccess(MULTI_NAMESPACE_TYPE, attributes, { id }); expectMigrationArgs({ namespace: expect.anything() }, false, 2); }); @@ -1647,7 +1716,7 @@ describe('SavedObjectsRepository', () => { await createSuccess(type, attributes, { id }); expectMigrationArgs({ namespaces: expect.anything() }, false, 1); - callAdminCluster.mockReset(); + client.create.mockClear(); await createSuccess(NAMESPACE_AGNOSTIC_TYPE, attributes, { id }); expectMigrationArgs({ namespaces: expect.anything() }, false, 2); }); @@ -1678,33 +1747,43 @@ describe('SavedObjectsRepository', () => { const deleteSuccess = async (type, id, options) => { if (registry.isMultiNamespace(type)) { const mockGetResponse = getMockGetResponse({ type, id, namespace: options?.namespace }); - callAdminCluster.mockResolvedValueOnce(mockGetResponse); // this._callCluster('get', ...) + client.get.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(mockGetResponse) + ); } - callAdminCluster.mockResolvedValue({ result: 'deleted' }); // this._writeToCluster('delete', ...) + client.delete.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ result: 'deleted' }) + ); const result = await savedObjectsRepository.delete(type, id, options); - expect(callAdminCluster).toHaveBeenCalledTimes(registry.isMultiNamespace(type) ? 2 : 1); + expect(client.get).toHaveBeenCalledTimes(registry.isMultiNamespace(type) ? 1 : 0); return result; }; - describe('cluster calls', () => { + describe('client calls', () => { it(`should use the ES delete action when not using a multi-namespace type`, async () => { await deleteSuccess(type, id); - expectClusterCalls('delete'); + expect(client.delete).toHaveBeenCalledTimes(1); }); it(`should use ES get action then delete action when using a multi-namespace type with no namespaces remaining`, async () => { await deleteSuccess(MULTI_NAMESPACE_TYPE, id); - expectClusterCalls('get', 'delete'); + expect(client.get).toHaveBeenCalledTimes(1); + expect(client.delete).toHaveBeenCalledTimes(1); }); it(`should use ES get action then update action when using a multi-namespace type with one or more namespaces remaining`, async () => { const mockResponse = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id }); mockResponse._source.namespaces = ['default', 'some-other-nameespace']; - callAdminCluster - .mockResolvedValueOnce(mockResponse) // this._callCluster('get', ...) - .mockResolvedValue({ result: 'updated' }); // this._writeToCluster('update', ...) + client.get.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(mockResponse) + ); + client.update.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ result: 'updated' }) + ); + await savedObjectsRepository.delete(MULTI_NAMESPACE_TYPE, id); - expectClusterCalls('get', 'update'); + expect(client.get).toHaveBeenCalledTimes(1); + expect(client.update).toHaveBeenCalledTimes(1); }); it(`includes the version of the existing document when type is multi-namespace`, async () => { @@ -1713,37 +1792,49 @@ describe('SavedObjectsRepository', () => { if_seq_no: mockVersionProps._seq_no, if_primary_term: mockVersionProps._primary_term, }; - expectClusterCallArgs(versionProperties, 2); + expect(client.delete).toHaveBeenCalledWith( + expect.objectContaining(versionProperties), + expect.anything() + ); }); it(`defaults to a refresh setting of wait_for`, async () => { await deleteSuccess(type, id); - expectClusterCallArgs({ refresh: 'wait_for' }); - }); - - it(`accepts a custom refresh setting`, async () => { - const refresh = 'foo'; - await deleteSuccess(type, id, { refresh }); - expectClusterCallArgs({ refresh }); + expect(client.delete).toHaveBeenCalledWith( + expect.objectContaining({ refresh: 'wait_for' }), + expect.anything() + ); }); it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => { await deleteSuccess(type, id, { namespace }); - expectClusterCallArgs({ id: `${namespace}:${type}:${id}` }); + expect(client.delete).toHaveBeenCalledWith( + expect.objectContaining({ id: `${namespace}:${type}:${id}` }), + expect.anything() + ); }); it(`doesn't prepend namespace to the id when providing no namespace for single-namespace type`, async () => { await deleteSuccess(type, id); - expectClusterCallArgs({ id: `${type}:${id}` }); + expect(client.delete).toHaveBeenCalledWith( + expect.objectContaining({ id: `${type}:${id}` }), + expect.anything() + ); }); it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => { await deleteSuccess(NAMESPACE_AGNOSTIC_TYPE, id, { namespace }); - expectClusterCallArgs({ id: `${NAMESPACE_AGNOSTIC_TYPE}:${id}` }); + expect(client.delete).toHaveBeenCalledWith( + expect.objectContaining({ id: `${NAMESPACE_AGNOSTIC_TYPE}:${id}` }), + expect.anything() + ); - callAdminCluster.mockReset(); + client.delete.mockClear(); await deleteSuccess(MULTI_NAMESPACE_TYPE, id, { namespace }); - expectClusterCallArgs({ id: `${MULTI_NAMESPACE_TYPE}:${id}` }); + expect(client.delete).toHaveBeenCalledWith( + expect.objectContaining({ id: `${MULTI_NAMESPACE_TYPE}:${id}` }), + expect.anything() + ); }); }); @@ -1756,73 +1847,82 @@ describe('SavedObjectsRepository', () => { it(`throws when type is invalid`, async () => { await expectNotFoundError('unknownType', id); - expect(callAdminCluster).not.toHaveBeenCalled(); + expect(client.delete).not.toHaveBeenCalled(); }); it(`throws when type is hidden`, async () => { await expectNotFoundError(HIDDEN_TYPE, id); - expect(callAdminCluster).not.toHaveBeenCalled(); + expect(client.delete).not.toHaveBeenCalled(); }); it(`throws when ES is unable to find the document during get`, async () => { - callAdminCluster.mockResolvedValue({ found: false }); // this._callCluster('get', ...) + client.get.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ found: false }) + ); await expectNotFoundError(MULTI_NAMESPACE_TYPE, id); - expectClusterCalls('get'); + expect(client.get).toHaveBeenCalledTimes(1); }); it(`throws when ES is unable to find the index during get`, async () => { - callAdminCluster.mockResolvedValue({ status: 404 }); // this._callCluster('get', ...) + client.get.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) + ); await expectNotFoundError(MULTI_NAMESPACE_TYPE, id); - expectClusterCalls('get'); + expect(client.get).toHaveBeenCalledTimes(1); }); it(`throws when the type is multi-namespace and the document exists, but not in this namespace`, async () => { const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id, namespace }); - callAdminCluster.mockResolvedValue(response); // this._callCluster('get', ...) + client.get.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(response) + ); await expectNotFoundError(MULTI_NAMESPACE_TYPE, id, { namespace: 'bar-namespace' }); - expectClusterCalls('get'); + expect(client.get).toHaveBeenCalledTimes(1); }); it(`throws when ES is unable to find the document during update`, async () => { const mockResponse = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id }); mockResponse._source.namespaces = ['default', 'some-other-nameespace']; - callAdminCluster - .mockResolvedValueOnce(mockResponse) // this._callCluster('get', ...) - .mockResolvedValue({ status: 404 }); // this._writeToCluster('update', ...) + client.get.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(mockResponse) + ); + client.update.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) + ); + await expectNotFoundError(MULTI_NAMESPACE_TYPE, id); - expectClusterCalls('get', 'update'); + expect(client.get).toHaveBeenCalledTimes(1); + expect(client.update).toHaveBeenCalledTimes(1); }); it(`throws when ES is unable to find the document during delete`, async () => { - callAdminCluster.mockResolvedValue({ result: 'not_found' }); // this._writeToCluster('delete', ...) + client.delete.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ result: 'not_found' }) + ); await expectNotFoundError(type, id); - expectClusterCalls('delete'); + expect(client.delete).toHaveBeenCalledTimes(1); }); it(`throws when ES is unable to find the index during delete`, async () => { - callAdminCluster.mockResolvedValue({ error: { type: 'index_not_found_exception' } }); // this._writeToCluster('delete', ...) + client.delete.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + error: { type: 'index_not_found_exception' }, + }) + ); await expectNotFoundError(type, id); - expectClusterCalls('delete'); + expect(client.delete).toHaveBeenCalledTimes(1); }); it(`throws when ES returns an unexpected response`, async () => { - callAdminCluster.mockResolvedValue({ result: 'something unexpected' }); // this._writeToCluster('delete', ...) + client.delete.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + result: 'something unexpected', + }) + ); await expect(savedObjectsRepository.delete(type, id)).rejects.toThrowError( 'Unexpected Elasticsearch DELETE response' ); - expectClusterCalls('delete'); - }); - }); - - describe('migration', () => { - it(`waits until migrations are complete before proceeding`, async () => { - let callAdminClusterCount = 0; - migrator.runMigrations = jest.fn(async () => - // runMigrations should resolve before callAdminCluster is initiated - expect(callAdminCluster).toHaveBeenCalledTimes(callAdminClusterCount++) - ); - await expect(deleteSuccess(type, id)).resolves.toBeDefined(); - expect(migrator.runMigrations).toHaveBeenCalledTimes(1); + expect(client.delete).toHaveBeenCalledTimes(1); }); }); @@ -1853,33 +1953,27 @@ describe('SavedObjectsRepository', () => { }; const deleteByNamespaceSuccess = async (namespace, options) => { - callAdminCluster.mockResolvedValue(mockUpdateResults); + client.updateByQuery.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(mockUpdateResults) + ); const result = await savedObjectsRepository.deleteByNamespace(namespace, options); expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledTimes(1); + expect(client.updateByQuery).toHaveBeenCalledTimes(1); return result; }; - describe('cluster calls', () => { + describe('client calls', () => { it(`should use the ES updateByQuery action`, async () => { await deleteByNamespaceSuccess(namespace); - expectClusterCalls('updateByQuery'); - }); - - it(`defaults to a refresh setting of wait_for`, async () => { - await deleteByNamespaceSuccess(namespace); - expectClusterCallArgs({ refresh: 'wait_for' }); - }); - - it(`accepts a custom refresh setting`, async () => { - const refresh = 'foo'; - await deleteByNamespaceSuccess(namespace, { refresh }); - expectClusterCallArgs({ refresh }); + expect(client.updateByQuery).toHaveBeenCalledTimes(1); }); it(`should use all indices for types that are not namespace-agnostic`, async () => { await deleteByNamespaceSuccess(namespace); - expectClusterCallArgs({ index: ['.kibana-test', 'custom'] }, 1); + expect(client.updateByQuery).toHaveBeenCalledWith( + expect.objectContaining({ index: ['.kibana-test', 'custom'] }), + expect.anything() + ); }); }); @@ -1889,7 +1983,7 @@ describe('SavedObjectsRepository', () => { await expect(savedObjectsRepository.deleteByNamespace(namespace)).rejects.toThrowError( `namespace is required, and must be a string` ); - expect(callAdminCluster).not.toHaveBeenCalled(); + expect(client.updateByQuery).not.toHaveBeenCalled(); }; await test(undefined); await test(['namespace']); @@ -1898,16 +1992,6 @@ describe('SavedObjectsRepository', () => { }); }); - describe('migration', () => { - it(`waits until migrations are complete before proceeding`, async () => { - migrator.runMigrations = jest.fn(async () => - expect(callAdminCluster).not.toHaveBeenCalled() - ); - await expect(deleteByNamespaceSuccess(namespace)).resolves.toBeDefined(); - expect(migrator.runMigrations).toHaveBeenCalledTimes(1); - }); - }); - describe('returns', () => { it(`returns the query results on success`, async () => { const result = await deleteByNamespaceSuccess(namespace); @@ -2002,64 +2086,90 @@ describe('SavedObjectsRepository', () => { const namespace = 'foo-namespace'; const findSuccess = async (options, namespace) => { - callAdminCluster.mockResolvedValue(generateSearchResults(namespace)); + client.search.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise( + generateSearchResults(namespace) + ) + ); const result = await savedObjectsRepository.find(options); expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledTimes(1); - expect(callAdminCluster).toHaveBeenCalledTimes(1); + expect(client.search).toHaveBeenCalledTimes(1); return result; }; - describe('cluster calls', () => { + describe('client calls', () => { it(`should use the ES search action`, async () => { await findSuccess({ type }); - expectClusterCalls('search'); + expect(client.search).toHaveBeenCalledTimes(1); }); it(`merges output of getSearchDsl into es request body`, async () => { const query = { query: 1, aggregations: 2 }; getSearchDslNS.getSearchDsl.mockReturnValue(query); await findSuccess({ type }); - expectClusterCallArgs({ body: expect.objectContaining({ ...query }) }); + + expect(client.search).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ ...query }), + }), + expect.anything() + ); }); it(`accepts per_page/page`, async () => { await findSuccess({ type, perPage: 10, page: 6 }); - expectClusterCallArgs({ - size: 10, - from: 50, - }); + expect(client.search).toHaveBeenCalledWith( + expect.objectContaining({ + size: 10, + from: 50, + }), + expect.anything() + ); }); it(`accepts preference`, async () => { await findSuccess({ type, preference: 'pref' }); - expectClusterCallArgs({ preference: 'pref' }); + expect(client.search).toHaveBeenCalledWith( + expect.objectContaining({ + preference: 'pref', + }), + expect.anything() + ); }); it(`can filter by fields`, async () => { await findSuccess({ type, fields: ['title'] }); - expectClusterCallArgs({ - _source: [ - `${type}.title`, - 'namespace', - 'namespaces', - 'type', - 'references', - 'migrationVersion', - 'updated_at', - 'title', - ], - }); + expect(client.search).toHaveBeenCalledWith( + expect.objectContaining({ + _source: [ + `${type}.title`, + 'namespace', + 'namespaces', + 'type', + 'references', + 'migrationVersion', + 'updated_at', + 'title', + ], + }), + expect.anything() + ); }); it(`should set rest_total_hits_as_int to true on a request`, async () => { await findSuccess({ type }); - expectClusterCallArgs({ rest_total_hits_as_int: true }); + expect(client.search).toHaveBeenCalledWith( + expect.objectContaining({ + rest_total_hits_as_int: true, + }), + expect.anything() + ); }); - it(`should not make a cluster call when attempting to find only invalid or hidden types`, async () => { + it(`should not make a client call when attempting to find only invalid or hidden types`, async () => { const test = async (types) => { await savedObjectsRepository.find({ type: types }); - expect(callAdminCluster).not.toHaveBeenCalled(); + expect(client.search).not.toHaveBeenCalled(); }; await test('unknownType'); @@ -2073,21 +2183,21 @@ describe('SavedObjectsRepository', () => { await expect(savedObjectsRepository.find({})).rejects.toThrowError( 'options.type must be a string or an array of strings' ); - expect(callAdminCluster).not.toHaveBeenCalled(); + expect(client.search).not.toHaveBeenCalled(); }); it(`throws when searchFields is defined but not an array`, async () => { await expect( savedObjectsRepository.find({ type, searchFields: 'string' }) ).rejects.toThrowError('options.searchFields must be an array'); - expect(callAdminCluster).not.toHaveBeenCalled(); + expect(client.search).not.toHaveBeenCalled(); }); it(`throws when fields is defined but not an array`, async () => { await expect(savedObjectsRepository.find({ type, fields: 'string' })).rejects.toThrowError( 'options.fields must be an array' ); - expect(callAdminCluster).not.toHaveBeenCalled(); + expect(client.search).not.toHaveBeenCalled(); }); it(`throws when KQL filter syntax is invalid`, async () => { @@ -2113,24 +2223,16 @@ describe('SavedObjectsRepository', () => { --------------------------------^: Bad Request] `); expect(getSearchDslNS.getSearchDsl).not.toHaveBeenCalled(); - expect(callAdminCluster).not.toHaveBeenCalled(); - }); - }); - - describe('migration', () => { - it(`waits until migrations are complete before proceeding`, async () => { - migrator.runMigrations = jest.fn(async () => - expect(callAdminCluster).not.toHaveBeenCalled() - ); - await expect(findSuccess({ type })).resolves.toBeDefined(); - expect(migrator.runMigrations).toHaveBeenCalledTimes(1); + expect(client.search).not.toHaveBeenCalled(); }); }); describe('returns', () => { it(`formats the ES response when there is no namespace`, async () => { const noNamespaceSearchResults = generateSearchResults(); - callAdminCluster.mockReturnValue(noNamespaceSearchResults); + client.search.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(noNamespaceSearchResults) + ); const count = noNamespaceSearchResults.hits.hits.length; const response = await savedObjectsRepository.find({ type }); @@ -2154,7 +2256,9 @@ describe('SavedObjectsRepository', () => { it(`formats the ES response when there is a namespace`, async () => { const namespacedSearchResults = generateSearchResults(namespace); - callAdminCluster.mockReturnValue(namespacedSearchResults); + client.search.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(namespacedSearchResults) + ); const count = namespacedSearchResults.hits.hits.length; const response = await savedObjectsRepository.find({ type, namespaces: [namespace] }); @@ -2298,35 +2402,57 @@ describe('SavedObjectsRepository', () => { const getSuccess = async (type, id, options) => { const response = getMockGetResponse({ type, id, namespace: options?.namespace }); - callAdminCluster.mockResolvedValue(response); + client.get.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(response) + ); const result = await savedObjectsRepository.get(type, id, options); - expect(callAdminCluster).toHaveBeenCalledTimes(1); + expect(client.get).toHaveBeenCalledTimes(1); return result; }; - describe('cluster calls', () => { + describe('client calls', () => { it(`should use the ES get action`, async () => { await getSuccess(type, id); - expectClusterCalls('get'); + expect(client.get).toHaveBeenCalledTimes(1); }); it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => { await getSuccess(type, id, { namespace }); - expectClusterCallArgs({ id: `${namespace}:${type}:${id}` }); + expect(client.get).toHaveBeenCalledWith( + expect.objectContaining({ + id: `${namespace}:${type}:${id}`, + }), + expect.anything() + ); }); it(`doesn't prepend namespace to the id when providing no namespace for single-namespace type`, async () => { await getSuccess(type, id); - expectClusterCallArgs({ id: `${type}:${id}` }); + expect(client.get).toHaveBeenCalledWith( + expect.objectContaining({ + id: `${type}:${id}`, + }), + expect.anything() + ); }); it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => { await getSuccess(NAMESPACE_AGNOSTIC_TYPE, id, { namespace }); - expectClusterCallArgs({ id: `${NAMESPACE_AGNOSTIC_TYPE}:${id}` }); + expect(client.get).toHaveBeenCalledWith( + expect.objectContaining({ + id: `${NAMESPACE_AGNOSTIC_TYPE}:${id}`, + }), + expect.anything() + ); - callAdminCluster.mockReset(); + client.get.mockClear(); await getSuccess(MULTI_NAMESPACE_TYPE, id, { namespace }); - expectClusterCallArgs({ id: `${MULTI_NAMESPACE_TYPE}:${id}` }); + expect(client.get).toHaveBeenCalledWith( + expect.objectContaining({ + id: `${MULTI_NAMESPACE_TYPE}:${id}`, + }), + expect.anything() + ); }); }); @@ -2339,41 +2465,37 @@ describe('SavedObjectsRepository', () => { it(`throws when type is invalid`, async () => { await expectNotFoundError('unknownType', id); - expect(callAdminCluster).not.toHaveBeenCalled(); + expect(client.get).not.toHaveBeenCalled(); }); it(`throws when type is hidden`, async () => { await expectNotFoundError(HIDDEN_TYPE, id); - expect(callAdminCluster).not.toHaveBeenCalled(); + expect(client.get).not.toHaveBeenCalled(); }); it(`throws when ES is unable to find the document during get`, async () => { - callAdminCluster.mockResolvedValue({ found: false }); + client.get.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ found: false }) + ); await expectNotFoundError(type, id); - expectClusterCalls('get'); + expect(client.get).toHaveBeenCalledTimes(1); }); it(`throws when ES is unable to find the index during get`, async () => { - callAdminCluster.mockResolvedValue({ status: 404 }); + client.get.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) + ); await expectNotFoundError(type, id); - expectClusterCalls('get'); + expect(client.get).toHaveBeenCalledTimes(1); }); it(`throws when type is multi-namespace and the document exists, but not in this namespace`, async () => { const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id, namespace }); - callAdminCluster.mockResolvedValue(response); - await expectNotFoundError(MULTI_NAMESPACE_TYPE, id, { namespace: 'bar-namespace' }); - expectClusterCalls('get'); - }); - }); - - describe('migration', () => { - it(`waits until migrations are complete before proceeding`, async () => { - migrator.runMigrations = jest.fn(async () => - expect(callAdminCluster).not.toHaveBeenCalled() + client.get.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(response) ); - await expect(getSuccess(type, id)).resolves.toBeDefined(); - expect(migrator.runMigrations).toHaveBeenCalledTimes(1); + await expectNotFoundError(MULTI_NAMESPACE_TYPE, id, { namespace: 'bar-namespace' }); + expect(client.get).toHaveBeenCalledTimes(1); }); }); @@ -2419,68 +2541,93 @@ describe('SavedObjectsRepository', () => { const isMultiNamespace = registry.isMultiNamespace(type); if (isMultiNamespace) { const response = getMockGetResponse({ type, id, namespace: options?.namespace }); - callAdminCluster.mockResolvedValueOnce(response); // this._callCluster('get', ...) + client.get.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(response) + ); } - callAdminCluster.mockImplementation((method, params) => ({ - _id: params.id, - ...mockVersionProps, - _index: '.kibana', - get: { - found: true, - _source: { - type, - ...mockTimestampFields, - [type]: { - [field]: 8468, - defaultIndex: 'logstash-*', + client.update.mockImplementation((params) => + elasticsearchClientMock.createSuccessTransportRequestPromise({ + _id: params.id, + ...mockVersionProps, + _index: '.kibana', + get: { + found: true, + _source: { + type, + ...mockTimestampFields, + [type]: { + [field]: 8468, + defaultIndex: 'logstash-*', + }, }, }, - }, - })); + }) + ); + const result = await savedObjectsRepository.incrementCounter(type, id, field, options); - expect(callAdminCluster).toHaveBeenCalledTimes(isMultiNamespace ? 2 : 1); + expect(client.get).toHaveBeenCalledTimes(isMultiNamespace ? 1 : 0); return result; }; - describe('cluster calls', () => { + describe('client calls', () => { it(`should use the ES update action if type is not multi-namespace`, async () => { await incrementCounterSuccess(type, id, field, { namespace }); - expectClusterCalls('update'); + expect(client.update).toHaveBeenCalledTimes(1); }); it(`should use the ES get action then update action if type is multi-namespace, ID is defined, and overwrite=true`, async () => { await incrementCounterSuccess(MULTI_NAMESPACE_TYPE, id, field, { namespace }); - expectClusterCalls('get', 'update'); + expect(client.get).toHaveBeenCalledTimes(1); + expect(client.update).toHaveBeenCalledTimes(1); }); it(`defaults to a refresh setting of wait_for`, async () => { await incrementCounterSuccess(type, id, field, { namespace }); - expectClusterCallArgs({ refresh: 'wait_for' }); - }); - - it(`accepts a custom refresh setting`, async () => { - const refresh = 'foo'; - await incrementCounterSuccess(type, id, field, { namespace, refresh }); - expectClusterCallArgs({ refresh }); + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ + refresh: 'wait_for', + }), + expect.anything() + ); }); it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => { await incrementCounterSuccess(type, id, field, { namespace }); - expectClusterCallArgs({ id: `${namespace}:${type}:${id}` }); + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ + id: `${namespace}:${type}:${id}`, + }), + expect.anything() + ); }); it(`doesn't prepend namespace to the id when providing no namespace for single-namespace type`, async () => { await incrementCounterSuccess(type, id, field); - expectClusterCallArgs({ id: `${type}:${id}` }); + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ + id: `${type}:${id}`, + }), + expect.anything() + ); }); it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => { await incrementCounterSuccess(NAMESPACE_AGNOSTIC_TYPE, id, field, { namespace }); - expectClusterCallArgs({ id: `${NAMESPACE_AGNOSTIC_TYPE}:${id}` }); + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ + id: `${NAMESPACE_AGNOSTIC_TYPE}:${id}`, + }), + expect.anything() + ); - callAdminCluster.mockReset(); + client.update.mockClear(); await incrementCounterSuccess(MULTI_NAMESPACE_TYPE, id, field, { namespace }); - expectClusterCallArgs({ id: `${MULTI_NAMESPACE_TYPE}:${id}` }); + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ + id: `${MULTI_NAMESPACE_TYPE}:${id}`, + }), + expect.anything() + ); }); }); @@ -2496,7 +2643,7 @@ describe('SavedObjectsRepository', () => { await expect( savedObjectsRepository.incrementCounter(type, id, field) ).rejects.toThrowError(`"type" argument must be a string`); - expect(callAdminCluster).not.toHaveBeenCalled(); + expect(client.update).not.toHaveBeenCalled(); }; await test(null); @@ -2510,7 +2657,7 @@ describe('SavedObjectsRepository', () => { await expect( savedObjectsRepository.incrementCounter(type, id, field) ).rejects.toThrowError(`"counterFieldName" argument must be a string`); - expect(callAdminCluster).not.toHaveBeenCalled(); + expect(client.update).not.toHaveBeenCalled(); }; await test(null); @@ -2521,12 +2668,12 @@ describe('SavedObjectsRepository', () => { it(`throws when type is invalid`, async () => { await expectUnsupportedTypeError('unknownType', id, field); - expect(callAdminCluster).not.toHaveBeenCalled(); + expect(client.update).not.toHaveBeenCalled(); }); it(`throws when type is hidden`, async () => { await expectUnsupportedTypeError(HIDDEN_TYPE, id, field); - expect(callAdminCluster).not.toHaveBeenCalled(); + expect(client.update).not.toHaveBeenCalled(); }); it(`throws when there is a conflict with an existing multi-namespace saved object (get)`, async () => { @@ -2535,11 +2682,13 @@ describe('SavedObjectsRepository', () => { id, namespace: 'bar-namespace', }); - callAdminCluster.mockResolvedValue(response); // this._callCluster('get', ...) + client.get.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(response) + ); await expect( savedObjectsRepository.incrementCounter(MULTI_NAMESPACE_TYPE, id, field, { namespace }) ).rejects.toThrowError(createConflictError(MULTI_NAMESPACE_TYPE, id)); - expectClusterCalls('get'); + expect(client.get).toHaveBeenCalledTimes(1); }); }); @@ -2548,16 +2697,6 @@ describe('SavedObjectsRepository', () => { migrator.migrateDocument.mockImplementation(mockMigrateDocument); }); - it(`waits until migrations are complete before proceeding`, async () => { - migrator.runMigrations = jest.fn(async () => - expect(callAdminCluster).not.toHaveBeenCalled() - ); - await expect( - incrementCounterSuccess(type, id, field, { namespace }) - ).resolves.toBeDefined(); - expect(migrator.runMigrations).toHaveBeenCalledTimes(1); - }); - it(`migrates a document and serializes the migrated doc`, async () => { const migrationVersion = mockMigrationVersion; await incrementCounterSuccess(type, id, field, { migrationVersion }); @@ -2572,22 +2711,24 @@ describe('SavedObjectsRepository', () => { describe('returns', () => { it(`formats the ES response`, async () => { - callAdminCluster.mockImplementation((method, params) => ({ - _id: params.id, - ...mockVersionProps, - _index: '.kibana', - get: { - found: true, - _source: { - type: 'config', - ...mockTimestampFields, - config: { - buildNum: 8468, - defaultIndex: 'logstash-*', + client.update.mockImplementation((params) => + elasticsearchClientMock.createSuccessTransportRequestPromise({ + _id: params.id, + ...mockVersionProps, + _index: '.kibana', + get: { + found: true, + _source: { + type: 'config', + ...mockTimestampFields, + config: { + buildNum: 8468, + defaultIndex: 'logstash-*', + }, }, }, - }, - })); + }) + ); const response = await savedObjectsRepository.incrementCounter( 'config', @@ -2623,7 +2764,9 @@ describe('SavedObjectsRepository', () => { // mock a document that exists in two namespaces const mockResponse = getMockGetResponse({ type, id }); mockResponse._source.namespaces = namespaces; - callAdminCluster.mockResolvedValueOnce(mockResponse); // this._callCluster('get', ...) + client.get.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(mockResponse) + ); }; const deleteFromNamespacesSuccess = async ( @@ -2633,71 +2776,96 @@ describe('SavedObjectsRepository', () => { currentNamespaces, options ) => { - mockGetResponse(type, id, currentNamespaces); // this._callCluster('get', ...) - const isDelete = currentNamespaces.every((namespace) => namespaces.includes(namespace)); - callAdminCluster.mockResolvedValue({ - _id: `${type}:${id}`, - ...mockVersionProps, - result: isDelete ? 'deleted' : 'updated', - }); // this._writeToCluster('delete', ...) *or* this._writeToCluster('update', ...) - const result = await savedObjectsRepository.deleteFromNamespaces( - type, - id, - namespaces, - options + mockGetResponse(type, id, currentNamespaces); + client.delete.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + _id: `${type}:${id}`, + ...mockVersionProps, + result: 'deleted', + }) ); - expect(callAdminCluster).toHaveBeenCalledTimes(2); - return result; + client.update.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + _id: `${type}:${id}`, + ...mockVersionProps, + result: 'updated', + }) + ); + + return await savedObjectsRepository.deleteFromNamespaces(type, id, namespaces, options); }; - describe('cluster calls', () => { + describe('client calls', () => { describe('delete action', () => { const deleteFromNamespacesSuccessDelete = async (expectFn, options, _type = type) => { const test = async (namespaces) => { await deleteFromNamespacesSuccess(_type, id, namespaces, namespaces, options); expectFn(); - callAdminCluster.mockReset(); + client.delete.mockClear(); + client.get.mockClear(); }; await test([namespace1]); await test([namespace1, namespace2]); }; it(`should use ES get action then delete action if the object has no namespaces remaining`, async () => { - const expectFn = () => expectClusterCalls('get', 'delete'); + const expectFn = () => { + expect(client.delete).toHaveBeenCalledTimes(1); + expect(client.get).toHaveBeenCalledTimes(1); + }; await deleteFromNamespacesSuccessDelete(expectFn); }); it(`formats the ES requests`, async () => { const expectFn = () => { - expectClusterCallArgs({ id: `${type}:${id}` }, 1); + expect(client.delete).toHaveBeenCalledWith( + expect.objectContaining({ + id: `${type}:${id}`, + }), + expect.anything() + ); + const versionProperties = { if_seq_no: mockVersionProps._seq_no, if_primary_term: mockVersionProps._primary_term, }; - expectClusterCallArgs({ id: `${type}:${id}`, ...versionProperties }, 2); + expect(client.delete).toHaveBeenCalledWith( + expect.objectContaining({ + id: `${type}:${id}`, + ...versionProperties, + }), + expect.anything() + ); }; await deleteFromNamespacesSuccessDelete(expectFn); }); it(`defaults to a refresh setting of wait_for`, async () => { await deleteFromNamespacesSuccessDelete(() => - expectClusterCallArgs({ refresh: 'wait_for' }, 2) + expect(client.delete).toHaveBeenCalledWith( + expect.objectContaining({ + refresh: 'wait_for', + }), + expect.anything() + ) ); }); - it(`accepts a custom refresh setting`, async () => { - const refresh = 'foo'; - const expectFn = () => expectClusterCallArgs({ refresh }, 2); - await deleteFromNamespacesSuccessDelete(expectFn, { refresh }); - }); - it(`should use default index`, async () => { - const expectFn = () => expectClusterCallArgs({ index: '.kibana-test' }, 2); + const expectFn = () => + expect(client.delete).toHaveBeenCalledWith( + expect.objectContaining({ index: '.kibana-test' }), + expect.anything() + ); await deleteFromNamespacesSuccessDelete(expectFn); }); it(`should use custom index`, async () => { - const expectFn = () => expectClusterCallArgs({ index: 'custom' }, 2); + const expectFn = () => + expect(client.delete).toHaveBeenCalledWith( + expect.objectContaining({ index: 'custom' }), + expect.anything() + ); await deleteFromNamespacesSuccessDelete(expectFn, {}, MULTI_NAMESPACE_CUSTOM_INDEX_TYPE); }); }); @@ -2708,55 +2876,73 @@ describe('SavedObjectsRepository', () => { const currentNamespaces = [namespace1].concat(remaining); await deleteFromNamespacesSuccess(_type, id, [namespace1], currentNamespaces, options); expectFn(); - callAdminCluster.mockReset(); + client.get.mockClear(); + client.update.mockClear(); }; await test([namespace2]); await test([namespace2, namespace3]); }; it(`should use ES get action then update action if the object has one or more namespaces remaining`, async () => { - await deleteFromNamespacesSuccessUpdate(() => expectClusterCalls('get', 'update')); + const expectFn = () => { + expect(client.update).toHaveBeenCalledTimes(1); + expect(client.get).toHaveBeenCalledTimes(1); + }; + await deleteFromNamespacesSuccessUpdate(expectFn); }); it(`formats the ES requests`, async () => { let ctr = 0; const expectFn = () => { - expectClusterCallArgs({ id: `${type}:${id}` }, 1); + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ + id: `${type}:${id}`, + }), + expect.anything() + ); const namespaces = ctr++ === 0 ? [namespace2] : [namespace2, namespace3]; const versionProperties = { if_seq_no: mockVersionProps._seq_no, if_primary_term: mockVersionProps._primary_term, }; - expectClusterCallArgs( - { + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ id: `${type}:${id}`, ...versionProperties, body: { doc: { ...mockTimestampFields, namespaces } }, - }, - 2 + }), + expect.anything() ); }; await deleteFromNamespacesSuccessUpdate(expectFn); }); it(`defaults to a refresh setting of wait_for`, async () => { - const expectFn = () => expectClusterCallArgs({ refresh: 'wait_for' }, 2); + const expectFn = () => + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ + refresh: 'wait_for', + }), + expect.anything() + ); await deleteFromNamespacesSuccessUpdate(expectFn); }); - it(`accepts a custom refresh setting`, async () => { - const refresh = 'foo'; - const expectFn = () => expectClusterCallArgs({ refresh }, 2); - await deleteFromNamespacesSuccessUpdate(expectFn, { refresh }); - }); - it(`should use default index`, async () => { - const expectFn = () => expectClusterCallArgs({ index: '.kibana-test' }, 2); + const expectFn = () => + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ index: '.kibana-test' }), + expect.anything() + ); await deleteFromNamespacesSuccessUpdate(expectFn); }); it(`should use custom index`, async () => { - const expectFn = () => expectClusterCallArgs({ index: 'custom' }, 2); + const expectFn = () => + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ index: 'custom' }), + expect.anything() + ); await deleteFromNamespacesSuccessUpdate(expectFn, {}, MULTI_NAMESPACE_CUSTOM_INDEX_TYPE); }); }); @@ -2776,19 +2962,22 @@ describe('SavedObjectsRepository', () => { it(`throws when type is invalid`, async () => { await expectNotFoundError('unknownType', id, [namespace1, namespace2]); - expect(callAdminCluster).not.toHaveBeenCalled(); + expect(client.delete).not.toHaveBeenCalled(); + expect(client.update).not.toHaveBeenCalled(); }); it(`throws when type is hidden`, async () => { await expectNotFoundError(HIDDEN_TYPE, id, [namespace1, namespace2]); - expect(callAdminCluster).not.toHaveBeenCalled(); + expect(client.delete).not.toHaveBeenCalled(); + expect(client.update).not.toHaveBeenCalled(); }); it(`throws when type is not namespace-agnostic`, async () => { const test = async (type) => { const message = `${type} doesn't support multiple namespaces`; await expectBadRequestError(type, id, [namespace1, namespace2], message); - expect(callAdminCluster).not.toHaveBeenCalled(); + expect(client.delete).not.toHaveBeenCalled(); + expect(client.update).not.toHaveBeenCalled(); }; await test('index-pattern'); await test(NAMESPACE_AGNOSTIC_TYPE); @@ -2798,71 +2987,78 @@ describe('SavedObjectsRepository', () => { const test = async (namespaces) => { const message = 'namespaces must be a non-empty array of strings'; await expectBadRequestError(type, id, namespaces, message); - expect(callAdminCluster).not.toHaveBeenCalled(); + expect(client.delete).not.toHaveBeenCalled(); + expect(client.update).not.toHaveBeenCalled(); }; await test([]); }); it(`throws when ES is unable to find the document during get`, async () => { - callAdminCluster.mockResolvedValue({ found: false }); // this._callCluster('get', ...) + client.get.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ found: false }) + ); await expectNotFoundError(type, id, [namespace1, namespace2]); - expectClusterCalls('get'); + expect(client.get).toHaveBeenCalledTimes(1); }); it(`throws when ES is unable to find the index during get`, async () => { - callAdminCluster.mockResolvedValue({ status: 404 }); // this._callCluster('get', ...) + client.get.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) + ); await expectNotFoundError(type, id, [namespace1, namespace2]); - expectClusterCalls('get'); + expect(client.get).toHaveBeenCalledTimes(1); }); it(`throws when the document exists, but not in this namespace`, async () => { - mockGetResponse(type, id, [namespace1]); // this._callCluster('get', ...) + mockGetResponse(type, id, [namespace1]); await expectNotFoundError(type, id, [namespace1], { namespace: 'some-other-namespace' }); - expectClusterCalls('get'); + expect(client.get).toHaveBeenCalledTimes(1); }); it(`throws when ES is unable to find the document during delete`, async () => { - mockGetResponse(type, id, [namespace1]); // this._callCluster('get', ...) - callAdminCluster.mockResolvedValue({ result: 'not_found' }); // this._writeToCluster('delete', ...) + mockGetResponse(type, id, [namespace1]); + client.delete.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ result: 'not_found' }) + ); await expectNotFoundError(type, id, [namespace1]); - expectClusterCalls('get', 'delete'); + expect(client.get).toHaveBeenCalledTimes(1); + expect(client.delete).toHaveBeenCalledTimes(1); }); it(`throws when ES is unable to find the index during delete`, async () => { - mockGetResponse(type, id, [namespace1]); // this._callCluster('get', ...) - callAdminCluster.mockResolvedValue({ error: { type: 'index_not_found_exception' } }); // this._writeToCluster('delete', ...) + mockGetResponse(type, id, [namespace1]); + client.delete.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + error: { type: 'index_not_found_exception' }, + }) + ); await expectNotFoundError(type, id, [namespace1]); - expectClusterCalls('get', 'delete'); + expect(client.get).toHaveBeenCalledTimes(1); + expect(client.delete).toHaveBeenCalledTimes(1); }); it(`throws when ES returns an unexpected response`, async () => { - mockGetResponse(type, id, [namespace1]); // this._callCluster('get', ...) - callAdminCluster.mockResolvedValue({ result: 'something unexpected' }); // this._writeToCluster('delete', ...) + mockGetResponse(type, id, [namespace1]); + client.delete.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + result: 'something unexpected', + }) + ); await expect( savedObjectsRepository.deleteFromNamespaces(type, id, [namespace1]) ).rejects.toThrowError('Unexpected Elasticsearch DELETE response'); - expectClusterCalls('get', 'delete'); + expect(client.get).toHaveBeenCalledTimes(1); + expect(client.delete).toHaveBeenCalledTimes(1); }); it(`throws when ES is unable to find the document during update`, async () => { - mockGetResponse(type, id, [namespace1, namespace2]); // this._callCluster('get', ...) - callAdminCluster.mockResolvedValue({ status: 404 }); // this._writeToCluster('update', ...) - await expectNotFoundError(type, id, [namespace1]); - expectClusterCalls('get', 'update'); - }); - }); - - describe('migration', () => { - it(`waits until migrations are complete before proceeding`, async () => { - let callAdminClusterCount = 0; - migrator.runMigrations = jest.fn(async () => - // runMigrations should resolve before callAdminCluster is initiated - expect(callAdminCluster).toHaveBeenCalledTimes(callAdminClusterCount++) + mockGetResponse(type, id, [namespace1, namespace2]); + client.update.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) ); - await expect( - deleteFromNamespacesSuccess(type, id, [namespace1], [namespace1]) - ).resolves.toBeDefined(); - expect(migrator.runMigrations).toHaveReturnedTimes(2); + await expectNotFoundError(type, id, [namespace1]); + expect(client.get).toHaveBeenCalledTimes(1); + expect(client.update).toHaveBeenCalledTimes(1); }); }); @@ -2871,7 +3067,7 @@ describe('SavedObjectsRepository', () => { const test = async (namespaces) => { const result = await deleteFromNamespacesSuccess(type, id, namespaces, namespaces); expect(result).toEqual({}); - callAdminCluster.mockReset(); + client.delete.mockClear(); }; await test([namespace1]); await test([namespace1, namespace2]); @@ -2887,7 +3083,7 @@ describe('SavedObjectsRepository', () => { currentNamespaces ); expect(result).toEqual({}); - callAdminCluster.mockReset(); + client.delete.mockClear(); }; await test([namespace2]); await test([namespace2, namespace3]); @@ -2918,47 +3114,61 @@ describe('SavedObjectsRepository', () => { const updateSuccess = async (type, id, attributes, options) => { if (registry.isMultiNamespace(type)) { const mockGetResponse = getMockGetResponse({ type, id, namespace: options?.namespace }); - callAdminCluster.mockResolvedValueOnce(mockGetResponse); // this._callCluster('get', ...) + client.get.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(mockGetResponse) + ); } - callAdminCluster.mockResolvedValue({ - _id: `${type}:${id}`, - ...mockVersionProps, - result: 'updated', - // don't need the rest of the source for test purposes, just the namespace and namespaces attributes - get: { - _source: { namespaces: [options?.namespace ?? 'default'], namespace: options?.namespace }, - }, - }); // this._writeToCluster('update', ...) + client.update.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ + _id: `${type}:${id}`, + ...mockVersionProps, + result: 'updated', + // don't need the rest of the source for test purposes, just the namespace and namespaces attributes + get: { + _source: { + namespaces: [options?.namespace ?? 'default'], + namespace: options?.namespace, + }, + }, + }) + ); const result = await savedObjectsRepository.update(type, id, attributes, options); - expect(callAdminCluster).toHaveBeenCalledTimes(registry.isMultiNamespace(type) ? 2 : 1); + expect(client.get).toHaveBeenCalledTimes(registry.isMultiNamespace(type) ? 1 : 0); return result; }; - describe('cluster calls', () => { + describe('client calls', () => { it(`should use the ES get action then update action when type is multi-namespace`, async () => { await updateSuccess(MULTI_NAMESPACE_TYPE, id, attributes); - expectClusterCalls('get', 'update'); + expect(client.get).toHaveBeenCalledTimes(1); + expect(client.update).toHaveBeenCalledTimes(1); }); it(`should use the ES update action when type is not multi-namespace`, async () => { await updateSuccess(type, id, attributes); - expectClusterCalls('update'); + expect(client.update).toHaveBeenCalledTimes(1); }); it(`defaults to no references array`, async () => { await updateSuccess(type, id, attributes); - expectClusterCallArgs({ - body: { doc: expect.not.objectContaining({ references: expect.anything() }) }, - }); + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ + body: { doc: expect.not.objectContaining({ references: expect.anything() }) }, + }), + expect.anything() + ); }); it(`accepts custom references array`, async () => { const test = async (references) => { await updateSuccess(type, id, attributes, { references }); - expectClusterCallArgs({ - body: { doc: expect.objectContaining({ references }) }, - }); - callAdminCluster.mockReset(); + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ + body: { doc: expect.objectContaining({ references }) }, + }), + expect.anything() + ); + client.update.mockClear(); }; await test(references); await test(['string']); @@ -2968,10 +3178,13 @@ describe('SavedObjectsRepository', () => { it(`doesn't accept custom references if not an array`, async () => { const test = async (references) => { await updateSuccess(type, id, attributes, { references }); - expectClusterCallArgs({ - body: { doc: expect.not.objectContaining({ references: expect.anything() }) }, - }); - callAdminCluster.mockReset(); + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ + body: { doc: expect.not.objectContaining({ references: expect.anything() }) }, + }), + expect.anything() + ); + client.update.mockClear(); }; await test('string'); await test(123); @@ -2981,13 +3194,12 @@ describe('SavedObjectsRepository', () => { it(`defaults to a refresh setting of wait_for`, async () => { await updateSuccess(type, id, { foo: 'bar' }); - expectClusterCallArgs({ refresh: 'wait_for' }); - }); - - it(`accepts a custom refresh setting`, async () => { - const refresh = 'foo'; - await updateSuccess(type, id, { foo: 'bar' }, { refresh }); - expectClusterCallArgs({ refresh }); + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ + refresh: 'wait_for', + }), + expect.anything() + ); }); it(`defaults to the version of the existing document when type is multi-namespace`, async () => { @@ -2996,47 +3208,70 @@ describe('SavedObjectsRepository', () => { if_seq_no: mockVersionProps._seq_no, if_primary_term: mockVersionProps._primary_term, }; - expectClusterCallArgs(versionProperties, 2); + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining(versionProperties), + expect.anything() + ); }); it(`accepts version`, async () => { await updateSuccess(type, id, attributes, { version: encodeHitVersion({ _seq_no: 100, _primary_term: 200 }), }); - expectClusterCallArgs({ if_seq_no: 100, if_primary_term: 200 }); + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ if_seq_no: 100, if_primary_term: 200 }), + expect.anything() + ); }); it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => { await updateSuccess(type, id, attributes, { namespace }); - expectClusterCallArgs({ id: expect.stringMatching(`${namespace}:${type}:${id}`) }); + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ id: expect.stringMatching(`${namespace}:${type}:${id}`) }), + expect.anything() + ); }); it(`doesn't prepend namespace to the id when providing no namespace for single-namespace type`, async () => { await updateSuccess(type, id, attributes, { references }); - expectClusterCallArgs({ id: expect.stringMatching(`${type}:${id}`) }); + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ id: expect.stringMatching(`${type}:${id}`) }), + expect.anything() + ); }); it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => { await updateSuccess(NAMESPACE_AGNOSTIC_TYPE, id, attributes, { namespace }); - expectClusterCallArgs({ id: expect.stringMatching(`${NAMESPACE_AGNOSTIC_TYPE}:${id}`) }); + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ + id: expect.stringMatching(`${NAMESPACE_AGNOSTIC_TYPE}:${id}`), + }), + expect.anything() + ); - callAdminCluster.mockReset(); + client.update.mockClear(); await updateSuccess(MULTI_NAMESPACE_TYPE, id, attributes, { namespace }); - expectClusterCallArgs({ id: expect.stringMatching(`${MULTI_NAMESPACE_TYPE}:${id}`) }, 2); + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ id: expect.stringMatching(`${MULTI_NAMESPACE_TYPE}:${id}`) }), + expect.anything() + ); }); - it(`includes _sourceIncludes when type is multi-namespace`, async () => { + it(`includes _source_includes when type is multi-namespace`, async () => { await updateSuccess(MULTI_NAMESPACE_TYPE, id, attributes); - expectClusterCallArgs({ _sourceIncludes: ['namespace', 'namespaces'] }, 2); + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ _source_includes: ['namespace', 'namespaces'] }), + expect.anything() + ); }); - it(`includes _sourceIncludes when type is not multi-namespace`, async () => { + it(`includes _source_includes when type is not multi-namespace`, async () => { await updateSuccess(type, id, attributes); - expect(callAdminCluster).toHaveBeenLastCalledWith( - expect.any(String), + expect(client.update).toHaveBeenLastCalledWith( expect.objectContaining({ - _sourceIncludes: ['namespace', 'namespaces'], - }) + _source_includes: ['namespace', 'namespaces'], + }), + expect.anything() ); }); }); @@ -3050,49 +3285,45 @@ describe('SavedObjectsRepository', () => { it(`throws when type is invalid`, async () => { await expectNotFoundError('unknownType', id); - expect(callAdminCluster).not.toHaveBeenCalled(); + expect(client.update).not.toHaveBeenCalled(); }); it(`throws when type is hidden`, async () => { await expectNotFoundError(HIDDEN_TYPE, id); - expect(callAdminCluster).not.toHaveBeenCalled(); + expect(client.update).not.toHaveBeenCalled(); }); it(`throws when ES is unable to find the document during get`, async () => { - callAdminCluster.mockResolvedValue({ found: false }); // this._callCluster('get', ...) + client.get.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({ found: false }) + ); await expectNotFoundError(MULTI_NAMESPACE_TYPE, id); - expectClusterCalls('get'); + expect(client.get).toHaveBeenCalledTimes(1); }); it(`throws when ES is unable to find the index during get`, async () => { - callAdminCluster.mockResolvedValue({ status: 404 }); // this._callCluster('get', ...) + client.get.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) + ); await expectNotFoundError(MULTI_NAMESPACE_TYPE, id); - expectClusterCalls('get'); + expect(client.get).toHaveBeenCalledTimes(1); }); it(`throws when type is multi-namespace and the document exists, but not in this namespace`, async () => { const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id, namespace }); - callAdminCluster.mockResolvedValue(response); // this._callCluster('get', ...) + client.get.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(response) + ); await expectNotFoundError(MULTI_NAMESPACE_TYPE, id, { namespace: 'bar-namespace' }); - expectClusterCalls('get'); + expect(client.get).toHaveBeenCalledTimes(1); }); it(`throws when ES is unable to find the document during update`, async () => { - callAdminCluster.mockResolvedValue({ status: 404 }); // this._writeToCluster('update', ...) + client.update.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) + ); await expectNotFoundError(type, id); - expectClusterCalls('update'); - }); - }); - - describe('migration', () => { - it(`waits until migrations are complete before proceeding`, async () => { - let callAdminClusterCount = 0; - migrator.runMigrations = jest.fn(async () => - // runMigrations should resolve before callAdminCluster is initiated - expect(callAdminCluster).toHaveBeenCalledTimes(callAdminClusterCount++) - ); - await expect(updateSuccess(type, id, attributes)).resolves.toBeDefined(); - expect(migrator.runMigrations).toHaveReturnedTimes(1); + expect(client.update).toHaveBeenCalledTimes(1); }); }); diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index 7a5ac9204627..8b7b1d62c1b7 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -19,13 +19,16 @@ import { omit } from 'lodash'; import uuid from 'uuid'; -import { retryCallCluster } from '../../../elasticsearch/legacy'; -import { LegacyAPICaller } from '../../../elasticsearch/'; - +import { + ElasticsearchClient, + DeleteDocumentResponse, + GetResponse, + SearchResponse, +} from '../../../elasticsearch/'; import { getRootPropertiesObjects, IndexMapping } from '../../mappings'; +import { createRepositoryEsClient, RepositoryEsClient } from './repository_es_client'; import { getSearchDsl } from './search_dsl'; import { includedFields } from './included_fields'; -import { decorateEsError } from './decorate_es_error'; import { SavedObjectsErrorHelpers } from './errors'; import { decodeRequestVersion, encodeVersion, encodeHitVersion } from '../../version'; import { KibanaMigrator } from '../../migrations'; @@ -33,6 +36,7 @@ import { SavedObjectsSerializer, SavedObjectSanitizedDoc, SavedObjectsRawDoc, + SavedObjectsRawDocSource, } from '../../serialization'; import { SavedObjectsBulkCreateObject, @@ -74,7 +78,7 @@ const isRight = (either: Either): either is Right => either.tag === 'Right'; export interface SavedObjectsRepositoryOptions { index: string; mappings: IndexMapping; - callCluster: LegacyAPICaller; + client: ElasticsearchClient; typeRegistry: SavedObjectTypeRegistry; serializer: SavedObjectsSerializer; migrator: KibanaMigrator; @@ -95,8 +99,8 @@ export interface SavedObjectsIncrementCounterOptions extends SavedObjectsBaseOpt * @public */ export interface SavedObjectsDeleteByNamespaceOptions extends SavedObjectsBaseOptions { - /** The Elasticsearch Refresh setting for this operation */ - refresh?: MutatingOperationRefreshSetting; + /** The Elasticsearch supports only boolean flag for this operation */ + refresh?: boolean; } const DEFAULT_REFRESH_SETTING = 'wait_for'; @@ -117,7 +121,7 @@ export class SavedObjectsRepository { private _mappings: IndexMapping; private _registry: SavedObjectTypeRegistry; private _allowedTypes: string[]; - private _unwrappedCallCluster: LegacyAPICaller; + private readonly client: RepositoryEsClient; private _serializer: SavedObjectsSerializer; /** @@ -132,7 +136,7 @@ export class SavedObjectsRepository { migrator: KibanaMigrator, typeRegistry: SavedObjectTypeRegistry, indexName: string, - callCluster: LegacyAPICaller, + client: ElasticsearchClient, includedHiddenTypes: string[] = [], injectedConstructor: any = SavedObjectsRepository ): ISavedObjectsRepository { @@ -157,7 +161,7 @@ export class SavedObjectsRepository { typeRegistry, serializer, allowedTypes, - callCluster: retryCallCluster(callCluster), + client, }); } @@ -165,7 +169,7 @@ export class SavedObjectsRepository { const { index, mappings, - callCluster, + client, typeRegistry, serializer, migrator, @@ -183,15 +187,11 @@ export class SavedObjectsRepository { this._index = index; this._mappings = mappings; this._registry = typeRegistry; + this.client = createRepositoryEsClient(client); if (allowedTypes.length === 0) { throw new Error('Empty or missing types for saved object repository!'); } this._allowedTypes = allowedTypes; - - this._unwrappedCallCluster = async (...args: Parameters) => { - await migrator.runMigrations(); - return callCluster(...args); - }; this._serializer = serializer; } @@ -254,17 +254,21 @@ export class SavedObjectsRepository { const raw = this._serializer.savedObjectToRaw(migrated as SavedObjectSanitizedDoc); - const method = id && overwrite ? 'index' : 'create'; - const response = await this._writeToCluster(method, { + const requestParams = { id: raw._id, index: this.getIndexForType(type), refresh, body: raw._source, - }); + }; + + const { body } = + id && overwrite + ? await this.client.index(requestParams) + : await this.client.create(requestParams); return this._rawToSavedObject({ ...raw, - ...response, + ...body, }); } @@ -322,12 +326,14 @@ export class SavedObjectsRepository { _source: ['type', 'namespaces'], })); const bulkGetResponse = bulkGetDocs.length - ? await this._callCluster('mget', { - body: { - docs: bulkGetDocs, + ? await this.client.mget( + { + body: { + docs: bulkGetDocs, + }, }, - ignore: [404], - }) + { ignore: [404] } + ) : undefined; let bulkRequestIndexCounter = 0; @@ -341,8 +347,8 @@ export class SavedObjectsRepository { let savedObjectNamespaces; const { esRequestIndex, object, method } = expectedBulkGetResult.value; if (esRequestIndex !== undefined) { - const indexFound = bulkGetResponse.status !== 404; - const actualResult = indexFound ? bulkGetResponse.docs[esRequestIndex] : undefined; + const indexFound = bulkGetResponse?.statusCode !== 404; + const actualResult = indexFound ? bulkGetResponse?.body.docs[esRequestIndex] : undefined; const docFound = indexFound && actualResult.found === true; if (docFound && !this.rawDocExistsInNamespace(actualResult, namespace)) { const { id, type } = object; @@ -395,7 +401,7 @@ export class SavedObjectsRepository { }); const bulkResponse = bulkCreateParams.length - ? await this._writeToCluster('bulk', { + ? await this.client.bulk({ refresh, body: bulkCreateParams, }) @@ -409,7 +415,7 @@ export class SavedObjectsRepository { const { requestedId, rawMigratedDoc, esRequestIndex } = expectedResult.value; const { error, ...rawResponse } = Object.values( - bulkResponse.items[esRequestIndex] + bulkResponse?.body.items[esRequestIndex] )[0] as any; if (error) { @@ -466,18 +472,20 @@ export class SavedObjectsRepository { namespaces: remainingNamespaces, }; - const updateResponse = await this._writeToCluster('update', { - id: rawId, - index: this.getIndexForType(type), - ...getExpectedVersionProperties(undefined, preflightResult), - refresh, - ignore: [404], - body: { - doc, + const { statusCode } = await this.client.update( + { + id: rawId, + index: this.getIndexForType(type), + ...getExpectedVersionProperties(undefined, preflightResult), + refresh, + body: { + doc, + }, }, - }); + { ignore: [404] } + ); - if (updateResponse.status === 404) { + if (statusCode === 404) { // see "404s from missing index" above throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } @@ -485,22 +493,23 @@ export class SavedObjectsRepository { } } - const deleteResponse = await this._writeToCluster('delete', { - id: rawId, - index: this.getIndexForType(type), - ...getExpectedVersionProperties(undefined, preflightResult), - refresh, - ignore: [404], - }); + const { body, statusCode } = await this.client.delete( + { + id: rawId, + index: this.getIndexForType(type), + ...getExpectedVersionProperties(undefined, preflightResult), + refresh, + }, + { ignore: [404] } + ); - const deleted = deleteResponse.result === 'deleted'; + const deleted = body.result === 'deleted'; if (deleted) { return {}; } - const deleteDocNotFound = deleteResponse.result === 'not_found'; - const deleteIndexNotFound = - deleteResponse.error && deleteResponse.error.type === 'index_not_found_exception'; + const deleteDocNotFound = body.result === 'not_found'; + const deleteIndexNotFound = body.error && body.error.type === 'index_not_found_exception'; if (deleteDocNotFound || deleteIndexNotFound) { // see "404s from missing index" above throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); @@ -510,7 +519,7 @@ export class SavedObjectsRepository { `Unexpected Elasticsearch DELETE response: ${JSON.stringify({ type, id, - response: deleteResponse, + response: { body, statusCode }, })}` ); } @@ -529,17 +538,16 @@ export class SavedObjectsRepository { throw new TypeError(`namespace is required, and must be a string`); } - const { refresh = DEFAULT_REFRESH_SETTING } = options; const allTypes = Object.keys(getRootPropertiesObjects(this._mappings)); const typesToUpdate = allTypes.filter((type) => !this._registry.isNamespaceAgnostic(type)); - const updateOptions = { - index: this.getIndicesForTypes(typesToUpdate), - ignore: [404], - refresh, - body: { - script: { - source: ` + const { body } = await this.client.updateByQuery( + { + index: this.getIndicesForTypes(typesToUpdate), + refresh: options.refresh, + body: { + script: { + source: ` if (!ctx._source.containsKey('namespaces')) { ctx.op = "delete"; } else { @@ -549,18 +557,20 @@ export class SavedObjectsRepository { } } `, - lang: 'painless', - params: { namespace: getNamespaceString(namespace) }, + lang: 'painless', + params: { namespace: getNamespaceString(namespace) }, + }, + conflicts: 'proceed', + ...getSearchDsl(this._mappings, this._registry, { + namespaces: namespace ? [namespace] : undefined, + type: typesToUpdate, + }), }, - conflicts: 'proceed', - ...getSearchDsl(this._mappings, this._registry, { - namespaces: namespace ? [namespace] : undefined, - type: typesToUpdate, - }), }, - }; + { ignore: [404] } + ); - return await this._writeToCluster('updateByQuery', updateOptions); + return body; } /** @@ -639,7 +649,6 @@ export class SavedObjectsRepository { size: perPage, from: perPage * (page - 1), _source: includedFields(type, fields), - ignore: [404], rest_total_hits_as_int: true, preference, body: { @@ -658,9 +667,10 @@ export class SavedObjectsRepository { }, }; - const response = await this._callCluster('search', esOptions); - - if (response.status === 404) { + const { body, statusCode } = await this.client.search>(esOptions, { + ignore: [404], + }); + if (statusCode === 404) { // 404 is only possible here if the index is missing, which // we don't want to leak, see "404s from missing index" above return { @@ -674,14 +684,14 @@ export class SavedObjectsRepository { return { page, per_page: perPage, - total: response.hits.total, - saved_objects: response.hits.hits.map( + total: body.hits.total, + saved_objects: body.hits.hits.map( (hit: SavedObjectsRawDoc): SavedObjectsFindResult => ({ ...this._rawToSavedObject(hit), score: (hit as any)._score, }) ), - }; + } as SavedObjectsFindResponse; } /** @@ -742,12 +752,14 @@ export class SavedObjectsRepository { _source: includedFields(type, fields), })); const bulkGetResponse = bulkGetDocs.length - ? await this._callCluster('mget', { - body: { - docs: bulkGetDocs, + ? await this.client.mget( + { + body: { + docs: bulkGetDocs, + }, }, - ignore: [404], - }) + { ignore: [404] } + ) : undefined; return { @@ -757,7 +769,7 @@ export class SavedObjectsRepository { } const { type, id, esRequestIndex } = expectedResult.value; - const doc = bulkGetResponse.docs[esRequestIndex]; + const doc = bulkGetResponse?.body.docs[esRequestIndex]; if (!doc.found || !this.rawDocExistsInNamespace(doc, namespace)) { return ({ @@ -808,24 +820,26 @@ export class SavedObjectsRepository { const { namespace } = options; - const response = await this._callCluster('get', { - id: this._serializer.generateRawId(namespace, type, id), - index: this.getIndexForType(type), - ignore: [404], - }); + const { body, statusCode } = await this.client.get>( + { + id: this._serializer.generateRawId(namespace, type, id), + index: this.getIndexForType(type), + }, + { ignore: [404] } + ); - const docNotFound = response.found === false; - const indexNotFound = response.status === 404; - if (docNotFound || indexNotFound || !this.rawDocExistsInNamespace(response, namespace)) { + const docNotFound = body.found === false; + const indexNotFound = statusCode === 404; + if (docNotFound || indexNotFound || !this.rawDocExistsInNamespace(body, namespace)) { // see "404s from missing index" above throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } - const { updated_at: updatedAt } = response._source; + const { updated_at: updatedAt } = body._source; - let namespaces = []; + let namespaces: string[] = []; if (!this._registry.isNamespaceAgnostic(type)) { - namespaces = response._source.namespaces ?? [getNamespaceString(response._source.namespace)]; + namespaces = body._source.namespaces ?? [getNamespaceString(body._source.namespace)]; } return { @@ -833,10 +847,10 @@ export class SavedObjectsRepository { type, namespaces, ...(updatedAt && { updated_at: updatedAt }), - version: encodeHitVersion(response), - attributes: response._source[type], - references: response._source.references || [], - migrationVersion: response._source.migrationVersion, + version: encodeHitVersion(body), + attributes: body._source[type], + references: body._source.references || [], + migrationVersion: body._source.migrationVersion, }; } @@ -876,35 +890,37 @@ export class SavedObjectsRepository { ...(Array.isArray(references) && { references }), }; - const updateResponse = await this._writeToCluster('update', { - id: this._serializer.generateRawId(namespace, type, id), - index: this.getIndexForType(type), - ...getExpectedVersionProperties(version, preflightResult), - refresh, - ignore: [404], - body: { - doc, + const { body, statusCode } = await this.client.update( + { + id: this._serializer.generateRawId(namespace, type, id), + index: this.getIndexForType(type), + ...getExpectedVersionProperties(version, preflightResult), + refresh, + + body: { + doc, + }, + _source_includes: ['namespace', 'namespaces'], }, - _sourceIncludes: ['namespace', 'namespaces'], - }); + { ignore: [404] } + ); - if (updateResponse.status === 404) { + if (statusCode === 404) { // see "404s from missing index" above throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } let namespaces = []; if (!this._registry.isNamespaceAgnostic(type)) { - namespaces = updateResponse.get._source.namespaces ?? [ - getNamespaceString(updateResponse.get._source.namespace), - ]; + namespaces = body.get._source.namespaces ?? [getNamespaceString(body.get._source.namespace)]; } return { id, type, updated_at: time, - version: encodeHitVersion(updateResponse), + // @ts-expect-error update doesn't have _seq_no, _primary_term as Record / any in LP + version: encodeHitVersion(body), namespaces, references, attributes, @@ -952,18 +968,20 @@ export class SavedObjectsRepository { namespaces: existingNamespaces ? unique(existingNamespaces.concat(namespaces)) : namespaces, }; - const updateResponse = await this._writeToCluster('update', { - id: rawId, - index: this.getIndexForType(type), - ...getExpectedVersionProperties(version, preflightResult), - refresh, - ignore: [404], - body: { - doc, + const { statusCode } = await this.client.update( + { + id: rawId, + index: this.getIndexForType(type), + ...getExpectedVersionProperties(version, preflightResult), + refresh, + body: { + doc, + }, }, - }); + { ignore: [404] } + ); - if (updateResponse.status === 404) { + if (statusCode === 404) { // see "404s from missing index" above throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } @@ -1015,40 +1033,48 @@ export class SavedObjectsRepository { namespaces: remainingNamespaces, }; - const updateResponse = await this._writeToCluster('update', { - id: rawId, - index: this.getIndexForType(type), - ...getExpectedVersionProperties(undefined, preflightResult), - refresh, - ignore: [404], - body: { - doc, + const { statusCode } = await this.client.update( + { + id: rawId, + index: this.getIndexForType(type), + ...getExpectedVersionProperties(undefined, preflightResult), + refresh, + + body: { + doc, + }, }, - }); + { + ignore: [404], + } + ); - if (updateResponse.status === 404) { + if (statusCode === 404) { // see "404s from missing index" above throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } return {}; } else { // if there are no namespaces remaining, delete the saved object - const deleteResponse = await this._writeToCluster('delete', { - id: this._serializer.generateRawId(undefined, type, id), - index: this.getIndexForType(type), - ...getExpectedVersionProperties(undefined, preflightResult), - refresh, - ignore: [404], - }); + const { body, statusCode } = await this.client.delete( + { + id: this._serializer.generateRawId(undefined, type, id), + refresh, + ...getExpectedVersionProperties(undefined, preflightResult), + index: this.getIndexForType(type), + }, + { + ignore: [404], + } + ); - const deleted = deleteResponse.result === 'deleted'; + const deleted = body.result === 'deleted'; if (deleted) { return {}; } - const deleteDocNotFound = deleteResponse.result === 'not_found'; - const deleteIndexNotFound = - deleteResponse.error && deleteResponse.error.type === 'index_not_found_exception'; + const deleteDocNotFound = body.result === 'not_found'; + const deleteIndexNotFound = body.error && body.error.type === 'index_not_found_exception'; if (deleteDocNotFound || deleteIndexNotFound) { // see "404s from missing index" above throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); @@ -1058,7 +1084,7 @@ export class SavedObjectsRepository { `Unexpected Elasticsearch DELETE response: ${JSON.stringify({ type, id, - response: deleteResponse, + response: { body, statusCode }, })}` ); } @@ -1125,12 +1151,16 @@ export class SavedObjectsRepository { _source: ['type', 'namespaces'], })); const bulkGetResponse = bulkGetDocs.length - ? await this._callCluster('mget', { - body: { - docs: bulkGetDocs, + ? await this.client.mget( + { + body: { + docs: bulkGetDocs, + }, }, - ignore: [404], - }) + { + ignore: [404], + } + ) : undefined; let bulkUpdateRequestIndexCounter = 0; @@ -1145,8 +1175,8 @@ export class SavedObjectsRepository { let namespaces; let versionProperties; if (esRequestIndex !== undefined) { - const indexFound = bulkGetResponse.status !== 404; - const actualResult = indexFound ? bulkGetResponse.docs[esRequestIndex] : undefined; + const indexFound = bulkGetResponse?.statusCode !== 404; + const actualResult = indexFound ? bulkGetResponse?.body.docs[esRequestIndex] : undefined; const docFound = indexFound && actualResult.found === true; if (!docFound || !this.rawDocExistsInNamespace(actualResult, namespace)) { return { @@ -1194,11 +1224,11 @@ export class SavedObjectsRepository { const { refresh = DEFAULT_REFRESH_SETTING } = options; const bulkUpdateResponse = bulkUpdateParams.length - ? await this._writeToCluster('bulk', { + ? await this.client.bulk({ refresh, body: bulkUpdateParams, }) - : {}; + : undefined; return { saved_objects: expectedBulkUpdateResults.map((expectedResult) => { @@ -1207,7 +1237,7 @@ export class SavedObjectsRepository { } const { type, id, namespaces, documentToSave, esRequestIndex } = expectedResult.value; - const response = bulkUpdateResponse.items[esRequestIndex]; + const response = bulkUpdateResponse?.body.items[esRequestIndex]; const { error, _seq_no: seqNo, _primary_term: primaryTerm } = Object.values( response )[0] as any; @@ -1283,11 +1313,11 @@ export class SavedObjectsRepository { const raw = this._serializer.savedObjectToRaw(migrated as SavedObjectSanitizedDoc); - const response = await this._writeToCluster('update', { + const { body } = await this.client.update({ id: raw._id, index: this.getIndexForType(type), refresh, - _source: true, + _source: 'true', body: { script: { source: ` @@ -1315,28 +1345,13 @@ export class SavedObjectsRepository { id, type, updated_at: time, - references: response.get._source.references, - version: encodeHitVersion(response), - attributes: response.get._source[type], + references: body.get._source.references, + // @ts-expect-error + version: encodeHitVersion(body), + attributes: body.get._source[type], }; } - private async _writeToCluster(...args: Parameters) { - try { - return await this._callCluster(...args); - } catch (err) { - throw decorateEsError(err); - } - } - - private async _callCluster(...args: Parameters) { - try { - return await this._unwrappedCallCluster(...args); - } catch (err) { - throw decorateEsError(err); - } - } - /** * Returns index specified by the given type or the default index * @@ -1408,19 +1423,23 @@ export class SavedObjectsRepository { throw new Error(`Cannot make preflight get request for non-multi-namespace type '${type}'.`); } - const response = await this._callCluster('get', { - id: this._serializer.generateRawId(undefined, type, id), - index: this.getIndexForType(type), - ignore: [404], - }); + const { body, statusCode } = await this.client.get>( + { + id: this._serializer.generateRawId(undefined, type, id), + index: this.getIndexForType(type), + }, + { + ignore: [404], + } + ); - const indexFound = response.status !== 404; - const docFound = indexFound && response.found === true; + const indexFound = statusCode !== 404; + const docFound = indexFound && body.found === true; if (docFound) { - if (!this.rawDocExistsInNamespace(response, namespace)) { + if (!this.rawDocExistsInNamespace(body, namespace)) { throw SavedObjectsErrorHelpers.createConflictError(type, id); } - return getSavedObjectNamespaces(namespace, response); + return getSavedObjectNamespaces(namespace, body); } return getSavedObjectNamespaces(namespace); } @@ -1441,18 +1460,20 @@ export class SavedObjectsRepository { } const rawId = this._serializer.generateRawId(undefined, type, id); - const response = await this._callCluster('get', { - id: rawId, - index: this.getIndexForType(type), - ignore: [404], - }); + const { body, statusCode } = await this.client.get>( + { + id: rawId, + index: this.getIndexForType(type), + }, + { ignore: [404] } + ); - const indexFound = response.status !== 404; - const docFound = indexFound && response.found === true; - if (!docFound || !this.rawDocExistsInNamespace(response, namespace)) { + const indexFound = statusCode !== 404; + const docFound = indexFound && body.found === true; + if (!docFound || !this.rawDocExistsInNamespace(body, namespace)) { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } - return response as SavedObjectsRawDoc; + return body as SavedObjectsRawDoc; } } diff --git a/src/core/server/saved_objects/service/lib/repository_es_client.test.mock.ts b/src/core/server/saved_objects/service/lib/repository_es_client.test.mock.ts new file mode 100644 index 000000000000..3dcf82dae5e4 --- /dev/null +++ b/src/core/server/saved_objects/service/lib/repository_es_client.test.mock.ts @@ -0,0 +1,22 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +export const retryCallClusterMock = jest.fn((fn) => fn()); +jest.doMock('../../../elasticsearch/client/retry_call_cluster', () => ({ + retryCallCluster: retryCallClusterMock, +})); diff --git a/src/core/server/saved_objects/service/lib/repository_es_client.test.ts b/src/core/server/saved_objects/service/lib/repository_es_client.test.ts new file mode 100644 index 000000000000..86a984fb6712 --- /dev/null +++ b/src/core/server/saved_objects/service/lib/repository_es_client.test.ts @@ -0,0 +1,64 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { retryCallClusterMock } from './repository_es_client.test.mock'; + +import { createRepositoryEsClient, RepositoryEsClient } from './repository_es_client'; +import { elasticsearchClientMock } from '../../../elasticsearch/client/mocks'; +import { SavedObjectsErrorHelpers } from './errors'; + +describe('RepositoryEsClient', () => { + let client: ReturnType; + let repositoryClient: RepositoryEsClient; + + beforeEach(() => { + client = elasticsearchClientMock.createElasticSearchClient(); + repositoryClient = createRepositoryEsClient(client); + retryCallClusterMock.mockClear(); + }); + + it('delegates call to ES client method', async () => { + expect(repositoryClient.bulk).toStrictEqual(expect.any(Function)); + await repositoryClient.bulk({ body: [] }); + expect(client.bulk).toHaveBeenCalledTimes(1); + }); + + it('wraps a method call in retryCallCluster', async () => { + await repositoryClient.bulk({ body: [] }); + expect(retryCallClusterMock).toHaveBeenCalledTimes(1); + }); + + it('sets maxRetries: 0 to delegate retry logic to retryCallCluster', async () => { + expect(repositoryClient.bulk).toStrictEqual(expect.any(Function)); + await repositoryClient.bulk({ body: [] }); + expect(client.bulk).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ maxRetries: 0 }) + ); + }); + + it('transform elasticsearch errors into saved objects errors', async () => { + expect.assertions(1); + client.bulk = jest.fn().mockRejectedValue(new Error('reason')); + try { + await repositoryClient.bulk({ body: [] }); + } catch (e) { + expect(SavedObjectsErrorHelpers.isSavedObjectsClientError(e)).toBe(true); + } + }); +}); diff --git a/src/core/server/saved_objects/service/lib/repository_es_client.ts b/src/core/server/saved_objects/service/lib/repository_es_client.ts new file mode 100644 index 000000000000..0a759669b1af --- /dev/null +++ b/src/core/server/saved_objects/service/lib/repository_es_client.ts @@ -0,0 +1,56 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import type { TransportRequestOptions } from '@elastic/elasticsearch/lib/Transport'; + +import { ElasticsearchClient } from '../../../elasticsearch/'; +import { retryCallCluster } from '../../../elasticsearch/client/retry_call_cluster'; +import { decorateEsError } from './decorate_es_error'; + +const methods = [ + 'bulk', + 'create', + 'delete', + 'get', + 'index', + 'mget', + 'search', + 'update', + 'updateByQuery', +] as const; + +type MethodName = typeof methods[number]; + +export type RepositoryEsClient = Pick; + +export function createRepositoryEsClient(client: ElasticsearchClient): RepositoryEsClient { + return methods.reduce((acc: RepositoryEsClient, key: MethodName) => { + Object.defineProperty(acc, key, { + value: async (params?: unknown, options?: TransportRequestOptions) => { + try { + return await retryCallCluster(() => + (client[key] as Function)(params, { maxRetries: 0, ...options }) + ); + } catch (e) { + throw decorateEsError(e); + } + }, + }); + return acc; + }, {} as RepositoryEsClient); +} diff --git a/src/plugins/home/server/services/sample_data/routes/list.ts b/src/plugins/home/server/services/sample_data/routes/list.ts index 770b3116b74f..7cce0fa5cccb 100644 --- a/src/plugins/home/server/services/sample_data/routes/list.ts +++ b/src/plugins/home/server/services/sample_data/routes/list.ts @@ -16,7 +16,6 @@ * specific language governing permissions and limitations * under the License. */ -import { isBoom } from 'boom'; import { IRouter } from 'src/core/server'; import { SampleDatasetSchema } from '../lib/sample_dataset_registry_types'; import { createIndexName } from '../lib/create_index_name'; @@ -75,8 +74,7 @@ export const createListRoute = (router: IRouter, sampleDatasets: SampleDatasetSc try { await context.core.savedObjects.client.get('dashboard', sampleDataset.overviewDashboard); } catch (err) { - // savedObjectClient.get() throws an boom error when object is not found. - if (isBoom(err) && err.output.statusCode === 404) { + if (context.core.savedObjects.client.errors.isNotFoundError(err)) { sampleDataset.status = NOT_INSTALLED; return; } diff --git a/test/api_integration/apis/saved_objects/migrations.js b/test/api_integration/apis/saved_objects/migrations.ts similarity index 68% rename from test/api_integration/apis/saved_objects/migrations.js rename to test/api_integration/apis/saved_objects/migrations.ts index ed259ccec011..9997d9710e21 100644 --- a/test/api_integration/apis/saved_objects/migrations.js +++ b/test/api_integration/apis/saved_objects/migrations.ts @@ -23,22 +23,39 @@ import { set } from '@elastic/safer-lodash-set'; import _ from 'lodash'; -import { assert } from 'chai'; +import expect from '@kbn/expect'; +import { ElasticsearchClient, SavedObjectMigrationMap, SavedObjectsType } from 'src/core/server'; +import { SearchResponse } from '../../../../src/core/server/elasticsearch/client'; import { DocumentMigrator, IndexMigrator, + createMigrationEsClient, } from '../../../../src/core/server/saved_objects/migrations/core'; +import { SavedObjectsTypeMappingDefinitions } from '../../../../src/core/server/saved_objects/mappings'; + import { SavedObjectsSerializer, SavedObjectTypeRegistry, } from '../../../../src/core/server/saved_objects'; - -export default ({ getService }) => { - const es = getService('legacyEs'); - const callCluster = (path, ...args) => _.get(es, path).call(es, ...args); +import { FtrProviderContext } from '../../ftr_provider_context'; + +function getLogMock() { + return { + debug() {}, + error() {}, + fatal() {}, + info() {}, + log() {}, + trace() {}, + warn() {}, + get: getLogMock, + }; +} +export default ({ getService }: FtrProviderContext) => { + const esClient = getService('es'); describe('Kibana index migration', () => { - before(() => callCluster('indices.delete', { index: '.migrate-*' })); + before(() => esClient.indices.delete({ index: '.migrate-*' })); it('Migrates an existing index that has never been migrated before', async () => { const index = '.migration-a'; @@ -55,7 +72,7 @@ export default ({ getService }) => { bar: { properties: { mynum: { type: 'integer' } } }, }; - const migrations = { + const migrations: Record = { foo: { '1.0.0': (doc) => set(doc, 'attributes.name', doc.attributes.name.toUpperCase()), }, @@ -66,11 +83,11 @@ export default ({ getService }) => { }, }; - await createIndex({ callCluster, index }); - await createDocs({ callCluster, index, docs: originalDocs }); + await createIndex({ esClient, index }); + await createDocs({ esClient, index, docs: originalDocs }); // Test that unrelated index templates are unaffected - await callCluster('indices.putTemplate', { + await esClient.indices.putTemplate({ name: 'migration_test_a_template', body: { index_patterns: 'migration_test_a', @@ -82,7 +99,7 @@ export default ({ getService }) => { }); // Test that obsolete index templates get removed - await callCluster('indices.putTemplate', { + await esClient.indices.putTemplate({ name: 'migration_a_template', body: { index_patterns: index, @@ -93,29 +110,37 @@ export default ({ getService }) => { }, }); - assert.isTrue(await callCluster('indices.existsTemplate', { name: 'migration_a_template' })); + const migrationATemplate = await esClient.indices.existsTemplate({ + name: 'migration_a_template', + }); + expect(migrationATemplate.body).to.be.ok(); const result = await migrateIndex({ - callCluster, + esClient, index, migrations, mappingProperties, obsoleteIndexTemplatePattern: 'migration_a*', }); - assert.isFalse(await callCluster('indices.existsTemplate', { name: 'migration_a_template' })); - assert.isTrue( - await callCluster('indices.existsTemplate', { name: 'migration_test_a_template' }) - ); + const migrationATemplateAfter = await esClient.indices.existsTemplate({ + name: 'migration_a_template', + }); - assert.deepEqual(_.omit(result, 'elapsedMs'), { + expect(migrationATemplateAfter.body).not.to.be.ok(); + const migrationTestATemplateAfter = await esClient.indices.existsTemplate({ + name: 'migration_test_a_template', + }); + + expect(migrationTestATemplateAfter.body).to.be.ok(); + expect(_.omit(result, 'elapsedMs')).to.eql({ destIndex: '.migration-a_2', sourceIndex: '.migration-a_1', status: 'migrated', }); // The docs in the original index are unchanged - assert.deepEqual(await fetchDocs({ callCluster, index: `${index}_1` }), [ + expect(await fetchDocs(esClient, `${index}_1`)).to.eql([ { id: 'bar:i', type: 'bar', bar: { nomnom: 33 } }, { id: 'bar:o', type: 'bar', bar: { nomnom: 2 } }, { id: 'baz:u', type: 'baz', baz: { title: 'Terrific!' } }, @@ -124,7 +149,7 @@ export default ({ getService }) => { ]); // The docs in the alias have been migrated - assert.deepEqual(await fetchDocs({ callCluster, index }), [ + expect(await fetchDocs(esClient, index)).to.eql([ { id: 'bar:i', type: 'bar', @@ -171,7 +196,7 @@ export default ({ getService }) => { bar: { properties: { mynum: { type: 'integer' } } }, }; - const migrations = { + const migrations: Record = { foo: { '1.0.0': (doc) => set(doc, 'attributes.name', doc.attributes.name.toUpperCase()), }, @@ -182,19 +207,20 @@ export default ({ getService }) => { }, }; - await createIndex({ callCluster, index }); - await createDocs({ callCluster, index, docs: originalDocs }); + await createIndex({ esClient, index }); + await createDocs({ esClient, index, docs: originalDocs }); - await migrateIndex({ callCluster, index, migrations, mappingProperties }); + await migrateIndex({ esClient, index, migrations, mappingProperties }); + // @ts-expect-error name doesn't exist on mynum type mappingProperties.bar.properties.name = { type: 'keyword' }; migrations.foo['2.0.1'] = (doc) => set(doc, 'attributes.name', `${doc.attributes.name}v2`); migrations.bar['2.3.4'] = (doc) => set(doc, 'attributes.name', `NAME ${doc.id}`); - await migrateIndex({ callCluster, index, migrations, mappingProperties }); + await migrateIndex({ esClient, index, migrations, mappingProperties }); // The index for the initial migration has not been destroyed... - assert.deepEqual(await fetchDocs({ callCluster, index: `${index}_2` }), [ + expect(await fetchDocs(esClient, `${index}_2`)).to.eql([ { id: 'bar:i', type: 'bar', @@ -226,7 +252,7 @@ export default ({ getService }) => { ]); // The docs were migrated again... - assert.deepEqual(await fetchDocs({ callCluster, index }), [ + expect(await fetchDocs(esClient, index)).to.eql([ { id: 'bar:i', type: 'bar', @@ -266,48 +292,43 @@ export default ({ getService }) => { foo: { properties: { name: { type: 'text' } } }, }; - const migrations = { + const migrations: Record = { foo: { '1.0.0': (doc) => set(doc, 'attributes.name', 'LOTR'), }, }; - await createIndex({ callCluster, index }); - await createDocs({ callCluster, index, docs: originalDocs }); + await createIndex({ esClient, index }); + await createDocs({ esClient, index, docs: originalDocs }); const result = await Promise.all([ - migrateIndex({ callCluster, index, migrations, mappingProperties }), - migrateIndex({ callCluster, index, migrations, mappingProperties }), + migrateIndex({ esClient, index, migrations, mappingProperties }), + migrateIndex({ esClient, index, migrations, mappingProperties }), ]); // The polling instance and the migrating instance should both - // return a similar migraiton result. - assert.deepEqual( + // return a similar migration result. + expect( result + // @ts-expect-error destIndex exists only on MigrationResult status: 'migrated'; .map(({ status, destIndex }) => ({ status, destIndex })) - .sort((a) => (a.destIndex ? 0 : 1)), - [ - { status: 'migrated', destIndex: '.migration-c_2' }, - { status: 'skipped', destIndex: undefined }, - ] - ); + .sort((a) => (a.destIndex ? 0 : 1)) + ).to.eql([ + { status: 'migrated', destIndex: '.migration-c_2' }, + { status: 'skipped', destIndex: undefined }, + ]); + const { body } = await esClient.cat.indices({ index: '.migration-c*', format: 'json' }); // It only created the original and the dest - assert.deepEqual( - _.map( - await callCluster('cat.indices', { index: '.migration-c*', format: 'json' }), - 'index' - ).sort(), - ['.migration-c_1', '.migration-c_2'] - ); + expect(_.map(body, 'index').sort()).to.eql(['.migration-c_1', '.migration-c_2']); // The docs in the original index are unchanged - assert.deepEqual(await fetchDocs({ callCluster, index: `${index}_1` }), [ + expect(await fetchDocs(esClient, `${index}_1`)).to.eql([ { id: 'foo:lotr', type: 'foo', foo: { name: 'Lord of the Rings' } }, ]); // The docs in the alias have been migrated - assert.deepEqual(await fetchDocs({ callCluster, index }), [ + expect(await fetchDocs(esClient, index)).to.eql([ { id: 'foo:lotr', type: 'foo', @@ -320,38 +341,53 @@ export default ({ getService }) => { }); }; -async function createIndex({ callCluster, index }) { - await callCluster('indices.delete', { index: `${index}*`, ignore: [404] }); +async function createIndex({ esClient, index }: { esClient: ElasticsearchClient; index: string }) { + await esClient.indices.delete({ index: `${index}*` }, { ignore: [404] }); const properties = { type: { type: 'keyword' }, foo: { properties: { name: { type: 'keyword' } } }, bar: { properties: { nomnom: { type: 'integer' } } }, baz: { properties: { title: { type: 'keyword' } } }, }; - await callCluster('indices.create', { + await esClient.indices.create({ index, body: { mappings: { dynamic: 'strict', properties } }, }); } -async function createDocs({ callCluster, index, docs }) { - await callCluster('bulk', { +async function createDocs({ + esClient, + index, + docs, +}: { + esClient: ElasticsearchClient; + index: string; + docs: any[]; +}) { + await esClient.bulk({ body: docs.reduce((acc, doc) => { acc.push({ index: { _id: doc.id, _index: index } }); acc.push(_.omit(doc, 'id')); return acc; }, []), }); - await callCluster('indices.refresh', { index }); + await esClient.indices.refresh({ index }); } async function migrateIndex({ - callCluster, + esClient, index, migrations, mappingProperties, validateDoc, obsoleteIndexTemplatePattern, +}: { + esClient: ElasticsearchClient; + index: string; + migrations: Record; + mappingProperties: SavedObjectsTypeMappingDefinitions; + validateDoc?: (doc: any) => void; + obsoleteIndexTemplatePattern?: string; }) { const typeRegistry = new SavedObjectTypeRegistry(); const types = migrationsToTypes(migrations); @@ -361,17 +397,17 @@ async function migrateIndex({ kibanaVersion: '99.9.9', typeRegistry, validateDoc: validateDoc || _.noop, - log: { info: _.noop, debug: _.noop, warn: _.noop }, + log: getLogMock(), }); const migrator = new IndexMigrator({ - callCluster, + client: createMigrationEsClient(esClient, getLogMock()), documentMigrator, index, obsoleteIndexTemplatePattern, mappingProperties, batchSize: 10, - log: { info: _.noop, debug: _.noop, warn: _.noop }, + log: getLogMock(), pollInterval: 50, scrollDuration: '5m', serializer: new SavedObjectsSerializer(typeRegistry), @@ -380,21 +416,22 @@ async function migrateIndex({ return await migrator.migrate(); } -function migrationsToTypes(migrations) { - return Object.entries(migrations).map(([type, migrations]) => ({ +function migrationsToTypes( + migrations: Record +): SavedObjectsType[] { + return Object.entries(migrations).map(([type, migrationsMap]) => ({ name: type, hidden: false, namespaceType: 'single', mappings: { properties: {} }, - migrations: { ...migrations }, + migrations: { ...migrationsMap }, })); } -async function fetchDocs({ callCluster, index }) { - const { - hits: { hits }, - } = await callCluster('search', { index }); - return hits +async function fetchDocs(esClient: ElasticsearchClient, index: string) { + const { body } = await esClient.search>({ index }); + + return body.hits.hits .map((h) => ({ ...h._source, id: h._id, diff --git a/x-pack/plugins/apm/server/lib/apm_telemetry/index.ts b/x-pack/plugins/apm/server/lib/apm_telemetry/index.ts index 2836cf100a43..6f4f92c6833f 100644 --- a/x-pack/plugins/apm/server/lib/apm_telemetry/index.ts +++ b/x-pack/plugins/apm/server/lib/apm_telemetry/index.ts @@ -5,8 +5,12 @@ */ import { Observable } from 'rxjs'; import { take } from 'rxjs/operators'; -import { CoreSetup, Logger } from 'src/core/server'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import { + CoreSetup, + Logger, + SavedObjectsErrorHelpers, +} from '../../../../../../src/core/server'; import { APMConfig } from '../..'; import { TaskManagerSetupContract, @@ -110,7 +114,7 @@ export async function createApmTelemetry({ return data; } catch (err) { - if (err.output?.statusCode === 404) { + if (SavedObjectsErrorHelpers.isNotFoundError(err)) { // task has not run yet, so no saved object to return return {}; } diff --git a/x-pack/plugins/ingest_manager/server/routes/agent/handlers.ts b/x-pack/plugins/ingest_manager/server/routes/agent/handlers.ts index e485fad09ba9..6cfe3d5b7626 100644 --- a/x-pack/plugins/ingest_manager/server/routes/agent/handlers.ts +++ b/x-pack/plugins/ingest_manager/server/routes/agent/handlers.ts @@ -48,7 +48,7 @@ export const getAgentHandler: RequestHandler { }); mockAgentService.getAgentStatusById = jest.fn().mockImplementation(() => { - throw Boom.notFound('Agent not found'); + SavedObjectsErrorHelpers.createGenericNotFoundError(); }); mockAgentService.getAgent = jest.fn().mockImplementation(() => { - throw Boom.notFound('Agent not found'); + SavedObjectsErrorHelpers.createGenericNotFoundError(); }); mockScopedClient.callAsCurrentUser.mockImplementationOnce(() => Promise.resolve(response)); diff --git a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts index 13ca51e1f2b3..b52c51ba789a 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts @@ -112,7 +112,7 @@ export class ManifestManager { // Cache the compressed body of the artifact this.cache.set(artifactId, Buffer.from(artifact.body, 'base64')); } catch (err) { - if (err.status === 409) { + if (this.savedObjectsClient.errors.isConflictError(err)) { this.logger.debug(`Tried to create artifact ${artifactId}, but it already exists.`); } else { return err; diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_actions.test.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_actions.test.ts index 713d0cb85e2e..525c3781be74 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_actions.test.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_actions.test.ts @@ -3,8 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - -import Boom from 'boom'; +import { SavedObjectsErrorHelpers } from '../../../../../../src/core/server'; import moment from 'moment'; import { @@ -27,7 +26,7 @@ describe('ReindexActions', () => { beforeEach(() => { client = { - errors: null, + errors: SavedObjectsErrorHelpers, create: jest.fn(unimplemented('create')), bulkCreate: jest.fn(unimplemented('bulkCreate')), delete: jest.fn(unimplemented('delete')), @@ -306,7 +305,7 @@ describe('ReindexActions', () => { describe(`IndexConsumerType.${typeKey}`, () => { it('creates the lock doc if it does not exist and executes callback', async () => { expect.assertions(3); - client.get.mockRejectedValueOnce(Boom.notFound()); // mock no ML doc exists yet + client.get.mockRejectedValueOnce(SavedObjectsErrorHelpers.createGenericNotFoundError()); // mock no ML doc exists yet client.create.mockImplementationOnce((type: any, attributes: any, { id }: any) => Promise.resolve({ type, diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_actions.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_actions.ts index 54f9fe9d298f..6d8afee1ff95 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_actions.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_actions.ts @@ -253,7 +253,7 @@ export const reindexActionsFactory = ( // The IndexGroup enum value (a string) serves as the ID of the lock doc return await client.get(REINDEX_OP_TYPE, indexGroup); } catch (e) { - if (e.isBoom && e.output.statusCode === 404) { + if (client.errors.isNotFoundError(e)) { return await client.create( REINDEX_OP_TYPE, { From ff9f06b880dab63e1e577433b012f4a2d6c792dc Mon Sep 17 00:00:00 2001 From: John Dorlus Date: Sun, 26 Jul 2020 10:04:09 -0400 Subject: [PATCH 142/142] The directory in the command was missing the /generated directory and would cause all definitions to be regenerated in the wrong place. (#72766) --- packages/kbn-spec-to-console/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/kbn-spec-to-console/README.md b/packages/kbn-spec-to-console/README.md index 526ceef43e3c..0328dec79132 100644 --- a/packages/kbn-spec-to-console/README.md +++ b/packages/kbn-spec-to-console/README.md @@ -23,10 +23,10 @@ At the root of the Kibana repository, run the following commands: ```sh # OSS -yarn spec_to_console -g "/rest-api-spec/src/main/resources/rest-api-spec/api/*" -d "src/plugins/console/server/lib/spec_definitions/json" +yarn spec_to_console -g "/rest-api-spec/src/main/resources/rest-api-spec/api/*" -d "src/plugins/console/server/lib/spec_definitions/json/generated" # X-pack -yarn spec_to_console -g "/x-pack/plugin/src/test/resources/rest-api-spec/api/*" -d "x-pack/plugins/console_extensions/server/lib/spec_definitions/json" +yarn spec_to_console -g "/x-pack/plugin/src/test/resources/rest-api-spec/api/*" -d "x-pack/plugins/console_extensions/server/lib/spec_definitions/json/generated" ``` ### Information used in Console that is not available in the REST spec