From 3f2913b5b81c5b8394a7c80e1e2ca88549c8a86d Mon Sep 17 00:00:00 2001 From: Andrew Goldstein Date: Tue, 21 Jul 2020 13:46:05 -0600 Subject: [PATCH 01/23] [Security Solution] Full screen Timeline CSS fixes (#72559) ## [Security Solution] Full screen Timeline CSS fixes Fixes CSS issues related to the [Full screen timeline, Collapse event](https://github.com/elastic/kibana/pull/71786) feature: - Sometimes, Timeline's left padding is missing in Full screen mode - The `Attach to new case` and `Attach to existing case...` actions should be centered in Full screen mode - The Timeline flyout button is not opaque when the alerts table is in Full screen mode ### Sometimes, Timeline's left padding is missing in Full screen mode To reproduce: 1) Drag anything to the Timeline 2) Click the `Full screen` button **Expected result** - [x] The timeline has left padding in full screen mode **Actual result** - [x] Sometimes, the left padding of the Timeline is missing, per the screenshot below: ![timeline-full-screen-before](https://user-images.githubusercontent.com/4459398/87998223-8acf8000-cab4-11ea-91a1-6b5644856b44.png) ### Fix: screenshot ![timeline-full-screen-after](https://user-images.githubusercontent.com/4459398/87998363-e3068200-cab4-11ea-8484-41d87ba4c97e.png) ### The `Attach to new case` and `Attach to existing case...` actions should be centered in full screen mode 1) Create a new timeline with the following KQL query: `agent.type : endpoint` 2) Click the `Analyze event` button on any enabled event to view Resolver **Expected result** - [x] The `Attach to new case` and `Attach to existing case...` actions should be centered between the horizontal lines **Actual result** - [x] The `Attach to new case` and `Attach to existing case...` actions are **NOT** centered, per the screenshot below: ![attach-to-case-before](https://user-images.githubusercontent.com/4459398/87998636-b9018f80-cab5-11ea-87e8-a54355386519.png) ### Fix: screenshot ![attach-to-case-after](https://user-images.githubusercontent.com/4459398/87998553-82c41000-cab5-11ea-9e33-fcffce11e4b4.png) ### The Timeline flyout button is not opaque when the alerts table is in Full screen mode To reproduce: 1) Navigate to Security > Detections 2) Click on the `Full screen` button **Expected result** - [x] The Timeline flyout button is opaque when the alerts table is in Full screen mode **Actual result** - [x] The Timeline flyout button is **NOT** opaque when the alerts table is in Full screen mode, per the screenshot below: ![flyout-button-before](https://user-images.githubusercontent.com/4459398/87998761-0d0c7400-cab6-11ea-9cd3-0c091e0291c9.png) ## Fix: screenshot (light theme) ![flyout-button-after-light](https://user-images.githubusercontent.com/4459398/87998784-231a3480-cab6-11ea-8fc9-17c28cf25202.png) ## Fix: screenshot (dark theme) ![flyout-button-after-dark](https://user-images.githubusercontent.com/4459398/87998824-45ac4d80-cab6-11ea-96ef-6242b8494f84.png) ### Desk testing Desk tested in : - Chrome `84.0.4147.89` - Firefox `78.0.2` - Safari `13.1.2` --- .../public/timelines/components/flyout/button/index.tsx | 1 + .../public/timelines/components/flyout/pane/index.tsx | 8 +++++--- .../public/timelines/components/graph_overlay/index.tsx | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/button/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/button/index.tsx index 5896a02b8202..c0a59fd07e34 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/button/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/button/index.tsx @@ -39,6 +39,7 @@ const Container = styled.div` } .${FLYOUT_BUTTON_CLASS_NAME} { + background: ${({ theme }) => rgba(theme.eui.euiPageBackgroundColor, 1)}; border-radius: 4px 4px 0 0; box-shadow: none; height: 46px; diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/index.tsx index f41d318ba958..3f842bcc2eb6 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/pane/index.tsx @@ -44,6 +44,8 @@ const StyledResizable = styled(Resizable)` const RESIZABLE_ENABLE = { left: true }; +const RESIZABLE_DISABLED = { left: false }; + const FlyoutPaneComponent: React.FC = ({ children, onClose, @@ -98,10 +100,10 @@ const FlyoutPaneComponent: React.FC = ({ size="l" > diff --git a/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx index 9f20c7f6c157..54b30aca44a1 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx @@ -181,7 +181,7 @@ const GraphOverlayComponent = ({ {timelineId === TimelineId.active && timelineType === TimelineType.default && ( - + Date: Tue, 21 Jul 2020 12:57:29 -0700 Subject: [PATCH 02/23] [test] Skips flaky bulk operation buffer tests Signed-off-by: Tyler Smalley --- .../task_manager/server/lib/bulk_operation_buffer.test.ts | 2 +- 1 file changed, 1 insertion(+), 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 929365623302..1994e5f74937 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.skip('Bulk Operation Buffer', () => { describe('createBuffer()', () => { test('batches up multiple Operation calls', async () => { const bulkUpdate: jest.Mocked> = jest.fn( From 8d5a5d0860c7747802720030f91b7658196d7e88 Mon Sep 17 00:00:00 2001 From: Ryland Herrick Date: Tue, 21 Jul 2020 15:26:51 -0500 Subject: [PATCH 03/23] [Security Solution][Detections] Adds loading states to export/delete on modal (#72562) * Add loading spinners to Value Lists modal While export or a delete is pending, we display a loading spinner instead of the button that was clicked. Since state is controlled in the parent, we must pass this additional state in the same way; the table component simply reacts to this state. * Fix bug with useAsync and multiple calls Multiple calls to start() would not previously reset the hook's state, where useEffect on the hook's state would fire improperly as subsequent calls would not travel the same undefined -> result path. * Fix style of loading spinner This fits the size of the button it's replacing, so no shifting occurs when replacing elements. * Better styling of spinner Keep it roughly the same size as the icons themselves, and fill the space with margin. * Fix circular dependency in value lists modal Moves our shared types into a separate module to prevent a circular dependency. --- .../public/common/hooks/use_async.test.ts | 32 ++++++ .../lists/public/common/hooks/use_async.ts | 2 + .../value_lists_management_modal/modal.tsx | 17 +++- .../table.test.tsx | 20 +++- .../value_lists_management_modal/table.tsx | 67 ++----------- .../table_helpers.tsx | 98 +++++++++++++++++++ .../value_lists_management_modal/types.ts | 16 +++ 7 files changed, 185 insertions(+), 67 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/table_helpers.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/types.ts diff --git a/x-pack/plugins/lists/public/common/hooks/use_async.test.ts b/x-pack/plugins/lists/public/common/hooks/use_async.test.ts index 6f115929c3f6..33f28cfc9729 100644 --- a/x-pack/plugins/lists/public/common/hooks/use_async.test.ts +++ b/x-pack/plugins/lists/public/common/hooks/use_async.test.ts @@ -95,4 +95,36 @@ describe('useAsync', () => { expect(result.current.loading).toBe(false); }); + + it('multiple start calls reset state', async () => { + let resolve: (result: string) => void; + fn.mockImplementation(() => new Promise((_resolve) => (resolve = _resolve))); + + const { result, waitForNextUpdate } = renderHook(() => useAsync(fn)); + + act(() => { + result.current.start(args); + }); + + expect(result.current.loading).toBe(true); + + act(() => resolve('result')); + await waitForNextUpdate(); + + expect(result.current.loading).toBe(false); + expect(result.current.result).toBe('result'); + + act(() => { + result.current.start(args); + }); + + expect(result.current.loading).toBe(true); + expect(result.current.result).toBe(undefined); + + act(() => resolve('result')); + await waitForNextUpdate(); + + expect(result.current.loading).toBe(false); + expect(result.current.result).toBe('result'); + }); }); diff --git a/x-pack/plugins/lists/public/common/hooks/use_async.ts b/x-pack/plugins/lists/public/common/hooks/use_async.ts index 362cad069b7e..2f4d611a4a73 100644 --- a/x-pack/plugins/lists/public/common/hooks/use_async.ts +++ b/x-pack/plugins/lists/public/common/hooks/use_async.ts @@ -32,6 +32,8 @@ export const useAsync = ( const start = useCallback( (...args: Args) => { setLoading(true); + setResult(undefined); + setError(undefined); fn(...args) .then((r) => isMounted() && setResult(r)) .catch((e) => isMounted() && setError(e)) diff --git a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/modal.tsx b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/modal.tsx index d7d4be6d951b..dc7226043909 100644 --- a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/modal.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/modal.tsx @@ -46,6 +46,7 @@ export const ValueListsModalComponent: React.FC = ({ const { start: findLists, ...lists } = useFindLists(); const { start: deleteList, result: deleteResult } = useDeleteList(); const [exportListId, setExportListId] = useState(); + const [deletingListIds, setDeletingListIds] = useState([]); const { addError, addSuccess } = useAppToasts(); const fetchLists = useCallback(() => { @@ -54,16 +55,18 @@ export const ValueListsModalComponent: React.FC = ({ const handleDelete = useCallback( ({ id }: { id: string }) => { + setDeletingListIds([...deletingListIds, id]); deleteList({ http, id }); }, - [deleteList, http] + [deleteList, deletingListIds, http] ); useEffect(() => { - if (deleteResult != null) { + if (deleteResult != null && deletingListIds.length > 0) { + setDeletingListIds([...deletingListIds.filter((id) => id !== deleteResult.id)]); fetchLists(); } - }, [deleteResult, fetchLists]); + }, [deleteResult, deletingListIds, fetchLists]); const handleExport = useCallback( async ({ ids }: { ids: string[] }) => @@ -116,6 +119,12 @@ export const ValueListsModalComponent: React.FC = ({ return null; } + const tableItems = (lists.result?.data ?? []).map((item) => ({ + ...item, + isExporting: item.id === exportListId, + isDeleting: deletingListIds.includes(item.id), + })); + const pagination = { pageIndex, pageSize, @@ -133,7 +142,7 @@ export const ValueListsModalComponent: React.FC = ({ + lists.map((list) => ({ + ...list, + isDeleting: false, + isExporting: false, + })); describe('ValueListsTable', () => { it('renders a row for each list', () => { const lists = Array(3).fill(getListResponseMock()); + const items = buildItems(lists); const container = mount( { it('calls onChange when pagination is modified', () => { const lists = Array(6).fill(getListResponseMock()); + const items = buildItems(lists); const onChange = jest.fn(); const container = mount( { it('calls onExport when export is clicked', () => { const lists = Array(3).fill(getListResponseMock()); + const items = buildItems(lists); const onExport = jest.fn(); const container = mount( { it('calls onDelete when delete is clicked', () => { const lists = Array(3).fill(getListResponseMock()); + const items = buildItems(lists); const onDelete = jest.fn(); const container = mount( ; -type ActionCallback = (item: ListSchema) => void; +import { buildColumns } from './table_helpers'; +import { TableProps, TableItemCallback } from './types'; export interface ValueListsTableProps { - lists: TableProps['items']; + items: TableProps['items']; loading: boolean; onChange: TableProps['onChange']; - onExport: ActionCallback; - onDelete: ActionCallback; + onExport: TableItemCallback; + onDelete: TableItemCallback; pagination: Exclude; } -const buildColumns = ( - onExport: ActionCallback, - onDelete: ActionCallback -): TableProps['columns'] => [ - { - field: 'name', - name: i18n.COLUMN_FILE_NAME, - truncateText: true, - }, - { - field: 'created_at', - name: i18n.COLUMN_UPLOAD_DATE, - /* eslint-disable-next-line react/display-name */ - render: (value: ListSchema['created_at']) => ( - - ), - width: '30%', - }, - { - field: 'created_by', - name: i18n.COLUMN_CREATED_BY, - truncateText: true, - width: '20%', - }, - { - name: i18n.COLUMN_ACTIONS, - actions: [ - { - name: i18n.ACTION_EXPORT_NAME, - description: i18n.ACTION_EXPORT_DESCRIPTION, - icon: 'exportAction', - type: 'icon', - onClick: onExport, - 'data-test-subj': 'action-export-value-list', - }, - { - name: i18n.ACTION_DELETE_NAME, - description: i18n.ACTION_DELETE_DESCRIPTION, - icon: 'trash', - type: 'icon', - onClick: onDelete, - 'data-test-subj': 'action-delete-value-list', - }, - ], - width: '15%', - }, -]; - export const ValueListsTableComponent: React.FC = ({ - lists, + items, loading, onChange, onExport, @@ -87,7 +36,7 @@ export const ValueListsTableComponent: React.FC = ({ theme.eui.euiSizeXS}; + vertical-align: middle; +`; + +const ActionButton: React.FC<{ + content: string; + dataTestSubj: string; + icon: IconType; + isLoading: boolean; + item: TableItem; + onClick: TableItemCallback; +}> = ({ content, dataTestSubj, icon, item, onClick, isLoading }) => ( + + {isLoading ? ( + + ) : ( + onClick(item)} + /> + )} + +); + +export const buildColumns = ( + onExport: TableItemCallback, + onDelete: TableItemCallback +): TableProps['columns'] => [ + { + field: 'name', + name: i18n.COLUMN_FILE_NAME, + truncateText: true, + }, + { + field: 'created_at', + name: i18n.COLUMN_UPLOAD_DATE, + render: (value: ListSchema['created_at']) => ( + + ), + width: '30%', + }, + { + field: 'created_by', + name: i18n.COLUMN_CREATED_BY, + truncateText: true, + width: '20%', + }, + { + name: i18n.COLUMN_ACTIONS, + actions: [ + { + render: (item) => ( + + ), + }, + { + render: (item) => ( + + ), + }, + ], + width: '15%', + }, +]; diff --git a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/types.ts b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/types.ts new file mode 100644 index 000000000000..f85e27524772 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/types.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 { EuiBasicTableProps } from '@elastic/eui'; + +import { ListSchema } from '../../../../../lists/common/schemas/response'; + +export interface TableItem extends ListSchema { + isDeleting: boolean; + isExporting: boolean; +} +export type TableProps = EuiBasicTableProps; +export type TableItemCallback = (item: TableItem) => void; From b0ef3e958033bc9ce8ab0de32c3024aa26b6dc60 Mon Sep 17 00:00:00 2001 From: Nick Partridge Date: Tue, 21 Jul 2020 15:31:49 -0500 Subject: [PATCH 04/23] Fix sorting of scripted string fields (#72681) --- .../normalize_sort_request.test.ts | 62 ++++++++++++++++++- .../search_source/normalize_sort_request.ts | 2 +- 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/src/plugins/data/public/search/search_source/normalize_sort_request.test.ts b/src/plugins/data/public/search/search_source/normalize_sort_request.test.ts index d47aab80ee0b..10004b87ca69 100644 --- a/src/plugins/data/public/search/search_source/normalize_sort_request.test.ts +++ b/src/plugins/data/public/search/search_source/normalize_sort_request.test.ts @@ -23,13 +23,23 @@ import { IIndexPattern } from '../..'; describe('SearchSource#normalizeSortRequest', function () { const scriptedField = { - name: 'script string', + name: 'script number', type: 'number', scripted: true, sortable: true, script: 'foo', lang: 'painless', }; + const stringScriptedField = { + ...scriptedField, + name: 'script string', + type: 'string', + }; + const booleanScriptedField = { + ...scriptedField, + name: 'script boolean', + type: 'boolean', + }; const murmurScriptedField = { ...scriptedField, sortable: false, @@ -37,7 +47,7 @@ describe('SearchSource#normalizeSortRequest', function () { type: 'murmur3', }; const indexPattern = { - fields: [scriptedField, murmurScriptedField], + fields: [scriptedField, stringScriptedField, booleanScriptedField, murmurScriptedField], } as IIndexPattern; it('should return an array', function () { @@ -106,6 +116,54 @@ describe('SearchSource#normalizeSortRequest', function () { ]); }); + it('should use script based sorting with string type', function () { + const result = normalizeSortRequest( + [ + { + [stringScriptedField.name]: SortDirection.asc, + }, + ], + indexPattern + ); + + expect(result).toEqual([ + { + _script: { + script: { + source: stringScriptedField.script, + lang: stringScriptedField.lang, + }, + type: 'string', + order: SortDirection.asc, + }, + }, + ]); + }); + + it('should use script based sorting with boolean type as string type', function () { + const result = normalizeSortRequest( + [ + { + [booleanScriptedField.name]: SortDirection.asc, + }, + ], + indexPattern + ); + + expect(result).toEqual([ + { + _script: { + script: { + source: booleanScriptedField.script, + lang: booleanScriptedField.lang, + }, + type: 'string', + order: SortDirection.asc, + }, + }, + ]); + }); + it('should use script based sorting only on sortable types', function () { const result = normalizeSortRequest( [ diff --git a/src/plugins/data/public/search/search_source/normalize_sort_request.ts b/src/plugins/data/public/search/search_source/normalize_sort_request.ts index 9a0cf371ce81..b00d28b38d67 100644 --- a/src/plugins/data/public/search/search_source/normalize_sort_request.ts +++ b/src/plugins/data/public/search/search_source/normalize_sort_request.ts @@ -69,7 +69,7 @@ function normalize( // The ES API only supports sort scripts of type 'number' and 'string' function castSortType(type: string) { - if (['number', 'string'].includes(type)) { + if (['number'].includes(type)) { return 'number'; } else if (['string', 'boolean'].includes(type)) { return 'string'; From 2437db63efabcd60b6a0cb5864711178f63fd505 Mon Sep 17 00:00:00 2001 From: Brian Seeders Date: Tue, 21 Jul 2020 16:51:58 -0400 Subject: [PATCH 05/23] [CI] In-progress PR comments (#72211) --- vars/githubPr.groovy | 95 +++++++++++++++++++++++++++++++------- vars/jenkinsApi.groovy | 2 +- vars/kibanaPipeline.groovy | 30 +++++++----- vars/workers.groovy | 4 +- 4 files changed, 101 insertions(+), 30 deletions(-) diff --git a/vars/githubPr.groovy b/vars/githubPr.groovy index 9dad2f88de2e..da5348749f66 100644 --- a/vars/githubPr.groovy +++ b/vars/githubPr.groovy @@ -15,25 +15,43 @@ */ def withDefaultPrComments(closure) { catchErrors { + // sendCommentOnError() needs to know if comments are enabled, so lets track it with a global + // isPr() just ensures this functionality is skipped for non-PR builds + buildState.set('PR_COMMENTS_ENABLED', isPr()) catchErrors { closure() } + sendComment(true) + } +} - if (!params.ENABLE_GITHUB_PR_COMMENTS || !isPr()) { - return - } +def sendComment(isFinal = false) { + if (!buildState.get('PR_COMMENTS_ENABLED')) { + return + } - def status = buildUtils.getBuildStatus() - if (status == "ABORTED") { - return; - } + def status = buildUtils.getBuildStatus() + if (status == "ABORTED") { + return + } + + def lastComment = getLatestBuildComment() + def info = getLatestBuildInfo(lastComment) ?: [:] + info.builds = (info.builds ?: []).takeRight(5) // Rotate out old builds + + // If two builds are running at the same time, the first one should not post a comment after the second one + if (info.number && info.number.toInteger() > env.BUILD_NUMBER.toInteger()) { + return + } + + def shouldUpdateComment = !!info.builds.find { it.number == env.BUILD_NUMBER } - def lastComment = getLatestBuildComment() - def info = getLatestBuildInfo(lastComment) ?: [:] - info.builds = (info.builds ?: []).takeRight(5) // Rotate out old builds + def message = getNextCommentMessage(info, isFinal) - def message = getNextCommentMessage(info) - postComment(message) + if (shouldUpdateComment) { + updateComment(lastComment.id, message) + } else { + createComment(message) if (lastComment && lastComment.user.login == 'kibanamachine') { deleteComment(lastComment.id) @@ -41,6 +59,19 @@ def withDefaultPrComments(closure) { } } +def sendCommentOnError(Closure closure) { + try { + closure() + } catch (ex) { + // If this is the first failed step, it's likely that the error hasn't propagated up far enough to mark the build as a failure + currentBuild.result = 'FAILURE' + catchErrors { + sendComment(false) + } + throw ex + } +} + // Checks whether or not this currently executing build was triggered via a PR in the elastic/kibana repo def isPr() { return !!(env.ghprbPullId && env.ghprbPullLink && env.ghprbPullLink =~ /\/elastic\/kibana\//) @@ -66,7 +97,7 @@ def getLatestBuildInfo() { } def getLatestBuildInfo(comment) { - return comment ? getBuildInfoFromComment(comment) : null + return comment ? getBuildInfoFromComment(comment.body) : null } def createBuildInfo() { @@ -137,14 +168,25 @@ def getTestFailuresMessage() { return messages.join("\n") } -def getNextCommentMessage(previousCommentInfo = [:]) { +def getNextCommentMessage(previousCommentInfo = [:], isFinal = false) { def info = previousCommentInfo ?: [:] info.builds = previousCommentInfo.builds ?: [] + // When we update an in-progress comment, we need to remove the old version from the history + info.builds = info.builds.findAll { it.number != env.BUILD_NUMBER } + def messages = [] def status = buildUtils.getBuildStatus() - if (status == 'SUCCESS') { + if (!isFinal) { + def failuresPart = status != 'SUCCESS' ? ', with failures' : '' + messages << """ + ## :hourglass_flowing_sand: Build in-progress${failuresPart} + * [continuous-integration/kibana-ci/pull-request](${env.BUILD_URL}) + * Commit: ${getCommitHash()} + * This comment will update when the build is complete + """ + } else if (status == 'SUCCESS') { messages << """ ## :green_heart: Build Succeeded * [continuous-integration/kibana-ci/pull-request](${env.BUILD_URL}) @@ -172,7 +214,9 @@ def getNextCommentMessage(previousCommentInfo = [:]) { * [Pipeline Steps](${env.BUILD_URL}flowGraphTable) (look for red circles / failed steps) * [Interpreting CI Failures](https://www.elastic.co/guide/en/kibana/current/interpreting-ci-failures.html) """ + } + if (status != 'SUCCESS' && status != 'UNSTABLE') { try { def steps = getFailedSteps() if (steps?.size() > 0) { @@ -186,7 +230,10 @@ def getNextCommentMessage(previousCommentInfo = [:]) { } messages << getTestFailuresMessage() - messages << ciStats.getMetricsReport() + + if (isFinal) { + messages << ciStats.getMetricsReport() + } if (info.builds && info.builds.size() > 0) { messages << getHistoryText(info.builds) @@ -208,7 +255,7 @@ def getNextCommentMessage(previousCommentInfo = [:]) { .join("\n\n") } -def postComment(message) { +def createComment(message) { if (!isPr()) { error "Trying to post a GitHub PR comment on a non-PR or non-elastic PR build" } @@ -224,6 +271,20 @@ def getComments() { } } +def updateComment(commentId, message) { + if (!isPr()) { + error "Trying to post a GitHub PR comment on a non-PR or non-elastic PR build" + } + + withGithubCredentials { + def path = "repos/elastic/kibana/issues/comments/${commentId}" + def json = toJSON([ body: message ]).toString() + + def resp = githubApi([ path: path ], [ method: "POST", data: json, headers: [ "X-HTTP-Method-Override": "PATCH" ] ]) + return toJSON(resp) + } +} + def deleteComment(commentId) { withGithubCredentials { def path = "repos/elastic/kibana/issues/comments/${commentId}" diff --git a/vars/jenkinsApi.groovy b/vars/jenkinsApi.groovy index 1ea4c3dd76b8..57818593ffeb 100644 --- a/vars/jenkinsApi.groovy +++ b/vars/jenkinsApi.groovy @@ -10,7 +10,7 @@ def getSteps() { def getFailedSteps() { def steps = getSteps() - def failedSteps = steps?.findAll { it.iconColor == "red" && it._class == "org.jenkinsci.plugins.workflow.cps.nodes.StepAtomNode" } + def failedSteps = steps?.findAll { (it.iconColor == "red" || it.iconColor == "red_anime") && it._class == "org.jenkinsci.plugins.workflow.cps.nodes.StepAtomNode" } failedSteps.each { step -> step.logs = "${env.BUILD_URL}execution/node/${step.id}/log".toString() } diff --git a/vars/kibanaPipeline.groovy b/vars/kibanaPipeline.groovy index f43fe9f96c3e..410578886a01 100644 --- a/vars/kibanaPipeline.groovy +++ b/vars/kibanaPipeline.groovy @@ -35,7 +35,9 @@ def functionalTestProcess(String name, Closure closure) { "JOB=${name}", "KBN_NP_PLUGINS_BUILT=true", ]) { - closure() + githubPr.sendCommentOnError { + closure() + } } } } @@ -180,26 +182,32 @@ def bash(script, label) { } def doSetup() { - retryWithDelay(2, 15) { - try { - runbld("./test/scripts/jenkins_setup.sh", "Setup Build Environment and Dependencies") - } catch (ex) { + githubPr.sendCommentOnError { + retryWithDelay(2, 15) { try { - // Setup expects this directory to be missing, so we need to remove it before we do a retry - bash("rm -rf ../elasticsearch", "Remove elasticsearch sibling directory, if it exists") - } finally { - throw ex + runbld("./test/scripts/jenkins_setup.sh", "Setup Build Environment and Dependencies") + } catch (ex) { + try { + // Setup expects this directory to be missing, so we need to remove it before we do a retry + bash("rm -rf ../elasticsearch", "Remove elasticsearch sibling directory, if it exists") + } finally { + throw ex + } } } } } def buildOss() { - runbld("./test/scripts/jenkins_build_kibana.sh", "Build OSS/Default Kibana") + githubPr.sendCommentOnError { + runbld("./test/scripts/jenkins_build_kibana.sh", "Build OSS/Default Kibana") + } } def buildXpack() { - runbld("./test/scripts/jenkins_xpack_build_kibana.sh", "Build X-Pack Kibana") + githubPr.sendCommentOnError { + runbld("./test/scripts/jenkins_xpack_build_kibana.sh", "Build X-Pack Kibana") + } } def runErrorReporter() { diff --git a/vars/workers.groovy b/vars/workers.groovy index 8b7e8525a7ce..74ce86516e86 100644 --- a/vars/workers.groovy +++ b/vars/workers.groovy @@ -126,7 +126,9 @@ def intake(jobName, String script) { return { ci(name: jobName, size: 's-highmem', ramDisk: true) { withEnv(["JOB=${jobName}"]) { - runbld(script, "Execute ${jobName}") + githubPr.sendCommentOnError { + runbld(script, "Execute ${jobName}") + } } } } From b32f72d370eb0d2c8ccfed01ff92ee915f2a1c5c Mon Sep 17 00:00:00 2001 From: Spencer Date: Tue, 21 Jul 2020 14:00:19 -0700 Subject: [PATCH 06/23] skip 6.8 branch when triggering baseline-capture builds (#72706) Co-authored-by: spalger --- .ci/Jenkinsfile_baseline_trigger | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.ci/Jenkinsfile_baseline_trigger b/.ci/Jenkinsfile_baseline_trigger index cc9fb47ca499..221b7a44e30d 100644 --- a/.ci/Jenkinsfile_baseline_trigger +++ b/.ci/Jenkinsfile_baseline_trigger @@ -16,6 +16,12 @@ kibanaLibrary.load() withGithubCredentials { branches.each { branch -> + if (branch == '6.8') { + // skip 6.8, it is tracked but we don't need snapshots for it and haven't backported + // the baseline capture scripts to it. + return; + } + stage(branch) { def commits = getCommits(branch, MAXIMUM_COMMITS_TO_CHECK, MAXIMUM_COMMITS_TO_BUILD) From bb7d12864f8c8c87c52d7328b72865a1b97d2be6 Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Tue, 21 Jul 2020 17:34:19 -0400 Subject: [PATCH 07/23] [Ingest Manager] Allow to force unenroll from the UI (#72386) --- .../common/services/agent_status.ts | 6 +-- .../components/context_menu_actions.tsx | 24 ++++++++--- .../components/actions_menu.tsx | 18 +++++--- .../sections/fleet/agent_list_page/index.tsx | 22 +++++++--- .../components/agent_unenroll_provider.tsx | 43 +++++++++++++------ 5 files changed, 82 insertions(+), 31 deletions(-) diff --git a/x-pack/plugins/ingest_manager/common/services/agent_status.ts b/x-pack/plugins/ingest_manager/common/services/agent_status.ts index 536003b0f743..fe4e094e1bb2 100644 --- a/x-pack/plugins/ingest_manager/common/services/agent_status.ts +++ b/x-pack/plugins/ingest_manager/common/services/agent_status.ts @@ -13,12 +13,12 @@ export function getAgentStatus(agent: Agent, now: number = Date.now()): AgentSta if (!agent.active) { return 'inactive'; } - if (!agent.last_checkin) { - return 'enrolling'; - } if (agent.unenrollment_started_at && !agent.unenrolled_at) { return 'unenrolling'; } + if (!agent.last_checkin) { + return 'enrolling'; + } const msLastCheckIn = new Date(lastCheckIn || 0).getTime(); const msSinceLastCheckIn = new Date().getTime() - msLastCheckIn; diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/context_menu_actions.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/context_menu_actions.tsx index 8a9f0553895a..7d1f12447340 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/context_menu_actions.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/context_menu_actions.tsx @@ -22,6 +22,8 @@ type Props = { props: EuiButtonProps; children: JSX.Element; }; + isOpen?: boolean; + onChange?: (isOpen: boolean) => void; } & ( | { items: EuiContextMenuPanelProps['items']; @@ -31,10 +33,22 @@ type Props = { } ); -export const ContextMenuActions = React.memo(({ button, ...props }) => { - const [isOpen, setIsOpen] = useState(false); - const handleCloseMenu = useCallback(() => setIsOpen(false), [setIsOpen]); - const handleToggleMenu = useCallback(() => setIsOpen(!isOpen), [isOpen]); +export const ContextMenuActions = React.memo(({ button, onChange, isOpen, ...props }) => { + const [isOpenState, setIsOpenState] = useState(false); + const handleCloseMenu = useCallback(() => { + if (onChange) { + onChange(false); + } else { + setIsOpenState(false); + } + }, [setIsOpenState, onChange]); + const handleToggleMenu = useCallback(() => { + if (onChange) { + onChange(!isOpen); + } else { + setIsOpenState(!isOpenState); + } + }, [isOpenState, onChange, isOpen]); return ( (({ button, ...props }) => { /> ) } - isOpen={isOpen} + isOpen={isOpen === undefined ? isOpenState : isOpen} closePopover={handleCloseMenu} > {'items' in props ? ( diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/actions_menu.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/actions_menu.tsx index 75a67fb9288e..7afc57b30cef 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/actions_menu.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_details_page/components/actions_menu.tsx @@ -20,6 +20,7 @@ export const AgentDetailsActionMenu: React.FunctionComponent<{ const hasWriteCapabilites = useCapabilities().write; const refreshAgent = useAgentRefresh(); const [isReassignFlyoutOpen, setIsReassignFlyoutOpen] = useState(assignFlyoutOpenByDefault); + const isUnenrolling = agent.status === 'unenrolling'; const onClose = useMemo(() => { if (onCancelReassign) { @@ -59,7 +60,7 @@ export const AgentDetailsActionMenu: React.FunctionComponent<{ defaultMessage="Assign new agent config" /> , - + {(unenrollAgentsPrompt) => ( - + {isUnenrolling ? ( + + ) : ( + + )} )} , 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 034482c4cf9b..3743f9b39191 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 @@ -74,8 +74,12 @@ const RowActions = React.memo<{ agent: Agent; onReassignClick: () => void; refre const { getHref } = useLink(); const hasWriteCapabilites = useCapabilities().write; + const isUnenrolling = agent.status === 'unenrolling'; + const [isMenuOpen, setIsMenuOpen] = useState(false); return ( setIsMenuOpen(isOpen)} items={[ void; refre /> , - + {(unenrollAgentsPrompt) => ( void; refre onClick={() => { unenrollAgentsPrompt([agent.id], 1, () => { refresh(); + setIsMenuOpen(false); }); }} > - + {isUnenrolling ? ( + + ) : ( + + )} )} , diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_unenroll_provider.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_unenroll_provider.tsx index 90d8ff545341..6f1cba70bbce 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_unenroll_provider.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/components/agent_unenroll_provider.tsx @@ -14,6 +14,7 @@ import { agentRouteService } from '../../../services'; interface Props { children: (unenrollAgents: UnenrollAgents) => React.ReactElement; + forceUnenroll?: boolean; } export type UnenrollAgents = ( @@ -24,7 +25,10 @@ export type UnenrollAgents = ( type OnSuccessCallback = (agentsUnenrolled: string[]) => void; -export const AgentUnenrollProvider: React.FunctionComponent = ({ children }) => { +export const AgentUnenrollProvider: React.FunctionComponent = ({ + children, + forceUnenroll = false, +}) => { const core = useCore(); const [agents, setAgents] = useState([]); const [agentsCount, setAgentsCount] = useState(0); @@ -65,19 +69,24 @@ export const AgentUnenrollProvider: React.FunctionComponent = ({ children const { error } = await sendRequest({ path: agentRouteService.getUnenrollPath(agentId), method: 'post', + body: { + force: forceUnenroll, + }, }); if (error) { throw new Error(error.message); } - const successMessage = i18n.translate( - 'xpack.ingestManager.unenrollAgents.successSingleNotificationTitle', - { - defaultMessage: "Unenrolling agent '{id}'", - values: { id: agentId }, - } - ); + const successMessage = forceUnenroll + ? i18n.translate('xpack.ingestManager.unenrollAgents.successForceSingleNotificationTitle', { + defaultMessage: "Agent '{id}' unenrolled", + values: { id: agentId }, + }) + : i18n.translate('xpack.ingestManager.unenrollAgents.successSingleNotificationTitle', { + defaultMessage: "Unenrolling agent '{id}'", + values: { id: agentId }, + }); core.notifications.toasts.addSuccess(successMessage); if (onSuccessCallback.current) { @@ -107,11 +116,19 @@ export const AgentUnenrollProvider: React.FunctionComponent = ({ children + forceUnenroll ? ( + + ) : ( + + ) ) : ( Date: Tue, 21 Jul 2020 17:47:22 -0400 Subject: [PATCH 08/23] [Resolver] Origin process (#72382) Co-authored-by: Brent Kimmel * Center the origin node * Nodes appear selected when they are selected. also the aria attributes are working. * Reposition the submenu when the user pans. --- .../isometric_taxi_layout.test.ts | 4 +- .../isometric_taxi_layout.ts | 61 ++++++++++++++---- .../public/resolver/store/actions.ts | 23 ++----- .../public/resolver/store/data/selectors.ts | 64 ++++++++++++++++--- .../public/resolver/store/reducer.ts | 50 ++++++--------- .../public/resolver/store/selectors.ts | 27 ++++---- .../public/resolver/store/ui/selectors.ts | 23 ++----- .../public/resolver/types.ts | 12 ++-- .../public/resolver/view/assets.tsx | 12 ++-- .../public/resolver/view/map.tsx | 3 +- .../public/resolver/view/panel.tsx | 6 +- .../resolver/view/process_event_dot.tsx | 34 +++++----- .../public/resolver/view/submenu.tsx | 34 +++++++++- 13 files changed, 214 insertions(+), 139 deletions(-) diff --git a/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/isometric_taxi_layout.test.ts b/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/isometric_taxi_layout.test.ts index bd534dcb989e..40be175c9fdb 100644 --- a/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/isometric_taxi_layout.test.ts +++ b/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/isometric_taxi_layout.test.ts @@ -5,7 +5,7 @@ */ import { IsometricTaxiLayout } from '../../types'; import { LegacyEndpointEvent } from '../../../../common/endpoint/types'; -import { isometricTaxiLayout } from './isometric_taxi_layout'; +import { isometricTaxiLayoutFactory } from './isometric_taxi_layout'; import { mockProcessEvent } from '../../models/process_event_test_helpers'; import { factory } from './index'; @@ -107,7 +107,7 @@ describe('resolver graph layout', () => { unique_ppid: 0, }, }); - layout = () => isometricTaxiLayout(factory(events)); + layout = () => isometricTaxiLayoutFactory(factory(events)); events = []; }); describe('when rendering no nodes', () => { diff --git a/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/isometric_taxi_layout.ts b/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/isometric_taxi_layout.ts index 11c888d1462f..1fc2ea0150ae 100644 --- a/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/isometric_taxi_layout.ts +++ b/x-pack/plugins/security_solution/public/resolver/models/indexed_process_tree/isometric_taxi_layout.ts @@ -3,7 +3,6 @@ * 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 vector2 from '../../models/vector2'; import { IndexedProcessTree, Vector2, @@ -17,14 +16,17 @@ import { } from '../../types'; import * as event from '../../../../common/endpoint/models/event'; import { ResolverEvent } from '../../../../common/endpoint/types'; -import * as model from './index'; +import * as vector2 from '../vector2'; +import * as indexedProcessTreeModel from './index'; import { getFriendlyElapsedTime as elapsedTime } from '../../lib/date'; import { uniquePidForProcess } from '../process_event'; /** * Graph the process tree */ -export function isometricTaxiLayout(indexedProcessTree: IndexedProcessTree): IsometricTaxiLayout { +export function isometricTaxiLayoutFactory( + indexedProcessTree: IndexedProcessTree +): IsometricTaxiLayout { /** * Walk the tree in reverse level order, calculating the 'width' of subtrees. */ @@ -83,8 +85,8 @@ export function isometricTaxiLayout(indexedProcessTree: IndexedProcessTree): Iso */ function ariaLevels(indexedProcessTree: IndexedProcessTree): Map { const map: Map = new Map(); - for (const node of model.levelOrder(indexedProcessTree)) { - const parentNode = model.parent(indexedProcessTree, node); + for (const node of indexedProcessTreeModel.levelOrder(indexedProcessTree)) { + const parentNode = indexedProcessTreeModel.parent(indexedProcessTree, node); if (parentNode === undefined) { // nodes at the root have a level of 1 map.set(node, 1); @@ -143,16 +145,19 @@ function ariaLevels(indexedProcessTree: IndexedProcessTree): Map(); - if (model.size(indexedProcessTree) === 0) { + if (indexedProcessTreeModel.size(indexedProcessTree) === 0) { return widths; } const processesInReverseLevelOrder: ResolverEvent[] = [ - ...model.levelOrder(indexedProcessTree), + ...indexedProcessTreeModel.levelOrder(indexedProcessTree), ].reverse(); for (const process of processesInReverseLevelOrder) { - const children = model.children(indexedProcessTree, uniquePidForProcess(process)); + const children = indexedProcessTreeModel.children( + indexedProcessTree, + uniquePidForProcess(process) + ); const sumOfWidthOfChildren = function sumOfWidthOfChildren() { return children.reduce(function sum(currentValue, child) { @@ -229,7 +234,10 @@ function processEdgeLineSegments( metadata: edgeLineMetadata, }; - const siblings = model.children(indexedProcessTree, uniquePidForProcess(parent)); + const siblings = indexedProcessTreeModel.children( + indexedProcessTree, + uniquePidForProcess(parent) + ); const isFirstChild = process === siblings[0]; if (metadata.isOnlyChild) { @@ -384,8 +392,8 @@ function* levelOrderWithWidths( tree: IndexedProcessTree, widths: ProcessWidths ): Iterable { - for (const process of model.levelOrder(tree)) { - const parent = model.parent(tree, process); + for (const process of indexedProcessTreeModel.levelOrder(tree)) { + const parent = indexedProcessTreeModel.parent(tree, process); const width = widths.get(process); if (width === undefined) { @@ -423,7 +431,7 @@ function* levelOrderWithWidths( parentWidth, }; - const siblings = model.children(tree, uniquePidForProcess(parent)); + const siblings = indexedProcessTreeModel.children(tree, uniquePidForProcess(parent)); if (siblings.length === 1) { metadata.isOnlyChild = true; metadata.lastChildWidth = width; @@ -479,3 +487,32 @@ const distanceBetweenNodesInUnits = 2; * The distance in pixels (at scale 1) between nodes. Change this to space out nodes more */ const distanceBetweenNodes = distanceBetweenNodesInUnits * unit; + +export function nodePosition(model: IsometricTaxiLayout, node: ResolverEvent): Vector2 | undefined { + return model.processNodePositions.get(node); +} + +/** + * Return a clone of `model` with all positions incremented by `translation`. + * Use this to move the layout around. + * e.g. + * ``` + * translated(layout, [100, -200]) // return a copy of `layout`, thats been moved 100 to the right and 200 up + * ``` + */ +export function translated(model: IsometricTaxiLayout, translation: Vector2): IsometricTaxiLayout { + return { + processNodePositions: new Map( + [...model.processNodePositions.entries()].map(([node, position]) => [ + node, + vector2.add(position, translation), + ]) + ), + edgeLineSegments: model.edgeLineSegments.map(({ points, metadata }) => ({ + points: points.map((point) => vector2.add(point, translation)), + metadata, + })), + // these are unchanged + ariaLevels: model.ariaLevels, + }; +} diff --git a/x-pack/plugins/security_solution/public/resolver/store/actions.ts b/x-pack/plugins/security_solution/public/resolver/store/actions.ts index ac11ab8c8868..418eb0d83727 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/actions.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/actions.ts @@ -69,12 +69,9 @@ interface AppDetectedMissingEventData { */ interface UserFocusedOnResolverNode { readonly type: 'userFocusedOnResolverNode'; - readonly payload: { - /** - * Used to identify the process node that the user focused on (in the DOM) - */ - readonly nodeId: string; - }; + + /** focused nodeID */ + readonly payload: string; } /** @@ -85,16 +82,10 @@ interface UserFocusedOnResolverNode { */ interface UserSelectedResolverNode { readonly type: 'userSelectedResolverNode'; - readonly payload: { - /** - * The HTML ID used to identify the process node's element that the user selected - */ - readonly nodeId: string; - /** - * The process entity_id for the process the node represents - */ - readonly selectedProcessId: string; - }; + /** + * The nodeID (aka entity_id) that was select. + */ + readonly payload: string; } /** 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 4098c6fc6c5d..40138d3f2fd3 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 @@ -30,8 +30,9 @@ import { ResolverRelatedEvents, } from '../../../../common/endpoint/types'; import * as resolverTreeModel from '../../models/resolver_tree'; -import { isometricTaxiLayout } from '../../models/indexed_process_tree/isometric_taxi_layout'; +import * as isometricTaxiLayoutModel from '../../models/indexed_process_tree/isometric_taxi_layout'; import { allEventCategories } from '../../../../common/endpoint/models/event'; +import * as vector2 from '../../models/vector2'; /** * If there is currently a request. @@ -70,6 +71,21 @@ const resolverTreeResponse = (state: DataState): ResolverTree | undefined => { } }; +/** + * the node ID of the node representing the databaseDocumentID. + * NB: this could be stale if the last response is stale + */ +export const originID: (state: DataState) => string | undefined = createSelector( + resolverTreeResponse, + function (resolverTree?) { + if (resolverTree) { + // This holds the entityID (aka nodeID) of the node related to the last fetched `_id` + return resolverTree.entityID; + } + return undefined; + } +); + /** * Process events that will be displayed as terminated. */ @@ -317,13 +333,45 @@ export function databaseDocumentIDToFetch(state: DataState): string | null { } } -export const layout = createSelector(tree, function processNodePositionsAndEdgeLineSegments( - /* eslint-disable no-shadow */ - indexedProcessTree - /* eslint-enable no-shadow */ -) { - return isometricTaxiLayout(indexedProcessTree); -}); +export const layout = createSelector( + tree, + originID, + function processNodePositionsAndEdgeLineSegments( + /* eslint-disable no-shadow */ + indexedProcessTree, + originID + /* eslint-enable no-shadow */ + ) { + // use the isometric taxi layout as a base + const taxiLayout = isometricTaxiLayoutModel.isometricTaxiLayoutFactory(indexedProcessTree); + + if (!originID) { + // no data has loaded. + return taxiLayout; + } + + // find the origin node + const originNode = indexedProcessTreeModel.processEvent(indexedProcessTree, originID); + + if (!originNode) { + // this should only happen if the `ResolverTree` from the server has an entity ID with no matching lifecycle events. + throw new Error('Origin node not found in ResolverTree'); + } + + // Find the position of the origin, we'll center the map on it intrinsically + const originPosition = isometricTaxiLayoutModel.nodePosition(taxiLayout, originNode); + // adjust the position of everything so that the origin node is at `(0, 0)` + + if (originPosition === undefined) { + // not sure how this could happen. + return taxiLayout; + } + + // Take the origin position, and multipy it by -1, then move the layout by that amount. + // This should center the layout around the origin. + return isometricTaxiLayoutModel.translated(taxiLayout, vector2.scale(originPosition, -1)); + } +); /** * Given a nodeID (aka entity_id) get the indexed process event. 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 fc4c4de5819f..028c28d94a41 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/reducer.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ import { Reducer, combineReducers } from 'redux'; -import { htmlIdGenerator } from '@elastic/eui'; import { animateProcessIntoView } from './methods'; import { cameraReducer } from './camera/reducer'; import { dataReducer } from './data/reducer'; @@ -12,51 +11,38 @@ import { ResolverAction } from './actions'; import { ResolverState, ResolverUIState } from '../types'; import { uniquePidForProcess } from '../models/process_event'; -/** - * Despite the name "generator", this function is entirely determinant - * (i.e. it will return the same html id given the same prefix 'resolverNode' - * and nodeId) - */ -const resolverNodeIdGenerator = htmlIdGenerator('resolverNode'); - const uiReducer: Reducer = ( - uiState = { - activeDescendantId: null, - selectedDescendantId: null, - processEntityIdOfSelectedDescendant: null, + state = { + ariaActiveDescendant: null, + selectedNode: null, }, action ) => { if (action.type === 'userFocusedOnResolverNode') { - return { - ...uiState, - activeDescendantId: action.payload.nodeId, + const next: ResolverUIState = { + ...state, + ariaActiveDescendant: action.payload, }; + return next; } else if (action.type === 'userSelectedResolverNode') { - return { - ...uiState, - selectedDescendantId: action.payload.nodeId, - processEntityIdOfSelectedDescendant: action.payload.selectedProcessId, + const next: ResolverUIState = { + ...state, + selectedNode: action.payload, }; + return next; } else if ( action.type === 'userBroughtProcessIntoView' || action.type === 'appDetectedNewIdFromQueryParams' ) { - /** - * This action has a process payload (instead of a processId), so we use - * `uniquePidForProcess` and `resolverNodeIdGenerator` to resolve the determinant - * html id of the node being brought into view. - */ - const processEntityId = uniquePidForProcess(action.payload.process); - const processNodeId = resolverNodeIdGenerator(processEntityId); - return { - ...uiState, - activeDescendantId: processNodeId, - selectedDescendantId: processNodeId, - processEntityIdOfSelectedDescendant: processEntityId, + const nodeID = uniquePidForProcess(action.payload.process); + const next: ResolverUIState = { + ...state, + ariaActiveDescendant: nodeID, + selectedNode: nodeID, }; + return next; } else { - return uiState; + return state; } }; diff --git a/x-pack/plugins/security_solution/public/resolver/store/selectors.ts b/x-pack/plugins/security_solution/public/resolver/store/selectors.ts index 09293d0b3b68..66d7e04d118e 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/selectors.ts @@ -144,26 +144,15 @@ export const relatedEventInfoByEntityId = composeSelectors( /** * Returns the id of the "current" tree node (fake-focused) */ -export const uiActiveDescendantId = composeSelectors( +export const ariaActiveDescendant = composeSelectors( uiStateSelector, - uiSelectors.activeDescendantId + uiSelectors.ariaActiveDescendant ); /** - * Returns the id of the "selected" tree node (the node that is currently "pressed" and possibly controlling other popups / components) + * Returns the nodeID of the selected node */ -export const uiSelectedDescendantId = composeSelectors( - uiStateSelector, - uiSelectors.selectedDescendantId -); - -/** - * Returns the entity_id of the "selected" tree node's process - */ -export const uiSelectedDescendantProcessId = composeSelectors( - uiStateSelector, - uiSelectors.selectedDescendantProcessId -); +export const selectedNode = composeSelectors(uiStateSelector, uiSelectors.selectedNode); /** * Returns the camera state from within ResolverState @@ -251,6 +240,14 @@ export const ariaLevel: ( dataSelectors.ariaLevel ); +/** + * the node ID of the node representing the databaseDocumentID + */ +export const originID: (state: ResolverState) => string | undefined = composeSelectors( + dataStateSelector, + dataSelectors.originID +); + /** * Takes a nodeID (aka entity_id) and returns the node ID of the node that aria should 'flowto' or null * If the node has a flowto candidate that is currently visible, that will be returned, otherwise null. diff --git a/x-pack/plugins/security_solution/public/resolver/store/ui/selectors.ts b/x-pack/plugins/security_solution/public/resolver/store/ui/selectors.ts index 494d8884329c..91a2cbecbc04 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/ui/selectors.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/ui/selectors.ts @@ -10,32 +10,21 @@ import { ResolverUIState } from '../../types'; /** * id of the "current" tree node (fake-focused) */ -export const activeDescendantId = createSelector( +export const ariaActiveDescendant = createSelector( (uiState: ResolverUIState) => uiState, /* eslint-disable no-shadow */ - ({ activeDescendantId }) => { - return activeDescendantId; + ({ ariaActiveDescendant }) => { + return ariaActiveDescendant; } ); /** * id of the currently "selected" tree node */ -export const selectedDescendantId = createSelector( +export const selectedNode = createSelector( (uiState: ResolverUIState) => uiState, /* eslint-disable no-shadow */ - ({ selectedDescendantId }) => { - return selectedDescendantId; - } -); - -/** - * id of the currently "selected" tree node - */ -export const selectedDescendantProcessId = createSelector( - (uiState: ResolverUIState) => uiState, - /* eslint-disable no-shadow */ - ({ processEntityIdOfSelectedDescendant }: ResolverUIState) => { - return processEntityIdOfSelectedDescendant; + ({ selectedNode }: ResolverUIState) => { + return selectedNode; } ); diff --git a/x-pack/plugins/security_solution/public/resolver/types.ts b/x-pack/plugins/security_solution/public/resolver/types.ts index 0272de0d8fd2..856ae2d6240e 100644 --- a/x-pack/plugins/security_solution/public/resolver/types.ts +++ b/x-pack/plugins/security_solution/public/resolver/types.ts @@ -34,17 +34,13 @@ export interface ResolverState { */ export interface ResolverUIState { /** - * The ID attribute of the resolver's aria-activedescendent. + * The nodeID for the process that is selected (in the aria-activedescendent sense of being selected.) */ - readonly activeDescendantId: string | null; + readonly ariaActiveDescendant: string | null; /** - * The ID attribute of the resolver's currently selected descendant. + * nodeID of the selected node */ - readonly selectedDescendantId: string | null; - /** - * The entity_id of the process for the resolver's currently selected descendant. - */ - readonly processEntityIdOfSelectedDescendant: string | null; + readonly selectedNode: string | null; } /** diff --git a/x-pack/plugins/security_solution/public/resolver/view/assets.tsx b/x-pack/plugins/security_solution/public/resolver/view/assets.tsx index 42f9634238e6..fc4a9daf17ad 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/assets.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/assets.tsx @@ -422,7 +422,7 @@ const processTypeToCube: Record = { export const useResolverTheme = (): { colorMap: ColorMap; nodeAssets: NodeStyleMap; - cubeAssetsForNode: (isProcessTerimnated: boolean, isProcessOrigin: boolean) => NodeStyleConfig; + cubeAssetsForNode: (isProcessTerimnated: boolean, isProcessTrigger: boolean) => NodeStyleConfig; } => { const isDarkMode = useUiSetting(DEFAULT_DARK_MODE); const theme = isDarkMode ? euiThemeAmsterdamDark : euiThemeAmsterdamLight; @@ -497,10 +497,14 @@ export const useResolverTheme = (): { }, }; - function cubeAssetsForNode(isProcessTerminated: boolean, isProcessOrigin: boolean) { + function cubeAssetsForNode(isProcessTerminated: boolean, isProcessTrigger: boolean) { if (isProcessTerminated) { - return nodeAssets[processTypeToCube.processTerminated]; - } else if (isProcessOrigin) { + if (isProcessTrigger) { + return nodeAssets.terminatedTriggerCube; + } else { + return nodeAssets[processTypeToCube.processTerminated]; + } + } else if (isProcessTrigger) { return nodeAssets[processTypeToCube.processCausedAlert]; } else { return nodeAssets[processTypeToCube.processRan]; 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 930e96c3f3e4..69ff9c8e2351 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/map.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/map.tsx @@ -64,7 +64,7 @@ export const ResolverMap = React.memo(function ({ const { projectionMatrix, ref, onMouseDown } = useCamera(); const isLoading = useSelector(selectors.isLoading); const hasError = useSelector(selectors.hasError); - const activeDescendantId = useSelector(selectors.uiActiveDescendantId); + const activeDescendantId = useSelector(selectors.ariaActiveDescendant); const { colorMap } = useResolverTheme(); return ( @@ -110,7 +110,6 @@ export const ResolverMap = React.memo(function ({ projectionMatrix={projectionMatrix} event={processEvent} isProcessTerminated={terminatedProcesses.has(processEntityId)} - isProcessOrigin={false} timeAtRender={timeAtRender} /> ); diff --git a/x-pack/plugins/security_solution/public/resolver/view/panel.tsx b/x-pack/plugins/security_solution/public/resolver/view/panel.tsx index efb2d95396ef..cb0acdc29ceb 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panel.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panel.tsx @@ -60,10 +60,10 @@ const PanelContent = memo(function PanelContent() { // The "selected" node (and its corresponding event) in the tree control. // It may need to be synchronized with the ID indicated as selected via the `idFromParams` // memo above. When this is the case, it is handled by the layout effect below. - const selectedDescendantProcessId = useSelector(selectors.uiSelectedDescendantProcessId); + const selectedNode = useSelector(selectors.selectedNode); const uiSelectedEvent = useMemo(() => { - return graphableProcesses.find((evt) => event.entityId(evt) === selectedDescendantProcessId); - }, [graphableProcesses, selectedDescendantProcessId]); + return graphableProcesses.find((evt) => event.entityId(evt) === selectedNode); + }, [graphableProcesses, selectedNode]); // Until an event is dispatched during update, the event indicated as selected by params may // be different than the one in state. 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 1f4952f15119..05f2e0cbfcfa 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 @@ -72,7 +72,6 @@ const UnstyledProcessEventDot = React.memo( event, projectionMatrix, isProcessTerminated, - isProcessOrigin, timeAtRender, }: { /** @@ -95,10 +94,6 @@ const UnstyledProcessEventDot = React.memo( * Whether or not to show the process as terminated. */ isProcessTerminated: boolean; - /** - * Whether or not to show the process as the originating event. - */ - isProcessOrigin: boolean; /** * The time (unix epoch) at render. @@ -117,8 +112,8 @@ const UnstyledProcessEventDot = React.memo( const [xScale] = projectionMatrix; // Node (html id=) IDs - const activeDescendantId = useSelector(selectors.uiActiveDescendantId); - const selectedDescendantId = useSelector(selectors.uiSelectedDescendantId); + const ariaActiveDescendant = useSelector(selectors.ariaActiveDescendant); + const selectedNode = useSelector(selectors.selectedNode); const nodeID = processEventModel.uniquePidForProcess(event); const relatedEventStats = useSelector(selectors.relatedEventsStats)(nodeID); @@ -212,23 +207,26 @@ const UnstyledProcessEventDot = React.memo( isLabelFilled, labelButtonFill, strokeColor, - } = cubeAssetsForNode(isProcessTerminated, isProcessOrigin); + } = cubeAssetsForNode( + isProcessTerminated, + /** + * There is no definition for 'trigger process' yet. return false. + */ false + ); const labelHTMLID = htmlIdGenerator('resolver')(`${nodeID}:label`); - const isAriaCurrent = nodeID === activeDescendantId; - const isAriaSelected = nodeID === selectedDescendantId; + const isAriaCurrent = nodeID === ariaActiveDescendant; + const isAriaSelected = nodeID === selectedNode; const dispatch = useResolverDispatch(); const handleFocus = useCallback(() => { dispatch({ type: 'userFocusedOnResolverNode', - payload: { - nodeId: nodeHTMLID(nodeID), - }, + payload: nodeID, }); - }, [dispatch, nodeHTMLID, nodeID]); + }, [dispatch, nodeID]); const handleRelatedEventRequest = useCallback(() => { dispatch({ @@ -247,13 +245,10 @@ const UnstyledProcessEventDot = React.memo( } dispatch({ type: 'userSelectedResolverNode', - payload: { - nodeId: nodeHTMLID(nodeID), - selectedProcessId: nodeID, - }, + payload: nodeID, }); pushToQueryParams({ crumbId: nodeID, crumbEvent: '' }); - }, [animationTarget, dispatch, pushToQueryParams, nodeID, nodeHTMLID]); + }, [animationTarget, dispatch, pushToQueryParams, nodeID]); /** * Enumerates the stats for related events to display with the node as options, @@ -422,6 +417,7 @@ const UnstyledProcessEventDot = React.memo( buttonFill={colorMap.resolverBackground} menuAction={handleRelatedEventRequest} menuTitle={subMenuAssets.relatedEvents.title} + projectionMatrix={projectionMatrix} optionsWithActions={relatedEventStatusOrOptions} /> )} 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 ce126bf69555..2499a451b9c8 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/submenu.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/submenu.tsx @@ -5,7 +5,7 @@ */ import { i18n } from '@kbn/i18n'; -import React, { ReactNode, useState, useMemo, useCallback } from 'react'; +import React, { ReactNode, useState, useMemo, useCallback, useRef, useLayoutEffect } from 'react'; import { EuiI18nNumber, EuiSelectable, @@ -15,6 +15,7 @@ import { htmlIdGenerator, } from '@elastic/eui'; import styled from 'styled-components'; +import { Matrix3 } from '../types'; /** * i18n-translated titles for submenus and identifiers for display of states: @@ -133,6 +134,7 @@ const NodeSubMenuComponents = React.memo( menuAction, optionsWithActions, className, + projectionMatrix, }: { menuTitle: string; className?: string; @@ -140,9 +142,16 @@ const NodeSubMenuComponents = React.memo( buttonBorderColor: ButtonColor; buttonFill: string; count?: number; + /** + * Receive the projection matrix, so we can see when the camera position changed, so we can force the submenu to reposition itself. + */ + projectionMatrix: Matrix3; } & { optionsWithActions?: ResolverSubmenuOptionList | string | undefined; }) => { + // keep a ref to the popover so we can call its reposition method + const popoverRef = useRef(null); + const [menuIsOpen, setMenuOpen] = useState(false); const handleMenuOpenClick = useCallback( (clickEvent: React.MouseEvent) => { @@ -169,6 +178,28 @@ const NodeSubMenuComponents = React.memo( const isMenuLoading = optionsWithActions === 'waitingForRelatedEventData'; + // The last projection matrix that was used to position the popover + const projectionMatrixAtLastRender = useRef(); + + useLayoutEffect(() => { + if ( + /** + * If there is a popover component reference, + * and this isn't the first render, + * and the projectionMatrix has changed since last render, + * then force the popover to reposition itself. + */ + popoverRef.current && + !projectionMatrixAtLastRender.current && + projectionMatrixAtLastRender.current !== projectionMatrix + ) { + popoverRef.current.positionPopoverFixed(); + } + + // no matter what, keep track of the last project matrix that was used to size the popover + projectionMatrixAtLastRender.current = projectionMatrix; + }, [projectionMatrixAtLastRender, projectionMatrix]); + if (!optionsWithActions) { /** * When called with a `menuAction` @@ -216,6 +247,7 @@ const NodeSubMenuComponents = React.memo( isOpen={menuIsOpen} closePopover={closePopover} repositionOnScroll + ref={popoverRef} > {menuIsOpen && typeof optionsWithActions === 'object' && ( From b3f15953316f63ac986f8e75369b75c2d4817042 Mon Sep 17 00:00:00 2001 From: Dima Arnautov Date: Wed, 22 Jul 2020 01:03:27 +0200 Subject: [PATCH 09/23] [ML] do not throw an error when agg is not supported by UI (#72685) --- x-pack/plugins/transform/public/app/common/pivot_aggs.test.ts | 4 ---- x-pack/plugins/transform/public/app/common/pivot_aggs.ts | 4 ++-- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/x-pack/plugins/transform/public/app/common/pivot_aggs.test.ts b/x-pack/plugins/transform/public/app/common/pivot_aggs.test.ts index 9e748068742f..48a0951e906e 100644 --- a/x-pack/plugins/transform/public/app/common/pivot_aggs.test.ts +++ b/x-pack/plugins/transform/public/app/common/pivot_aggs.test.ts @@ -11,10 +11,6 @@ import { } from '../sections/create_transform/components/step_define/common/filter_agg/components'; describe('getAggConfigFromEsAgg', () => { - test('should throw an error for unsupported agg', () => { - expect(() => getAggConfigFromEsAgg({ terms: {} }, 'test')).toThrowError(); - }); - test('should return a common config if the agg does not have a custom config defined', () => { expect(getAggConfigFromEsAgg({ avg: { field: 'region' } }, 'test_1')).toEqual({ agg: 'avg', diff --git a/x-pack/plugins/transform/public/app/common/pivot_aggs.ts b/x-pack/plugins/transform/public/app/common/pivot_aggs.ts index 54dfd9ecda7b..ec52de4b9da9 100644 --- a/x-pack/plugins/transform/public/app/common/pivot_aggs.ts +++ b/x-pack/plugins/transform/public/app/common/pivot_aggs.ts @@ -110,8 +110,8 @@ export function getAggConfigFromEsAgg( // Find the main aggregation key const agg = aggKeys.find((aggKey) => aggKey !== 'aggs'); - if (!isPivotSupportedAggs(agg)) { - throw new Error(`Aggregation "${agg}" is not supported`); + if (agg === undefined) { + throw new Error(`Aggregation key is required`); } const commonConfig: PivotAggsConfigBase = { From a4957e65c2102b0710ab6672f7590f8925cc43fe Mon Sep 17 00:00:00 2001 From: Clint Andrew Hall Date: Tue, 21 Jul 2020 19:18:57 -0400 Subject: [PATCH 10/23] [pre-req] New Component Layout proposal (#72385) * New Component Layout proposal * Add contribution guidelines; remove dead i18n * Re-adding i18n... ugh * Fix i18n files to reflect changes * Addressing feedback * Fix merge issue Co-authored-by: Elastic Machine --- x-pack/plugins/canvas/CONTRIBUTING.md | 139 +++++ x-pack/plugins/canvas/i18n/components.ts | 25 +- .../__snapshots__/asset.stories.storyshot | 178 ++++++ .../asset_manager.stories.storyshot | 539 ++++++++++++++++++ .../__examples__/asset.stories.tsx | 46 -- .../__examples__/asset_manager.stories.tsx | 52 +- .../asset_manager/__examples__/provider.tsx | 110 ++++ .../asset_manager/asset.component.tsx | 147 +++++ .../public/components/asset_manager/asset.tsx | 177 ++---- ..._modal.tsx => asset_manager.component.tsx} | 76 +-- .../components/asset_manager/asset_manager.ts | 70 +++ .../asset_manager/asset_manager.tsx | 111 ---- .../public/components/asset_manager/index.ts | 108 +--- .../canvas/storybook/storyshots.test.js | 1 + .../canvas/storybook/webpack.config.js | 17 + .../translations/translations/ja-JP.json | 6 +- .../translations/translations/zh-CN.json | 6 +- 17 files changed, 1334 insertions(+), 474 deletions(-) create mode 100644 x-pack/plugins/canvas/CONTRIBUTING.md delete mode 100644 x-pack/plugins/canvas/public/components/asset_manager/__examples__/asset.stories.tsx create mode 100644 x-pack/plugins/canvas/public/components/asset_manager/__examples__/provider.tsx create mode 100644 x-pack/plugins/canvas/public/components/asset_manager/asset.component.tsx rename x-pack/plugins/canvas/public/components/asset_manager/{asset_modal.tsx => asset_manager.component.tsx} (69%) create mode 100644 x-pack/plugins/canvas/public/components/asset_manager/asset_manager.ts delete mode 100644 x-pack/plugins/canvas/public/components/asset_manager/asset_manager.tsx diff --git a/x-pack/plugins/canvas/CONTRIBUTING.md b/x-pack/plugins/canvas/CONTRIBUTING.md new file mode 100644 index 000000000000..538cc1592c3b --- /dev/null +++ b/x-pack/plugins/canvas/CONTRIBUTING.md @@ -0,0 +1,139 @@ +# Contributing to Canvas + +Canvas is a plugin for Kibana, therefore its [contribution guidelines](../../../CONTRIBUTING.md) apply to Canvas development, as well. This document contains Canvas-specific guidelines that extend from the Kibana guidelines. + +- [Active Migrations](#active_migrations) +- [i18n](#i18n) +- [Component Code Structure](#component_code_structure) +- [Storybook](#storybook) + +## Active Migrations + +When editing code in Canvas, be aware of the following active migrations, (generally, taken when a file is touched): + +- Convert file(s) to Typescript. +- Convert React classes to Functional components, (where applicable). +- Add Storybook stories for components, (and thus Storyshots). +- Remove `recompose` in favor of React hooks. +- Apply improved component structure. +- Write tests. + +## i18n + +i18n syntax in Kibana can be a bit verbose in code: + +```js + i18n('pluginNamespace.messageId', { + defaultMessage: 'Default message string literal, {key}', + values: { + key: 'value', + }, + description: 'Message context or description', + }); +``` + +To keep the code terse, Canvas uses i18n "dictionaries": abstracted, static singletons of accessor methods which return a given string: + +```js + +// i18n/components.ts +export const ComponentStrings = { + // ... + AssetManager: { + getCopyAssetMessage: (id: string) => + i18n.translate('xpack.canvas.assetModal.copyAssetMessage', { + defaultMessage: `Copied '{id}' to clipboard`, + values: { + id, + }, + }), + // ... + }, + // ... +}; + +// asset_manager.tsx +import { ComponentStrings } from '../../../i18n'; +const { AssetManager: strings } = ComponentStrings; + +const text = ( + + {strings.getSpaceUsedText(percentageUsed)} + +); + +``` + +These singletons can then be changed at will, as well as audited for unused methods, (and therefore unused i18n strings). + +## Component Code Structure + +Canvas uses Redux. Component code is divided into React components and Redux containers. This way, components can be reused, their containers can be edited, and both can be tested independently. + +Canvas is actively migrating to a structure which uses the `index.ts` file as a thin exporting index, rather than functional code: + +``` +- components + - foo <- directory representing the component + - foo.ts <- redux container + - foo.component.tsx <- react component + - foo.scss + - index.ts <- thin exporting index, (no redux) + - bar <- directory representing the component + - bar.ts + - bar.component.tsx + - bar.scss + - bar_dep.ts <- redux sub container + - bar_dep.component.tsx <- sub component + - index.ts +``` + +The exporting file would be: + +``` +export { Foo } from './foo'; +export { Foo as FooComponent } from './foo.component'; +``` + +### Why? + +Canvas has been using an "index-export" structure that has served it well, until recently. In this structure, the `index.ts` file serves as the primary export of the Redux component, (and holds that code). The component is then named-- `component.tsx`-- and consumed in the `index` file. + +The problem we've run into is when you have sub-components which are also connected to Redux. To maintain this structure, each sub-component and its Redux container would then be stored in a subdirectory, (with only two files in it). + +> NOTE: if a PR touches component code that is in the older structure, it should be migrated to the structure above. + +## Storybook + +Canvas uses [Storybook](https://storybook.js.org) to test and develop components. This has a number of benefits: + +- Developing components without needing to start ES + Kibana. +- Testing components interactively without starting ES + Kibana. +- Automatic Storyshot integration with Jest + +### Using Storybook + +The Canvas Storybook instance can be started by running `node scripts/storybook` from the Canvas root directory. It has a number of options: + +``` +node scripts/storybook + + Storybook runner for Canvas. + + Options: + --clean Forces a clean of the Storybook DLL and exits. + --dll Cleans and builds the Storybook dependency DLL and exits. + --stats Produces a Webpack stats file. + --site Produces a site deployment of this Storybook. + --verbose, -v Log verbosely + --debug Log debug messages (less than verbose) + --quiet Only log errors + --silent Don't log anything + --help Show this message +``` + +### What about `kbn-storybook`? + +Canvas wants to move to the Kibana Storybook instance as soon as feasible. There are few tweaks Canvas makes to Storybook, so we're actively working with the maintainers to make that migration successful. + +In the meantime, people can test our progress by running `node scripts/storybook_new` from the Canvas root. diff --git a/x-pack/plugins/canvas/i18n/components.ts b/x-pack/plugins/canvas/i18n/components.ts index acc55d50ae19..9b1d60f38eb5 100644 --- a/x-pack/plugins/canvas/i18n/components.ts +++ b/x-pack/plugins/canvas/i18n/components.ts @@ -110,26 +110,24 @@ export const ComponentStrings = { i18n.translate('xpack.canvas.asset.thumbnailAltText', { defaultMessage: 'Asset thumbnail', }), - }, - AssetManager: { - getButtonLabel: () => - i18n.translate('xpack.canvas.assetManager.manageButtonLabel', { - defaultMessage: 'Manage assets', - }), getConfirmModalButtonLabel: () => - i18n.translate('xpack.canvas.assetManager.confirmModalButtonLabel', { + i18n.translate('xpack.canvas.asset.confirmModalButtonLabel', { defaultMessage: 'Remove', }), getConfirmModalMessageText: () => - i18n.translate('xpack.canvas.assetManager.confirmModalDetail', { + i18n.translate('xpack.canvas.asset.confirmModalDetail', { defaultMessage: 'Are you sure you want to remove this asset?', }), getConfirmModalTitle: () => - i18n.translate('xpack.canvas.assetManager.confirmModalTitle', { + i18n.translate('xpack.canvas.asset.confirmModalTitle', { defaultMessage: 'Remove Asset', }), }, - AssetModal: { + AssetManager: { + getButtonLabel: () => + i18n.translate('xpack.canvas.assetManager.manageButtonLabel', { + defaultMessage: 'Manage assets', + }), getDescription: () => i18n.translate('xpack.canvas.assetModal.modalDescription', { defaultMessage: @@ -162,6 +160,13 @@ export const ComponentStrings = { percentageUsed, }, }), + getCopyAssetMessage: (id: string) => + i18n.translate('xpack.canvas.assetModal.copyAssetMessage', { + defaultMessage: `Copied '{id}' to clipboard`, + values: { + id, + }, + }), }, AssetPicker: { getAssetAltText: () => diff --git a/x-pack/plugins/canvas/public/components/asset_manager/__examples__/__snapshots__/asset.stories.storyshot b/x-pack/plugins/canvas/public/components/asset_manager/__examples__/__snapshots__/asset.stories.storyshot index 14791cd3d8b2..87205b363e69 100644 --- a/x-pack/plugins/canvas/public/components/asset_manager/__examples__/__snapshots__/asset.stories.storyshot +++ b/x-pack/plugins/canvas/public/components/asset_manager/__examples__/__snapshots__/asset.stories.storyshot @@ -355,3 +355,181 @@ exports[`Storyshots components/Assets/Asset marker 1`] = ` `; + +exports[`Storyshots components/Assets/Asset redux 1`] = ` +
+
+
+
+
+ Asset thumbnail +
+
+
+
+

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

+
+
+
+
+ + + +
+
+ +
+ +
+
+
+
+ +
+ +
+
+
+
+ + + +
+
+
+
+
+`; 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 11/23] [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 12/23] [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 13/23] [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 14/23] [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 15/23] [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 16/23] [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 17/23] [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 18/23] 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 19/23] [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 20/23] 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 21/23] 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 22/23] 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 23/23] 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 + } } }