From 9b6ff9c258bef33dea205f6f17df2fc65e0f1268 Mon Sep 17 00:00:00 2001 From: Andrew Goldstein Date: Fri, 28 Jun 2019 12:12:32 -0600 Subject: [PATCH] # [SIEM] Toggle Column / Code Coverage and Cypress ## Features - A `Toggle column` checkbox for adding / removing fields in the timeline offers an alternative to drag and drop in an expanded event: ![toggle-field](https://user-images.githubusercontent.com/4459398/62572146-4d94c280-b850-11e9-8ff9-5feaca3f305d.gif) The behavior of the `Toggle field` checkbox in the expanded event is the same as the checkboxes that appear in the timeline _Fields Browser_. ## Fixes - Pinned timeline events now use a "filled" styling when pinned instead of rotation, and unpinned events have a higher contrast in dark mode: Before: ![unpinned-before](https://user-images.githubusercontent.com/4459398/62572175-5b4a4800-b850-11e9-9d4f-317d5abad002.png) After (unpinned): ![after-unpinned](https://user-images.githubusercontent.com/4459398/62908225-28aebc80-bd34-11e9-9237-8a98c88a6da1.png) After (pinned): ![after-pinned](https://user-images.githubusercontent.com/4459398/62908230-306e6100-bd34-11e9-9830-45d725bac5b1.png) - Fixed an issue where the `Copy to Clipboard` icon rendered incorrectly (too small, and not the right color): Before: ![copy-to-clipboard-before](https://user-images.githubusercontent.com/4459398/62572224-7452f900-b850-11e9-962c-eef0b96202ff.png) After: ![copy-to-clipboard-after](https://user-images.githubusercontent.com/4459398/62572240-7f0d8e00-b850-11e9-8b89-7c14bf92e705.png) - Fixed an issue where a previously invisible control became visible and occupied additonal row space in the timeline: Before: ![row-height-before](https://user-images.githubusercontent.com/4459398/62572265-8e8cd700-b850-11e9-9c36-1d74ba0ba6f5.png) After: ![row-height-after](https://user-images.githubusercontent.com/4459398/62572285-977da880-b850-11e9-8448-7085bbac8da4.png) - Fixed an issue where the timeline Fields Browser Overlaps Column Headers Before: ![field-browser-position-before](https://user-images.githubusercontent.com/4459398/62572309-a5332e00-b850-11e9-9b6f-38700d2f48a0.png) After: ![field-browser-position-after](https://user-images.githubusercontent.com/4459398/62572335-b845fe00-b850-11e9-84f8-4b273f7ef163.png) ## Code Coverage and Cypress Tests - This PR increases the `jest` unit test coverage for the _Fields Browser_, and introduces `Cypress` tests for it - Added new `Cypress` test helpers, configuration, and test refactorings to support running smoke tests against remote Kibana instances, including [Elastic Cloud](https://www.elastic.co/cloud) - Refactored some `Cypress` tests to speed them up and reduce flakyness https://github.com/elastic/siem-team/issues/380 https://github.com/elastic/siem-team/issues/431 https://github.com/elastic/siem-team/issues/432 https://github.com/elastic/siem-team/issues/433 https://github.com/elastic/siem-team/issues/434 https://github.com/elastic/siem-team/issues/435 --- x-pack/legacy/plugins/siem/cypress.json | 4 +- x-pack/legacy/plugins/siem/cypress/README.md | 9 + .../integration/lib/fields_browser/helpers.ts | 40 ++ .../lib/fields_browser/selectors.ts | 35 ++ .../cypress/integration/lib/hosts/helpers.ts | 7 +- .../cypress/integration/lib/login/helpers.ts | 125 +++-- .../cypress/integration/lib/logout/index.ts | 6 +- .../integration/lib/navigation/selectors.ts | 8 +- .../integration/lib/timeline/helpers.ts | 42 +- .../integration/lib/timeline/selectors.ts | 10 + .../cypress/integration/lib/urls/index.ts | 13 +- .../cypress/integration/lib/util/helpers.ts | 7 +- .../fields_browser/fields_browser.spec.ts | 232 ++++++++++ .../smoke_tests/navigation/navigation.spec.ts | 6 +- .../smoke_tests/overview/overview.spec.ts | 8 +- ...ta_providers.ts => data_providers.spec.ts} | 11 +- .../timeline/search_or_filter.spec.ts | 33 ++ .../timeline/toggle_column.spec.ts | 77 ++++ .../edit_data_provider/helpers.test.tsx | 295 ++++++++++++ .../edit_data_provider/index.test.tsx | 428 ++++++++++++++++++ .../components/edit_data_provider/index.tsx | 3 + .../__snapshots__/event_details.test.tsx.snap | 113 +++++ .../components/event_details/columns.tsx | 125 +++-- .../event_details/event_details.test.tsx | 7 + .../event_details/event_details.tsx | 18 +- .../event_fields_browser.test.tsx | 106 ++++- .../event_details/event_fields_browser.tsx | 18 +- .../event_details/stateful_event_details.tsx | 16 +- .../components/event_details/translations.ts | 4 + .../fields_browser/categories_pane.test.tsx | 64 +++ .../fields_browser/categories_pane.tsx | 2 +- .../fields_browser/category.test.tsx | 121 +++++ .../fields_browser/category_buttons.tsx | 42 -- .../fields_browser/category_columns.test.tsx | 172 +++++++ .../fields_browser/category_columns.tsx | 3 +- .../fields_browser/category_title.test.tsx | 72 +++ .../fields_browser/category_title.tsx | 6 +- .../fields_browser/field_browser.test.tsx | 259 +++++++++++ .../fields_browser/field_browser.tsx | 3 +- .../fields_browser/field_items.test.tsx | 303 +++++++++++++ .../components/fields_browser/field_items.tsx | 43 +- .../fields_browser/field_name.test.tsx | 152 +++++++ .../components/fields_browser/field_name.tsx | 11 +- .../fields_browser/fields_pane.test.tsx | 134 ++++++ .../components/fields_browser/header.test.tsx | 298 ++++++++++++ .../components/fields_browser/header.tsx | 4 +- .../fields_browser/helpers.test.tsx | 360 +++++++++++++++ .../components/fields_browser/helpers.tsx | 2 +- .../components/fields_browser/index.test.tsx | 124 +++++ .../components/fields_browser/index.tsx | 23 +- .../components/fields_browser/translations.ts | 4 + .../public/components/fields_browser/types.ts | 2 + .../public/components/page/manage_query.tsx | 2 +- .../siem/public/components/pin/index.test.tsx | 10 +- .../siem/public/components/pin/index.tsx | 33 +- .../__snapshots__/timeline.test.tsx.snap | 1 + .../timeline/body/actions/index.test.tsx | 2 +- .../timeline/body/actions/index.tsx | 4 + .../__snapshots__/index.test.tsx.snap | 1 + .../body/column_headers/header/index.test.tsx | 4 +- .../body/column_headers/header/index.tsx | 2 +- .../body/column_headers/index.test.tsx | 4 + .../timeline/body/column_headers/index.tsx | 3 + .../components/timeline/body/events/index.tsx | 3 + .../timeline/body/events/stateful_event.tsx | 4 + .../components/timeline/body/index.test.tsx | 4 + .../public/components/timeline/body/index.tsx | 4 + .../timeline/body/stateful_body.tsx | 3 + .../timeline/expandable_event/index.tsx | 49 +- .../components/timeline/footer/index.tsx | 5 +- .../siem/public/components/timeline/index.tsx | 32 ++ .../timeline/properties/helpers.tsx | 14 +- .../components/timeline/timeline.test.tsx | 12 + .../public/components/timeline/timeline.tsx | 3 + .../siem/public/lib/clipboard/clipboard.tsx | 5 +- .../lib/clipboard/with_copy_to_clipboard.tsx | 10 +- .../lib/events/elasticsearch_adapter.ts | 2 +- 77 files changed, 3939 insertions(+), 287 deletions(-) create mode 100644 x-pack/legacy/plugins/siem/cypress/integration/lib/fields_browser/helpers.ts create mode 100644 x-pack/legacy/plugins/siem/cypress/integration/lib/fields_browser/selectors.ts create mode 100644 x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/fields_browser/fields_browser.spec.ts rename x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/{data_providers.ts => data_providers.spec.ts} (82%) create mode 100644 x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/search_or_filter.spec.ts create mode 100644 x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/toggle_column.spec.ts create mode 100644 x-pack/legacy/plugins/siem/public/components/edit_data_provider/helpers.test.tsx create mode 100644 x-pack/legacy/plugins/siem/public/components/edit_data_provider/index.test.tsx create mode 100644 x-pack/legacy/plugins/siem/public/components/fields_browser/categories_pane.test.tsx create mode 100644 x-pack/legacy/plugins/siem/public/components/fields_browser/category.test.tsx delete mode 100644 x-pack/legacy/plugins/siem/public/components/fields_browser/category_buttons.tsx create mode 100644 x-pack/legacy/plugins/siem/public/components/fields_browser/category_columns.test.tsx create mode 100644 x-pack/legacy/plugins/siem/public/components/fields_browser/category_title.test.tsx create mode 100644 x-pack/legacy/plugins/siem/public/components/fields_browser/field_browser.test.tsx create mode 100644 x-pack/legacy/plugins/siem/public/components/fields_browser/field_items.test.tsx create mode 100644 x-pack/legacy/plugins/siem/public/components/fields_browser/field_name.test.tsx create mode 100644 x-pack/legacy/plugins/siem/public/components/fields_browser/fields_pane.test.tsx create mode 100644 x-pack/legacy/plugins/siem/public/components/fields_browser/header.test.tsx create mode 100644 x-pack/legacy/plugins/siem/public/components/fields_browser/helpers.test.tsx create mode 100644 x-pack/legacy/plugins/siem/public/components/fields_browser/index.test.tsx diff --git a/x-pack/legacy/plugins/siem/cypress.json b/x-pack/legacy/plugins/siem/cypress.json index 0967ef424bce6..e5f7bdd0f7875 100644 --- a/x-pack/legacy/plugins/siem/cypress.json +++ b/x-pack/legacy/plugins/siem/cypress.json @@ -1 +1,3 @@ -{} +{ + "baseUrl": "http://localhost:5601" +} diff --git a/x-pack/legacy/plugins/siem/cypress/README.md b/x-pack/legacy/plugins/siem/cypress/README.md index 4994ae5f12c9f..257c4f348c495 100644 --- a/x-pack/legacy/plugins/siem/cypress/README.md +++ b/x-pack/legacy/plugins/siem/cypress/README.md @@ -39,6 +39,15 @@ The `username` and `password` from `config/kibana.dev.yml` will be read by the ` See the `Running Tests Interactively` section for details. +### Content Security Policy (CSP) Settings + + Your local or cloud Kibana server must have the `csp.strict: false` setting +configured in `kibana.dev.yml`, or `kibana.yml`, as shown in the example below: + + ```yaml +csp.strict: false +``` + ## Running Tests Interactively To run tests in interactively via the Cypress test runner: diff --git a/x-pack/legacy/plugins/siem/cypress/integration/lib/fields_browser/helpers.ts b/x-pack/legacy/plugins/siem/cypress/integration/lib/fields_browser/helpers.ts new file mode 100644 index 0000000000000..29ed841ee179c --- /dev/null +++ b/x-pack/legacy/plugins/siem/cypress/integration/lib/fields_browser/helpers.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 { FIELDS_BROWSER_CONTAINER, FIELDS_BROWSER_FILTER_INPUT, FIELDS_BUTTON } from './selectors'; +import { + assertAtLeastOneEventMatchesSearch, + executeKQL, + hostExistsQuery, + toggleTimelineVisibility, +} from '../timeline/helpers'; +import { TIMELINE_DATA_PROVIDERS } from '../timeline/selectors'; + +/** Opens the timeline's Field Browser */ +export const openFieldsBrowser = () => { + cy.get(FIELDS_BUTTON).click(); + + cy.get(FIELDS_BROWSER_CONTAINER).should('exist'); +}; + +/** Populates the timeline with a host from the hosts page */ +export const populateTimeline = () => { + toggleTimelineVisibility(); + + executeKQL(hostExistsQuery); + + assertAtLeastOneEventMatchesSearch(); +}; + +/** Clicks an arbitrary UI element that's not part of the fields browser (to dismiss it) */ +export const clickOutsideFieldsBrowser = () => { + cy.get(TIMELINE_DATA_PROVIDERS).click(); +}; + +/** Filters the Field Browser by typing `fieldName` in the input */ +export const filterFieldsBrowser = (fieldName: string) => { + cy.get(FIELDS_BROWSER_FILTER_INPUT).type(fieldName); +}; diff --git a/x-pack/legacy/plugins/siem/cypress/integration/lib/fields_browser/selectors.ts b/x-pack/legacy/plugins/siem/cypress/integration/lib/fields_browser/selectors.ts new file mode 100644 index 0000000000000..4a130773746de --- /dev/null +++ b/x-pack/legacy/plugins/siem/cypress/integration/lib/fields_browser/selectors.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/** Clicking this button in the timeline opens the Fields browser */ +export const FIELDS_BUTTON = '[data-test-subj="show-field-browser"]'; + +/** The title displayed in the fields browser (i.e. Customize Columns) */ +export const FIELDS_BROWSER_TITLE = '[data-test-subj="field-browser-title"]'; + +/** Contains the body of the fields browser */ +export const FIELDS_BROWSER_CONTAINER = '[data-test-subj="fields-browser-container"]'; + +/** The title of the selected category in the right-hand side of the fields browser */ +export const FIELDS_BROWSER_SELECTED_CATEGORY_TITLE = '[data-test-subj="selected-category-title"]'; + +/** A count of the fields in the selected category in the right-hand side of the fields browser */ +export const FIELDS_BROWSER_SELECTED_CATEGORY_COUNT = + '[data-test-subj="selected-category-count-badge"]'; + +/** Typing in this input filters the Field Browser */ +export const FIELDS_BROWSER_FILTER_INPUT = '[data-test-subj="field-search"]'; + +/** + * This label displays a count of the categories containing (one or more) + * fields that match the filter criteria + */ +export const FIELDS_BROWSER_CATEGORIES_COUNT = '[data-test-subj="categories-count"]'; + +/** + * This label displays a count of the fields that match the filter criteria + */ +export const FIELDS_BROWSER_FIELDS_COUNT = '[data-test-subj="fields-count"]'; diff --git a/x-pack/legacy/plugins/siem/cypress/integration/lib/hosts/helpers.ts b/x-pack/legacy/plugins/siem/cypress/integration/lib/hosts/helpers.ts index a9c55988b776b..43d99666fd261 100644 --- a/x-pack/legacy/plugins/siem/cypress/integration/lib/hosts/helpers.ts +++ b/x-pack/legacy/plugins/siem/cypress/integration/lib/hosts/helpers.ts @@ -4,10 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ALL_HOSTS_WIDGET } from './selectors'; +import { DEFAULT_TIMEOUT } from '../util/helpers'; -/** Wait this long for the for the `All Hosts` widget on the `Hosts` page to load */ -const ALL_HOSTS_TIMEOUT = 10 * 1000; +import { ALL_HOSTS_WIDGET } from './selectors'; /** Wait for the for the `All Hosts` widget on the `Hosts` page to load */ -export const waitForAllHostsWidget = () => cy.get(ALL_HOSTS_WIDGET, { timeout: ALL_HOSTS_TIMEOUT }); +export const waitForAllHostsWidget = () => cy.get(ALL_HOSTS_WIDGET, { timeout: DEFAULT_TIMEOUT }); diff --git a/x-pack/legacy/plugins/siem/cypress/integration/lib/login/helpers.ts b/x-pack/legacy/plugins/siem/cypress/integration/lib/login/helpers.ts index 6c5ee1a0d7c97..a2d0daf34fe81 100644 --- a/x-pack/legacy/plugins/siem/cypress/integration/lib/login/helpers.ts +++ b/x-pack/legacy/plugins/siem/cypress/integration/lib/login/helpers.ts @@ -6,73 +6,112 @@ import * as yaml from 'js-yaml'; -import { LOGIN_PAGE } from '../urls'; +/** + * Credentials in the `kibana.dev.yml` config file will be used to authenticate + * with Kibana when credentials are not provided via environment variables + */ +const KIBANA_DEV_YML_PATH = '../../../../config/kibana.dev.yml'; -import { DEFAULT_SPACE_BUTTON, PASSWORD, USERNAME } from './selectors'; +/** + * The configuration path in `kibana.dev.yml` to the username to be used when + * authenticating with Kibana. + */ +const ELASTICSEARCH_USERNAME_CONFIG_PATH = 'config.elasticsearch.username'; /** - * The `username` and `password` values in the `elasticsearch` section of this - * file will be used to login to Kibana + * The configuration path in `kibana.dev.yml` to the password to be used when + * authenticating with Kibana. */ -const KIBANA_DEV_YML_PATH = '../../../../config/kibana.dev.yml'; +const ELASTICSEARCH_PASSWORD_CONFIG_PATH = 'config.elasticsearch.password'; -/** Wait this long for the login page wait (ms) */ -const USERNAME_TIMEOUT = 10 * 1000; +/** + * The `CYPRESS_ELASTICSEARCH_USERNAME` environment variable specifies the + * username to be used when authenticating with Kibana + */ +const ELASTICSEARCH_USERNAME = 'ELASTICSEARCH_USERNAME'; /** - * Authenticates with Kibana by POSTing the username and password directly to - * Kibana's `security/v1/login` endpoint, bypassing the login page (for speed). + * The `CYPRESS_ELASTICSEARCH_PASSWORD` environment variable specifies the + * username to be used when authenticating with Kibana + */ +const ELASTICSEARCH_PASSWORD = 'ELASTICSEARCH_PASSWORD'; + +/** + * The Kibana server endpoint used for authentication + */ +const LOGIN_API_ENDPOINT = '/api/security/v1/login'; + +/** + * Authenticates with Kibana using, if specified, credentials specified by + * environment variables. The credentials in `kibana.dev.yml` will be used + * for authentication when the environment variables are unset. * - * To speed the execution of tests, prefer this function over authenticating - * via an interactive login. + * To speed the execution of tests, prefer this non-interactive authentication, + * which is faster than authentication via Kibana's interactive login page. */ export const login = () => { + if (credentialsProvidedByEnvironment()) { + loginViaEnvironmentCredentials(); + } else { + loginViaConfig(); + } +}; + +/** + * Returns `true` if the credentials used to login to Kibana are provided + * via environment variables + */ +const credentialsProvidedByEnvironment = (): boolean => + Cypress.env(ELASTICSEARCH_USERNAME) != null && Cypress.env(ELASTICSEARCH_PASSWORD) != null; + +/** + * Authenticates with Kibana by reading credentials from the + * `CYPRESS_ELASTICSEARCH_USERNAME` and `CYPRESS_ELASTICSEARCH_PASSWORD` + * environment variables, and POSTing the username and password directly to + * Kibana's `security/v1/login` endpoint, bypassing the login page (for speed). + */ +const loginViaEnvironmentCredentials = () => { cy.log( - `NON-interactively logging into Kibana with the \`username\` and \`password\` from the \`elasticsearch\` section of \`${KIBANA_DEV_YML_PATH}\`` + `Authenticating via environment credentials from the \`CYPRESS_${ELASTICSEARCH_USERNAME}\` and \`CYPRESS_${ELASTICSEARCH_PASSWORD}\` environment variables` ); - // read the login details - cy.readFile(KIBANA_DEV_YML_PATH).then(kibanaDevYml => { - const config = yaml.safeLoad(kibanaDevYml); - - // programmatically log us in without needing the UI - cy.request({ - body: { - username: config.elasticsearch.username, - password: config.elasticsearch.password, - }, - followRedirect: false, - headers: { 'kbn-xsrf': 'cypress' }, - method: 'POST', - url: 'http://localhost:5601/api/security/v1/login', - }); + // programmatically authenticate without interacting with the Kibana login page + cy.request({ + body: { + username: Cypress.env(ELASTICSEARCH_USERNAME), + password: Cypress.env(ELASTICSEARCH_PASSWORD), + }, + followRedirect: false, + headers: { 'kbn-xsrf': 'cypress-creds-via-env' }, + method: 'POST', + url: `${Cypress.config().baseUrl}${LOGIN_API_ENDPOINT}`, }); }; /** - * This (slower) login function authenticates with Kibana via the login page. - * To speed the execution of tests, is generally preferable to use the - * NON-interactive `login` function (in this file) instead, because in - * addition to having to wait for the interactive Kibana login page to load, - * this function also waits for the "spaces" page to load after - * authenticating so it can click on the default space. + * Authenticates with Kibana by reading credentials from the + * `kibana.dev.yml` file and POSTing the username and password directly to + * Kibana's `security/v1/login` endpoint, bypassing the login page (for speed). */ -export const interactiveLogin = () => { +const loginViaConfig = () => { cy.log( - `Interactively logging into Kibana with the \`username\` and \`password\` from the \`elasticsearch\` section of \`${KIBANA_DEV_YML_PATH}\`` + `Authenticating via config credentials \`${ELASTICSEARCH_USERNAME_CONFIG_PATH}\` and \`${ELASTICSEARCH_PASSWORD_CONFIG_PATH}\` from \`${KIBANA_DEV_YML_PATH}\`` ); - // read the login details + // read the login details from `kibana.dev.yaml` cy.readFile(KIBANA_DEV_YML_PATH).then(kibanaDevYml => { const config = yaml.safeLoad(kibanaDevYml); - cy.visit(LOGIN_PAGE); - - cy.get(USERNAME, { timeout: USERNAME_TIMEOUT }).type(config.elasticsearch.username); - cy.get(PASSWORD).type(`${config.elasticsearch.password}{enter}`, { - log: false, + // programmatically authenticate without interacting with the Kibana login page + cy.request({ + body: { + username: config.elasticsearch.username, + password: config.elasticsearch.password, + }, + followRedirect: false, + headers: { 'kbn-xsrf': 'cypress-creds-via-config' }, + method: 'POST', + url: `${Cypress.config().baseUrl}${LOGIN_API_ENDPOINT}`, }); - - cy.get(DEFAULT_SPACE_BUTTON).click(); // click the `Default` space in the `Select Your Space` page }); }; diff --git a/x-pack/legacy/plugins/siem/cypress/integration/lib/logout/index.ts b/x-pack/legacy/plugins/siem/cypress/integration/lib/logout/index.ts index 5a037982a3290..139ed6df8632d 100644 --- a/x-pack/legacy/plugins/siem/cypress/integration/lib/logout/index.ts +++ b/x-pack/legacy/plugins/siem/cypress/integration/lib/logout/index.ts @@ -4,10 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { LOGOUT_LINK, USER_MENU } from './selectors'; +import { LOGOUT } from '../urls'; export const logout = () => { - cy.get(USER_MENU).click(); - - cy.get(LOGOUT_LINK).click(); + cy.visit(`${Cypress.config().baseUrl}${LOGOUT}`); }; diff --git a/x-pack/legacy/plugins/siem/cypress/integration/lib/navigation/selectors.ts b/x-pack/legacy/plugins/siem/cypress/integration/lib/navigation/selectors.ts index aeb6740000f35..e1bdb6307b1a3 100644 --- a/x-pack/legacy/plugins/siem/cypress/integration/lib/navigation/selectors.ts +++ b/x-pack/legacy/plugins/siem/cypress/integration/lib/navigation/selectors.ts @@ -5,13 +5,13 @@ */ /** Top-level (global) navigation link to the `Hosts` page */ -export const NAVIGATION_HOSTS = '[data-test-subj="navigation-hosts"]'; +export const NAVIGATION_HOSTS = '[data-test-subj="navigation-link-hosts"]'; /** Top-level (global) navigation link to the `Network` page */ -export const NAVIGATION_NETWORK = '[data-test-subj="navigation-network"]'; +export const NAVIGATION_NETWORK = '[data-test-subj="navigation-link-network"]'; /** Top-level (global) navigation link to the `Overview` page */ -export const NAVIGATION_OVERVIEW = '[data-test-subj="navigation-overview"]'; +export const NAVIGATION_OVERVIEW = '[data-test-subj="navigation-link-overview"]'; /** Top-level (global) navigation link to the `Timelines` page */ -export const NAVIGATION_TIMELINES = '[data-test-subj="navigation-timelines"]'; +export const NAVIGATION_TIMELINES = '[data-test-subj="navigation-link-timelines"]'; diff --git a/x-pack/legacy/plugins/siem/cypress/integration/lib/timeline/helpers.ts b/x-pack/legacy/plugins/siem/cypress/integration/lib/timeline/helpers.ts index 24174fe7174be..2bc5fb4050907 100644 --- a/x-pack/legacy/plugins/siem/cypress/integration/lib/timeline/helpers.ts +++ b/x-pack/legacy/plugins/siem/cypress/integration/lib/timeline/helpers.ts @@ -6,18 +6,18 @@ import { drag, drop } from '../drag_n_drop/helpers'; import { ALL_HOSTS_WIDGET_DRAGGABLE_HOSTS } from '../hosts/selectors'; - -import { TIMELINE_DATA_PROVIDERS, TIMELINE_TOGGLE_BUTTON } from './selectors'; - -/** Wait up to this many ms for the timeline to render data providers that were dropped in the timeline */ -export const TIMELINE_RENDER_DATA_PROVIDERS_TIMEOUT = 10 * 1000; +import { + SEARCH_OR_FILTER_CONTAINER, + SERVER_SIDE_EVENT_COUNT, + TIMELINE_DATA_PROVIDERS, + TIMELINE_TOGGLE_BUTTON, + TOGGLE_EXPAND_EVENT, +} from './selectors'; +import { DEFAULT_TIMEOUT } from '../util/helpers'; /** Toggles the timeline's open / closed state by clicking the `T I M E L I N E` button */ export const toggleTimelineVisibility = () => - cy - .get(TIMELINE_TOGGLE_BUTTON) - .first() - .click(); + cy.get(TIMELINE_TOGGLE_BUTTON, { timeout: DEFAULT_TIMEOUT }).click(); /** Drags and drops a host from the `All Hosts` widget on the `Hosts` page to the timeline */ export const dragFromAllHostsToTimeline = () => { @@ -25,7 +25,27 @@ export const dragFromAllHostsToTimeline = () => { .first() .then(host => drag(host)); - cy.get(TIMELINE_DATA_PROVIDERS) + cy.get(TIMELINE_DATA_PROVIDERS).then(dataProvidersDropArea => drop(dataProvidersDropArea)); +}; + +/** Executes the specified KQL query in the timeline */ +export const executeKQL = (query: string) => { + cy.get(`${SEARCH_OR_FILTER_CONTAINER} input`).type(`${query} {enter}`); +}; + +/** A sample KQL query that finds any documents where the `host.name` field exists */ +export const hostExistsQuery = 'host.name: *'; + +/** Asserts that at least one event matches the timeline's search criteria */ +export const assertAtLeastOneEventMatchesSearch = () => + cy + .get(SERVER_SIDE_EVENT_COUNT, { timeout: DEFAULT_TIMEOUT }) + .invoke('text') + .should('be.above', 0); + +/** Toggles open or closed the first event in the timeline */ +export const toggleFirstEventDetails = () => { + cy.get(TOGGLE_EXPAND_EVENT, { timeout: DEFAULT_TIMEOUT }) .first() - .then(dataProvidersDropArea => drop(dataProvidersDropArea)); + .click(); }; diff --git a/x-pack/legacy/plugins/siem/cypress/integration/lib/timeline/selectors.ts b/x-pack/legacy/plugins/siem/cypress/integration/lib/timeline/selectors.ts index 14bcea10a7bb0..89fcf5ad467e9 100644 --- a/x-pack/legacy/plugins/siem/cypress/integration/lib/timeline/selectors.ts +++ b/x-pack/legacy/plugins/siem/cypress/integration/lib/timeline/selectors.ts @@ -15,3 +15,13 @@ export const TIMELINE_DROPPED_DATA_PROVIDERS = `${TIMELINE_DATA_PROVIDERS} ${DAT /** The `T I M E L I N E` button that toggles visibility of the Timeline */ export const TIMELINE_TOGGLE_BUTTON = '[data-test-subj="flyoutOverlay"]'; + +/** Contains the KQL bar for searching or filtering in the timeline */ +export const SEARCH_OR_FILTER_CONTAINER = + '[data-test-subj="timeline-search-or-filter-search-container"]'; + +/** The total server-side count of the events matching the timeline's search criteria */ +export const SERVER_SIDE_EVENT_COUNT = '[data-test-subj="server-side-event-count"]'; + +/** Expands or collapses an event in the timeline */ +export const TOGGLE_EXPAND_EVENT = '[data-test-subj="expand-event"]'; diff --git a/x-pack/legacy/plugins/siem/cypress/integration/lib/urls/index.ts b/x-pack/legacy/plugins/siem/cypress/integration/lib/urls/index.ts index 281a9c68babd1..25cff28836a7c 100644 --- a/x-pack/legacy/plugins/siem/cypress/integration/lib/urls/index.ts +++ b/x-pack/legacy/plugins/siem/cypress/integration/lib/urls/index.ts @@ -5,16 +5,19 @@ */ /** The SIEM app's Hosts page */ -export const HOSTS_PAGE = 'http://localhost:5601/app/siem#/hosts'; +export const HOSTS_PAGE = '/app/siem#/hosts'; /** Kibana's login page */ -export const LOGIN_PAGE = 'http://localhost:5601/login'; +export const LOGIN_PAGE = '/login'; /** The SIEM app's Network page */ -export const NETWORK_PAGE = 'http://localhost:5601/app/siem#/network'; +export const NETWORK_PAGE = '/app/siem#/network'; /** The SIEM app's Overview page */ -export const OVERVIEW_PAGE = 'http://localhost:5601/app/siem#/overview'; +export const OVERVIEW_PAGE = '/app/siem#/overview'; /** The SIEM app's Timelines page */ -export const TIMELINES_PAGE = 'http://localhost:5601/app/siem#/timelines'; +export const TIMELINES_PAGE = '/app/siem#/timelines'; + +/** Visit this URL to logout of Kibana */ +export const LOGOUT = '/logout'; diff --git a/x-pack/legacy/plugins/siem/cypress/integration/lib/util/helpers.ts b/x-pack/legacy/plugins/siem/cypress/integration/lib/util/helpers.ts index 67a2714073441..480da42a6fe66 100644 --- a/x-pack/legacy/plugins/siem/cypress/integration/lib/util/helpers.ts +++ b/x-pack/legacy/plugins/siem/cypress/integration/lib/util/helpers.ts @@ -6,7 +6,8 @@ import { login } from '../login/helpers'; -const KIBANA_LOGO_TIMEOUT = 10 * 1000; +/** The default time in ms to wait for a Cypress command to complete */ +export const DEFAULT_TIMEOUT = 30 * 1000; /** * Authenticates with Kibana, visits the specified `url`, and waits for the @@ -15,9 +16,9 @@ const KIBANA_LOGO_TIMEOUT = 10 * 1000; export const loginAndWaitForPage = (url: string) => { login(); - cy.visit(url); + cy.visit(`${Cypress.config().baseUrl}${url}`); cy.viewport('macbook-15'); - cy.contains('a', 'SIEM', { timeout: KIBANA_LOGO_TIMEOUT }); + cy.contains('a', 'SIEM', { timeout: DEFAULT_TIMEOUT }); }; diff --git a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/fields_browser/fields_browser.spec.ts b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/fields_browser/fields_browser.spec.ts new file mode 100644 index 0000000000000..546438284b947 --- /dev/null +++ b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/fields_browser/fields_browser.spec.ts @@ -0,0 +1,232 @@ +/* + * 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 { drag, drop } from '../../lib/drag_n_drop/helpers'; +import { + clickOutsideFieldsBrowser, + openFieldsBrowser, + populateTimeline, + filterFieldsBrowser, +} from '../../lib/fields_browser/helpers'; +import { + FIELDS_BROWSER_CATEGORIES_COUNT, + FIELDS_BROWSER_CONTAINER, + FIELDS_BROWSER_FIELDS_COUNT, + FIELDS_BROWSER_SELECTED_CATEGORY_COUNT, + FIELDS_BROWSER_SELECTED_CATEGORY_TITLE, + FIELDS_BROWSER_TITLE, +} from '../../lib/fields_browser/selectors'; +import { logout } from '../../lib/logout'; +import { HOSTS_PAGE } from '../../lib/urls'; +import { loginAndWaitForPage } from '../../lib/util/helpers'; + +const defaultHeaders = [ + { id: '@timestamp' }, + { id: 'message' }, + { id: 'event.category' }, + { id: 'event.action' }, + { id: 'host.name' }, + { id: 'source.ip' }, + { id: 'destination.ip' }, + { id: 'user.name' }, +]; + +describe('Fields Browser', () => { + beforeEach(() => { + loginAndWaitForPage(HOSTS_PAGE); + }); + + afterEach(() => { + logout(); + }); + + it('renders the fields browser with the expected title when the Fields button is clicked', () => { + populateTimeline(); + + openFieldsBrowser(); + + cy.get(FIELDS_BROWSER_TITLE) + .invoke('text') + .should('eq', 'Customize Columns'); + }); + + it('closes the fields browser when the user clicks outside of it', () => { + populateTimeline(); + + openFieldsBrowser(); + + clickOutsideFieldsBrowser(); + + cy.get(FIELDS_BROWSER_CONTAINER).should('not.exist'); + }); + + it('displays the `default ECS` category (by default)', () => { + populateTimeline(); + + openFieldsBrowser(); + + cy.get(FIELDS_BROWSER_SELECTED_CATEGORY_TITLE) + .invoke('text') + .should('eq', 'default ECS'); + }); + + it('the `defaultECS` (selected) category count matches the default timeline header count', () => { + populateTimeline(); + + openFieldsBrowser(); + + cy.get(FIELDS_BROWSER_SELECTED_CATEGORY_COUNT) + .invoke('text') + .should('eq', `${defaultHeaders.length}`); + }); + + it('displays a checked checkbox for all of the default timeline columns', () => { + populateTimeline(); + + openFieldsBrowser(); + + defaultHeaders.forEach(header => + cy.get(`[data-test-subj="field-${header.id}-checkbox"]`).should('be.checked') + ); + }); + + it('removes the message field from the timeline when the user un-checks the field', () => { + const toggleField = 'message'; + + populateTimeline(); + + cy.get(`[data-test-subj="header-text-${toggleField}"]`).should('exist'); + + openFieldsBrowser(); + + cy.get(`[data-test-subj="field-${toggleField}-checkbox"]`).uncheck(); + + clickOutsideFieldsBrowser(); + + cy.get(`[data-test-subj="header-text-${toggleField}"]`).should('not.exist'); + }); + + it('displays the expected count of categories that match the filter input', () => { + const filterInput = 'host.mac'; + + populateTimeline(); + + openFieldsBrowser(); + + filterFieldsBrowser(filterInput); + + cy.get(FIELDS_BROWSER_CATEGORIES_COUNT) + .invoke('text') + .should('eq', '2 categories'); + }); + + it('displays a search results label with the expected count of fields matching the filter input', () => { + const filterInput = 'host.mac'; + + populateTimeline(); + + openFieldsBrowser(); + + filterFieldsBrowser(filterInput); + + cy.get(FIELDS_BROWSER_FIELDS_COUNT) + .invoke('text') + .should('eq', '2 fields'); + }); + + it('selects a search results label with the expected count of categories matching the filter input', () => { + const category = 'host'; + + populateTimeline(); + + openFieldsBrowser(); + + filterFieldsBrowser(`${category}.`); + + cy.get(FIELDS_BROWSER_SELECTED_CATEGORY_TITLE) + .invoke('text') + .should('eq', category); + }); + + it('displays a count of only the fields in the selected category that match the filter input', () => { + const filterInput = 'host.geo.c'; + + populateTimeline(); + + openFieldsBrowser(); + + filterFieldsBrowser(filterInput); + + cy.get(FIELDS_BROWSER_SELECTED_CATEGORY_COUNT) + .invoke('text') + .should('eq', '4'); + }); + + it('adds a field to the timeline when the user clicks the checkbox', () => { + const filterInput = 'host.geo.c'; + const toggleField = 'host.geo.city_name'; + + populateTimeline(); + + openFieldsBrowser(); + + filterFieldsBrowser(filterInput); + + cy.get(`[data-test-subj="header-text-${toggleField}"]`).should('not.exist'); + + cy.get(`[data-test-subj="field-${toggleField}-checkbox"]`).check(); + + clickOutsideFieldsBrowser(); + + cy.get(`[data-test-subj="header-text-${toggleField}"]`).should('exist'); + }); + + it('adds a field to the timeline when the user drags and drops a field', () => { + const filterInput = 'host.geo.c'; + const toggleField = 'host.geo.city_name'; + + populateTimeline(); + + openFieldsBrowser(); + + filterFieldsBrowser(filterInput); + + cy.get(`[data-test-subj="header-text-${toggleField}"]`).should('not.exist'); + + cy.get(`[data-test-subj="field-name-${toggleField}"]`).then(field => drag(field)); + + cy.get(`[data-test-subj="headers-group"]`).then(headersDropArea => drop(headersDropArea)); + + clickOutsideFieldsBrowser(); + + cy.get(`[data-test-subj="header-text-${toggleField}"]`).should('exist'); + }); + + it('resets all fields in the timeline when `Reset Fields` is clicked', () => { + const filterInput = 'host.geo.c'; + const toggleField = 'host.geo.city_name'; + + populateTimeline(); + + openFieldsBrowser(); + + filterFieldsBrowser(filterInput); + + cy.get(`[data-test-subj="header-text-${toggleField}"]`).should('not.exist'); + + cy.get(`[data-test-subj="field-${toggleField}-checkbox"]`).check(); + + clickOutsideFieldsBrowser(); + + cy.get(`[data-test-subj="header-text-${toggleField}"]`).should('exist'); + + openFieldsBrowser(); + + cy.get('[data-test-subj="reset-fields"]').click(); + + cy.get(`[data-test-subj="header-text-${toggleField}"]`).should('not.exist'); + }); +}); diff --git a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/navigation/navigation.spec.ts b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/navigation/navigation.spec.ts index 2714f5f949216..e10f992686ab3 100644 --- a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/navigation/navigation.spec.ts +++ b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/navigation/navigation.spec.ts @@ -5,7 +5,7 @@ */ import { logout } from '../../lib/logout'; -import { HOSTS_PAGE, NETWORK_PAGE, OVERVIEW_PAGE, TIMELINES_PAGE } from '../../lib/urls'; +import { OVERVIEW_PAGE, TIMELINES_PAGE } from '../../lib/urls'; import { NAVIGATION_HOSTS, NAVIGATION_NETWORK, @@ -28,7 +28,7 @@ describe('top-level navigation common to all pages in the SIEM app', () => { }); it('navigates to the Hosts page', () => { - loginAndWaitForPage(NETWORK_PAGE); + loginAndWaitForPage(TIMELINES_PAGE); cy.get(NAVIGATION_HOSTS).click({ force: true }); @@ -36,7 +36,7 @@ describe('top-level navigation common to all pages in the SIEM app', () => { }); it('navigates to the Network page', () => { - loginAndWaitForPage(HOSTS_PAGE); + loginAndWaitForPage(TIMELINES_PAGE); cy.get(NAVIGATION_NETWORK).click({ force: true }); diff --git a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/overview/overview.spec.ts b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/overview/overview.spec.ts index 30bcf902608df..d2361280f1614 100644 --- a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/overview/overview.spec.ts +++ b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/overview/overview.spec.ts @@ -27,17 +27,13 @@ describe('Overview Page', () => { HOST_STATS.forEach(stat => { cy.get(stat.domId) .invoke('text') - .should(statValue => { - expect(statValue).to.eq(stat.value); - }); + .should('eq', stat.value); }); NETWORK_STATS.forEach(stat => { cy.get(stat.domId) .invoke('text') - .should(statValue => { - expect(statValue).to.eq(stat.value); - }); + .should('eq', stat.value); }); }); }); diff --git a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/data_providers.ts b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/data_providers.spec.ts similarity index 82% rename from x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/data_providers.ts rename to x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/data_providers.spec.ts index f31e5dd6aaefa..18e57c3fe35ac 100644 --- a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/data_providers.ts +++ b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/data_providers.spec.ts @@ -6,15 +6,11 @@ import { logout } from '../../lib/logout'; import { TIMELINE_DROPPED_DATA_PROVIDERS } from '../../lib/timeline/selectors'; -import { - dragFromAllHostsToTimeline, - TIMELINE_RENDER_DATA_PROVIDERS_TIMEOUT, - toggleTimelineVisibility, -} from '../../lib/timeline/helpers'; +import { dragFromAllHostsToTimeline, toggleTimelineVisibility } from '../../lib/timeline/helpers'; import { ALL_HOSTS_WIDGET_DRAGGABLE_HOSTS } from '../../lib/hosts/selectors'; import { HOSTS_PAGE } from '../../lib/urls'; import { waitForAllHostsWidget } from '../../lib/hosts/helpers'; -import { loginAndWaitForPage } from '../../lib/util/helpers'; +import { DEFAULT_TIMEOUT, loginAndWaitForPage } from '../../lib/util/helpers'; describe('timeline data providers', () => { beforeEach(() => { @@ -25,7 +21,6 @@ describe('timeline data providers', () => { logout(); }); - // eslint-disable-next-line ban/ban it('renders the data provider of a host dragged from the All Hosts widget on the hosts page', () => { waitForAllHostsWidget(); @@ -34,7 +29,7 @@ describe('timeline data providers', () => { dragFromAllHostsToTimeline(); cy.get(TIMELINE_DROPPED_DATA_PROVIDERS, { - timeout: TIMELINE_RENDER_DATA_PROVIDERS_TIMEOUT, + timeout: DEFAULT_TIMEOUT + 10 * 1000, }) .first() .invoke('text') diff --git a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/search_or_filter.spec.ts b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/search_or_filter.spec.ts new file mode 100644 index 0000000000000..481ef759c1b1e --- /dev/null +++ b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/search_or_filter.spec.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { logout } from '../../lib/logout'; +import { + assertAtLeastOneEventMatchesSearch, + executeKQL, + hostExistsQuery, + toggleTimelineVisibility, +} from '../../lib/timeline/helpers'; +import { HOSTS_PAGE } from '../../lib/urls'; +import { loginAndWaitForPage } from '../../lib/util/helpers'; + +describe('timeline search or filter KQL bar', () => { + beforeEach(() => { + loginAndWaitForPage(HOSTS_PAGE); + }); + + afterEach(() => { + logout(); + }); + + it('executes a KQL query', () => { + toggleTimelineVisibility(); + + executeKQL(hostExistsQuery); + + assertAtLeastOneEventMatchesSearch(); + }); +}); diff --git a/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/toggle_column.spec.ts b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/toggle_column.spec.ts new file mode 100644 index 0000000000000..828b3cd9cd410 --- /dev/null +++ b/x-pack/legacy/plugins/siem/cypress/integration/smoke_tests/timeline/toggle_column.spec.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { drag, drop } from '../../lib/drag_n_drop/helpers'; +import { populateTimeline } from '../../lib/fields_browser/helpers'; +import { logout } from '../../lib/logout'; +import { toggleFirstEventDetails } from '../../lib/timeline/helpers'; +import { HOSTS_PAGE } from '../../lib/urls'; +import { loginAndWaitForPage } from '../../lib/util/helpers'; + +describe('toggle column in timeline', () => { + beforeEach(() => { + loginAndWaitForPage(HOSTS_PAGE); + }); + + afterEach(() => { + logout(); + }); + + const timestampField = '@timestamp'; + const idField = '_id'; + + it('displays a checked Toggle field checkbox for `@timestamp`, a default timeline column', () => { + populateTimeline(); + + toggleFirstEventDetails(); + + cy.get(`[data-test-subj="toggle-field-${timestampField}"]`).should('be.checked'); + }); + + it('displays an UNchecked Toggle field checkbox for `_id`, because it is NOT a default timeline column', () => { + populateTimeline(); + + cy.get(`[data-test-subj="toggle-field-${idField}"]`).should('not.be.checked'); + }); + + it('removes the @timestamp field from the timeline when the user un-checks the toggle', () => { + populateTimeline(); + + toggleFirstEventDetails(); + + cy.get(`[data-test-subj="header-text-${timestampField}"]`).should('exist'); + + cy.get(`[data-test-subj="toggle-field-${timestampField}"]`).uncheck(); + + cy.get(`[data-test-subj="header-text-${timestampField}"]`).should('not.exist'); + }); + + it('adds the _id field to the timeline when the user checks the field', () => { + populateTimeline(); + + toggleFirstEventDetails(); + + cy.get(`[data-test-subj="header-text-${idField}"]`).should('not.exist'); + + cy.get(`[data-test-subj="toggle-field-${idField}"]`).check(); + + cy.get(`[data-test-subj="header-text-${idField}"]`).should('exist'); + }); + + it('adds the _id field to the timeline via drag and drop', () => { + populateTimeline(); + + toggleFirstEventDetails(); + + cy.get(`[data-test-subj="header-text-${idField}"]`).should('not.exist'); + + cy.get(`[data-test-subj="field-name-${idField}"]`).then(field => drag(field)); + + cy.get(`[data-test-subj="headers-group"]`).then(headersDropArea => drop(headersDropArea)); + + cy.get(`[data-test-subj="header-text-${idField}"]`).should('exist'); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/components/edit_data_provider/helpers.test.tsx b/x-pack/legacy/plugins/siem/public/components/edit_data_provider/helpers.test.tsx new file mode 100644 index 0000000000000..7443087306428 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/edit_data_provider/helpers.test.tsx @@ -0,0 +1,295 @@ +/* + * 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 { mockBrowserFields } from '../../containers/source/mock'; +import { EXISTS_OPERATOR, IS_OPERATOR } from '../timeline/data_providers/data_provider'; + +import { + getCategorizedFieldNames, + getExcludedFromSelection, + getFieldNames, + getQueryOperatorFromSelection, + selectionsAreValid, +} from './helpers'; + +import * as i18n from './translations'; + +describe('helpers', () => { + describe('getFieldNames', () => { + test('it should return the expected field names in a category', () => { + expect(getFieldNames(mockBrowserFields.auditd)).toEqual([ + 'auditd.data.a0', + 'auditd.data.a1', + 'auditd.data.a2', + ]); + }); + }); + + describe('getCategorizedFieldNames', () => { + test('it should return the expected field names grouped by category', () => { + expect(getCategorizedFieldNames(mockBrowserFields)).toEqual([ + { + label: 'agent', + options: [ + { label: 'agent.ephemeral_id' }, + { label: 'agent.hostname' }, + { label: 'agent.id' }, + { label: 'agent.name' }, + ], + }, + { + label: 'auditd', + options: [ + { label: 'auditd.data.a0' }, + { label: 'auditd.data.a1' }, + { label: 'auditd.data.a2' }, + ], + }, + { label: 'base', options: [{ label: '@timestamp' }] }, + { + label: 'client', + options: [ + { label: 'client.address' }, + { label: 'client.bytes' }, + { label: 'client.domain' }, + { label: 'client.geo.country_iso_code' }, + ], + }, + { + label: 'cloud', + options: [{ label: 'cloud.account.id' }, { label: 'cloud.availability_zone' }], + }, + { + label: 'container', + options: [ + { label: 'container.id' }, + { label: 'container.image.name' }, + { label: 'container.image.tag' }, + ], + }, + { + label: 'destination', + options: [ + { label: 'destination.address' }, + { label: 'destination.bytes' }, + { label: 'destination.domain' }, + { label: 'destination.ip' }, + { label: 'destination.port' }, + ], + }, + { label: 'event', options: [{ label: 'event.end' }] }, + { label: 'source', options: [{ label: 'source.ip' }, { label: 'source.port' }] }, + ]); + }); + }); + + describe('selectionsAreValid', () => { + test('it should return true when the selected field and operator are valid', () => { + expect( + selectionsAreValid({ + browserFields: mockBrowserFields, + selectedField: [ + { + label: 'destination.bytes', + }, + ], + selectedOperator: [ + { + label: 'is', + }, + ], + }) + ).toBe(true); + }); + + test('it should return false when the selected field is empty', () => { + expect( + selectionsAreValid({ + browserFields: mockBrowserFields, + selectedField: [ + { + label: '', + }, + ], + selectedOperator: [ + { + label: 'is', + }, + ], + }) + ).toBe(false); + }); + + test('it should return false when the selected field is unknown', () => { + expect( + selectionsAreValid({ + browserFields: mockBrowserFields, + selectedField: [ + { + label: 'invalid-field', + }, + ], + selectedOperator: [ + { + label: 'is', + }, + ], + }) + ).toBe(false); + }); + + test('it should return false when the selected operator is empty', () => { + expect( + selectionsAreValid({ + browserFields: mockBrowserFields, + selectedField: [ + { + label: 'destination.bytes', + }, + ], + selectedOperator: [ + { + label: '', + }, + ], + }) + ).toBe(false); + }); + + test('it should return false when the selected operator is unknown', () => { + expect( + selectionsAreValid({ + browserFields: mockBrowserFields, + selectedField: [ + { + label: 'destination.bytes', + }, + ], + selectedOperator: [ + { + label: 'invalid-operator', + }, + ], + }) + ).toBe(false); + }); + }); + + describe('getQueryOperatorFromSelection', () => { + const validSelections = [ + { + operator: i18n.IS, + expected: IS_OPERATOR, + }, + { + operator: i18n.IS_NOT, + expected: IS_OPERATOR, + }, + { + operator: i18n.EXISTS, + expected: EXISTS_OPERATOR, + }, + { + operator: i18n.DOES_NOT_EXIST, + expected: EXISTS_OPERATOR, + }, + ]; + + validSelections.forEach(({ operator, expected }) => { + test(`it should the expected operator given "${operator}", a valid selection`, () => { + expect( + getQueryOperatorFromSelection([ + { + label: operator, + }, + ]) + ).toEqual(expected); + }); + }); + + test('it should default to the "is" operator given an empty selection', () => { + expect( + getQueryOperatorFromSelection([ + { + label: '', + }, + ]) + ).toEqual(IS_OPERATOR); + }); + + test('it should default to the "is" operator given an invalid selection', () => { + expect( + getQueryOperatorFromSelection([ + { + label: 'invalid', + }, + ]) + ).toEqual(IS_OPERATOR); + }); + }); + + describe('getExcludedFromSelection', () => { + test('it returns false when the selected operator is empty', () => { + expect( + getExcludedFromSelection([ + { + label: '', + }, + ]) + ).toBe(false); + }); + + test('it returns false when the "is" operator is selected', () => { + expect( + getExcludedFromSelection([ + { + label: i18n.IS, + }, + ]) + ).toBe(false); + }); + + test('it returns false when the "exists" operator is selected', () => { + expect( + getExcludedFromSelection([ + { + label: i18n.EXISTS, + }, + ]) + ).toBe(false); + }); + + test('it returns false when an unknown selection is made', () => { + expect( + getExcludedFromSelection([ + { + label: 'an unknown selection', + }, + ]) + ).toBe(false); + }); + + test('it returns true when "is not" is selected', () => { + expect( + getExcludedFromSelection([ + { + label: i18n.IS_NOT, + }, + ]) + ).toBe(true); + }); + + test('it returns true when "does not exist" is selected', () => { + expect( + getExcludedFromSelection([ + { + label: i18n.DOES_NOT_EXIST, + }, + ]) + ).toBe(true); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/components/edit_data_provider/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/edit_data_provider/index.test.tsx new file mode 100644 index 0000000000000..7c515862b0d92 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/edit_data_provider/index.test.tsx @@ -0,0 +1,428 @@ +/* + * 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 { mount } from 'enzyme'; +import * as React from 'react'; + +import { mockBrowserFields } from '../../containers/source/mock'; +import { TestProviders } from '../../mock'; +import { IS_OPERATOR, EXISTS_OPERATOR } from '../timeline/data_providers/data_provider'; + +import { StatefulEditDataProvider } from '.'; + +interface HasIsDisabled { + isDisabled: boolean; +} + +describe('StatefulEditDataProvider', () => { + const field = 'client.address'; + const timelineId = 'test'; + const value = 'test-host'; + + test('it renders the current field', () => { + const wrapper = mount( + + + + ); + + expect( + wrapper + .find('[data-test-subj="field"]') + .first() + .text() + ).toEqual(field); + }); + + test('it renders the expected placeholder for the current field when field is empty', () => { + const wrapper = mount( + + + + ); + + expect( + wrapper + .find('[data-test-subj="field"]') + .first() + .props().placeholder + ).toEqual('Select a field'); + }); + + test('it renders the "is" operator in a humanized format', () => { + const wrapper = mount( + + + + ); + + expect( + wrapper + .find('[data-test-subj="operator"]') + .first() + .text() + ).toEqual('is'); + }); + + test('it renders the negated "is" operator in a humanized format when isExcluded is true', () => { + const wrapper = mount( + + + + ); + + expect( + wrapper + .find('[data-test-subj="operator"]') + .first() + .text() + ).toEqual('is not'); + }); + + test('it renders the "exists" operator in human-readable format', () => { + const wrapper = mount( + + + + ); + + expect( + wrapper + .find('[data-test-subj="operator"]') + .first() + .text() + ).toEqual('exists'); + }); + + test('it renders the negated "exists" operator in a humanized format when isExcluded is true', () => { + const wrapper = mount( + + + + ); + + expect( + wrapper + .find('[data-test-subj="operator"]') + .first() + .text() + ).toEqual('does not exist'); + }); + + test('it renders the current value when the operator is "is"', () => { + const wrapper = mount( + + + + ); + + expect( + wrapper + .find('[data-test-subj="value"]') + .first() + .props().value + ).toEqual(value); + }); + + test('it renders the current value when the type of value is an array', () => { + const reallyAnArray = ([value] as unknown) as string; + + const wrapper = mount( + + + + ); + + expect( + wrapper + .find('[data-test-subj="value"]') + .first() + .props().value + ).toEqual(value); + }); + + test('it does NOT render the current value when the operator is "is not" (isExcluded is true)', () => { + const wrapper = mount( + + + + ); + + expect( + wrapper + .find('[data-test-subj="value"]') + .first() + .props().value + ).toEqual(value); + }); + + test('it renders the expected placeholder when value is empty', () => { + const wrapper = mount( + + + + ); + + expect( + wrapper + .find('[data-test-subj="value"]') + .first() + .props().placeholder + ).toEqual('value'); + }); + + test('it does NOT render value when the operator is "exists"', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="value"]').exists()).toBe(false); + }); + + test('it does NOT render value when the operator is "not exists" (isExcluded is true)', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="value"]').exists()).toBe(false); + }); + + test('it does NOT disable the save button when field is valid', () => { + const wrapper = mount( + + + + ); + + const props = wrapper + .find('[data-test-subj="save"]') + .first() + .props() as HasIsDisabled; + + expect(props.isDisabled).toBe(false); + }); + + test('it disables the save button when field is invalid because it is empty', () => { + const wrapper = mount( + + + + ); + + const props = wrapper + .find('[data-test-subj="save"]') + .first() + .props() as HasIsDisabled; + + expect(props.isDisabled).toBe(true); + }); + + test('it disables the save button when field is invalid because it is not contained in the browser fields', () => { + const wrapper = mount( + + + + ); + + const props = wrapper + .find('[data-test-subj="save"]') + .first() + .props() as HasIsDisabled; + + expect(props.isDisabled).toBe(true); + }); + + test('it invokes onDataProviderEdited with the expected values when the user clicks the save button', () => { + const onDataProviderEdited = jest.fn(); + + const wrapper = mount( + + + + ); + + wrapper + .find('[data-test-subj="save"]') + .first() + .simulate('click'); + + wrapper.update(); + + expect(onDataProviderEdited).toBeCalledWith({ + andProviderId: undefined, + excluded: false, + field: 'client.address', + id: 'test', + operator: ':', + providerId: 'hosts-table-hostName-test-host', + value: 'test-host', + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/components/edit_data_provider/index.tsx b/x-pack/legacy/plugins/siem/public/components/edit_data_provider/index.tsx index 71c4f3af0257e..10b4340b6a88d 100644 --- a/x-pack/legacy/plugins/siem/public/components/edit_data_provider/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/edit_data_provider/index.tsx @@ -133,6 +133,7 @@ export class StatefulEditDataProvider extends React.PureComponent } > diff --git a/x-pack/legacy/plugins/siem/public/components/event_details/columns.tsx b/x-pack/legacy/plugins/siem/public/components/event_details/columns.tsx index dc4cb9cda3efa..d22c5e1255a64 100644 --- a/x-pack/legacy/plugins/siem/public/components/event_details/columns.tsx +++ b/x-pack/legacy/plugins/siem/public/components/event_details/columns.tsx @@ -5,12 +5,22 @@ */ import { Draggable } from 'react-beautiful-dnd'; -import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiPanel, EuiToolTip } from '@elastic/eui'; +import { + EuiCheckbox, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiPanel, + EuiToolTip, +} from '@elastic/eui'; import * as React from 'react'; import styled from 'styled-components'; import { BrowserFields } from '../../containers/source'; +import { ColumnHeader } from '../timeline/body/column_headers/column_header'; +import { defaultColumnHeaderType } from '../timeline/body/column_headers/default_headers'; import { DragEffects } from '../drag_and_drop/draggable_wrapper'; +import { DEFAULT_COLUMN_MIN_WIDTH } from '../timeline/body/helpers'; import { DefaultDraggable } from '../draggables'; import { ToStringArray } from '../../graphql/types'; import { DroppableWrapper } from '../drag_and_drop/droppable_wrapper'; @@ -53,26 +63,41 @@ FieldTypeIcon.displayName = 'FieldTypeIcon'; export const getColumns = ({ browserFields, + columnHeaders, eventId, isLoading, onUpdateColumns, timelineId, + toggleColumn, }: { browserFields: BrowserFields; + columnHeaders: ColumnHeader[]; eventId: string; isLoading: boolean; onUpdateColumns: OnUpdateColumns; timelineId: string; + toggleColumn: (column: ColumnHeader) => void; }) => [ { - field: 'type', + field: 'field', name: '', sortable: false, truncateText: false, width: '30px', - render: (type: string) => ( - - + render: (field: string) => ( + + c.id === field) !== -1} + data-test-subj={`toggle-field-${field}`} + id={field} + onChange={() => + toggleColumn({ + columnHeaderType: defaultColumnHeaderType, + id: field, + width: DEFAULT_COLUMN_MIN_WIDTH, + }) + } + /> ), }, @@ -82,45 +107,59 @@ export const getColumns = ({ sortable: true, truncateText: false, render: (field: string, data: EventFieldsData) => ( - - - {(provided, snapshot) => ( -
- {!snapshot.isDragging ? ( - - ) : ( - - - + + + + + + + + + + + {(provided, snapshot) => ( +
+ {!snapshot.isDragging ? ( + + ) : ( + + + + )} +
)} -
- )} -
-
+ + +
+ ), }, { diff --git a/x-pack/legacy/plugins/siem/public/components/event_details/event_details.test.tsx b/x-pack/legacy/plugins/siem/public/components/event_details/event_details.test.tsx index 78df9ab938690..80f605a9c412f 100644 --- a/x-pack/legacy/plugins/siem/public/components/event_details/event_details.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/event_details/event_details.test.tsx @@ -13,6 +13,7 @@ import { TestProviders } from '../../mock/test_providers'; import { EventDetails } from './event_details'; import { mockBrowserFields } from '../../containers/source/mock'; +import { defaultHeaders } from '../../mock/header'; describe('EventDetails', () => { describe('rendering', () => { @@ -21,6 +22,7 @@ describe('EventDetails', () => { { onUpdateColumns={jest.fn()} onViewSelected={jest.fn()} timelineId="test" + toggleColumn={jest.fn()} /> ); @@ -42,6 +45,7 @@ describe('EventDetails', () => { { onUpdateColumns={jest.fn()} onViewSelected={jest.fn()} timelineId="test" + toggleColumn={jest.fn()} /> ); @@ -67,6 +72,7 @@ describe('EventDetails', () => { { onUpdateColumns={jest.fn()} onViewSelected={jest.fn()} timelineId="test" + toggleColumn={jest.fn()} /> ); diff --git a/x-pack/legacy/plugins/siem/public/components/event_details/event_details.tsx b/x-pack/legacy/plugins/siem/public/components/event_details/event_details.tsx index 02b5245f12c8e..a4986973ec4f7 100644 --- a/x-pack/legacy/plugins/siem/public/components/event_details/event_details.tsx +++ b/x-pack/legacy/plugins/siem/public/components/event_details/event_details.tsx @@ -10,6 +10,7 @@ import { pure } from 'recompose'; import styled from 'styled-components'; import { BrowserFields } from '../../containers/source'; +import { ColumnHeader } from '../timeline/body/column_headers/column_header'; import { DetailItem } from '../../graphql/types'; import { OnUpdateColumns } from '../timeline/events'; @@ -21,6 +22,7 @@ export type View = 'table-view' | 'json-view'; interface Props { browserFields: BrowserFields; + columnHeaders: ColumnHeader[]; data: DetailItem[]; id: string; isLoading: boolean; @@ -28,6 +30,7 @@ interface Props { onUpdateColumns: OnUpdateColumns; onViewSelected: (selected: View) => void; timelineId: string; + toggleColumn: (column: ColumnHeader) => void; } const Details = styled.div` @@ -38,7 +41,18 @@ const Details = styled.div` Details.displayName = 'Details'; export const EventDetails = pure( - ({ browserFields, data, id, isLoading, view, onUpdateColumns, onViewSelected, timelineId }) => { + ({ + browserFields, + columnHeaders, + data, + id, + isLoading, + view, + onUpdateColumns, + onViewSelected, + timelineId, + toggleColumn, + }) => { const tabs: EuiTabbedContentTab[] = [ { id: 'table-view', @@ -46,11 +60,13 @@ export const EventDetails = pure( content: ( ), }, diff --git a/x-pack/legacy/plugins/siem/public/components/event_details/event_fields_browser.test.tsx b/x-pack/legacy/plugins/siem/public/components/event_details/event_fields_browser.test.tsx index f481da69232b2..3cda3debe7efb 100644 --- a/x-pack/legacy/plugins/siem/public/components/event_details/event_fields_browser.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/event_details/event_fields_browser.test.tsx @@ -12,6 +12,7 @@ import { TestProviders } from '../../mock/test_providers'; import { EventFieldsBrowser } from './event_fields_browser'; import { mockBrowserFields } from '../../containers/source/mock'; +import { defaultHeaders } from '../../mock/header'; describe('EventFieldsBrowser', () => { describe('column headers', () => { @@ -21,11 +22,13 @@ describe('EventFieldsBrowser', () => { ); @@ -41,11 +44,13 @@ describe('EventFieldsBrowser', () => { ); @@ -56,17 +61,110 @@ describe('EventFieldsBrowser', () => { }); }); + describe('toggle column checkbox', () => { + const eventId = 'pEMaMmkBUV60JmNWmWVi'; + + test('it renders an UNchecked checkbox for a field that is not a member of columnHeaders', () => { + const field = 'agent.id'; + + const wrapper = mountWithIntl( + + + + ); + + expect( + wrapper + .find(`[data-test-subj="toggle-field-${field}"]`) + .first() + .props().checked + ).toBe(false); + }); + + test('it renders an checked checkbox for a field that is a member of columnHeaders', () => { + const field = '@timestamp'; + + const wrapper = mountWithIntl( + + + + ); + + expect( + wrapper + .find(`[data-test-subj="toggle-field-${field}"]`) + .first() + .props().checked + ).toBe(true); + }); + + test('it invokes toggleColumn when the checkbox is clicked', () => { + const field = '@timestamp'; + const toggleColumn = jest.fn(); + + const wrapper = mountWithIntl( + + + + ); + + wrapper + .find(`[data-test-subj="toggle-field-${field}"]`) + .find(`input[type="checkbox"]`) + .first() + .simulate('change', { + target: { checked: true }, + }); + wrapper.update(); + + expect(toggleColumn).toBeCalledWith({ + columnHeaderType: 'not-filtered', + id: '@timestamp', + width: 180, + }); + }); + }); + describe('field type icon', () => { test('it renders the expected icon type for the data provided', () => { const wrapper = mountWithIntl( ); @@ -75,7 +173,7 @@ describe('EventFieldsBrowser', () => { wrapper .find('.euiTableRow') .find('.euiTableRowCell') - .at(0) + .at(1) .find('svg') .exists() ).toEqual(true); @@ -88,11 +186,13 @@ describe('EventFieldsBrowser', () => { ); @@ -111,11 +211,13 @@ describe('EventFieldsBrowser', () => { ); @@ -134,11 +236,13 @@ describe('EventFieldsBrowser', () => { ); diff --git a/x-pack/legacy/plugins/siem/public/components/event_details/event_fields_browser.tsx b/x-pack/legacy/plugins/siem/public/components/event_details/event_fields_browser.tsx index 0979420158a80..3d4bef53fd243 100644 --- a/x-pack/legacy/plugins/siem/public/components/event_details/event_fields_browser.tsx +++ b/x-pack/legacy/plugins/siem/public/components/event_details/event_fields_browser.tsx @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { sortBy } from 'lodash'; import { // @ts-ignore EuiInMemoryTable, @@ -11,7 +12,7 @@ import { import * as React from 'react'; import { pure } from 'recompose'; -import { sortBy } from 'lodash'; +import { ColumnHeader } from '../timeline/body/column_headers/column_header'; import { BrowserFields, getAllFieldsByName } from '../../containers/source'; import { DetailItem } from '../../graphql/types'; import { OnUpdateColumns } from '../timeline/events'; @@ -21,16 +22,27 @@ import { search } from './helpers'; interface Props { browserFields: BrowserFields; + columnHeaders: ColumnHeader[]; data: DetailItem[]; eventId: string; isLoading: boolean; onUpdateColumns: OnUpdateColumns; timelineId: string; + toggleColumn: (column: ColumnHeader) => void; } /** Renders a table view or JSON view of the `ECS` `data` */ export const EventFieldsBrowser = pure( - ({ browserFields, data, eventId, isLoading, onUpdateColumns, timelineId }) => { + ({ + browserFields, + columnHeaders, + data, + eventId, + isLoading, + onUpdateColumns, + timelineId, + toggleColumn, + }) => { const fieldsByName = getAllFieldsByName(browserFields); return ( ( })} columns={getColumns({ browserFields, + columnHeaders, eventId, isLoading, onUpdateColumns, timelineId, + toggleColumn, })} pagination={false} search={search} diff --git a/x-pack/legacy/plugins/siem/public/components/event_details/stateful_event_details.tsx b/x-pack/legacy/plugins/siem/public/components/event_details/stateful_event_details.tsx index 8908645ba0adb..02d2b28664a3e 100644 --- a/x-pack/legacy/plugins/siem/public/components/event_details/stateful_event_details.tsx +++ b/x-pack/legacy/plugins/siem/public/components/event_details/stateful_event_details.tsx @@ -7,6 +7,7 @@ import * as React from 'react'; import { BrowserFields } from '../../containers/source'; +import { ColumnHeader } from '../timeline/body/column_headers/column_header'; import { DetailItem } from '../../graphql/types'; import { OnUpdateColumns } from '../timeline/events'; @@ -14,11 +15,13 @@ import { EventDetails, View } from './event_details'; interface Props { browserFields: BrowserFields; + columnHeaders: ColumnHeader[]; data: DetailItem[]; id: string; isLoading: boolean; onUpdateColumns: OnUpdateColumns; timelineId: string; + toggleColumn: (column: ColumnHeader) => void; } interface State { @@ -37,11 +40,21 @@ export class StatefulEventDetails extends React.PureComponent { }; public render() { - const { browserFields, data, id, isLoading, onUpdateColumns, timelineId } = this.props; + const { + browserFields, + columnHeaders, + data, + id, + isLoading, + onUpdateColumns, + timelineId, + toggleColumn, + } = this.props; return ( { onUpdateColumns={onUpdateColumns} onViewSelected={this.onViewSelected} timelineId={timelineId} + toggleColumn={toggleColumn} /> ); } diff --git a/x-pack/legacy/plugins/siem/public/components/event_details/translations.ts b/x-pack/legacy/plugins/siem/public/components/event_details/translations.ts index c5e37b2cd7451..efaffaa3e5524 100644 --- a/x-pack/legacy/plugins/siem/public/components/event_details/translations.ts +++ b/x-pack/legacy/plugins/siem/public/components/event_details/translations.ts @@ -37,3 +37,7 @@ export const PLACEHOLDER = i18n.translate('xpack.siem.eventDetails.filter.placeh export const COPY_TO_CLIPBOARD = i18n.translate('xpack.siem.eventDetails.copyToClipboard', { defaultMessage: 'Copy to Clipboard', }); + +export const TOGGLE_COLUMN_TOOLTIP = i18n.translate('xpack.siem.eventDetails.toggleColumnTooltip', { + defaultMessage: 'Toggle column', +}); diff --git a/x-pack/legacy/plugins/siem/public/components/fields_browser/categories_pane.test.tsx b/x-pack/legacy/plugins/siem/public/components/fields_browser/categories_pane.test.tsx new file mode 100644 index 0000000000000..cab97ecc7b9e2 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/fields_browser/categories_pane.test.tsx @@ -0,0 +1,64 @@ +/* + * 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 { mount } from 'enzyme'; +import * as React from 'react'; + +import { mockBrowserFields } from '../../containers/source/mock'; + +import { CATEGORY_PANE_WIDTH } from './helpers'; +import { CategoriesPane } from './categories_pane'; +import * as i18n from './translations'; + +const timelineId = 'test'; + +describe('CategoriesPane', () => { + test('it renders the expected title', () => { + const wrapper = mount( + + ); + + expect( + wrapper + .find('[data-test-subj="categories-pane-title"]') + .first() + .text() + ).toEqual(i18n.CATEGORIES); + }); + + test('it renders a "No fields match" message when filteredBrowserFields is empty', () => { + const wrapper = mount( +
+ +
+ ); + + expect( + wrapper + .find('[data-test-subj="categories-container"] tbody') + .first() + .text() + ).toEqual(i18n.NO_FIELDS_MATCH); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/components/fields_browser/categories_pane.tsx b/x-pack/legacy/plugins/siem/public/components/fields_browser/categories_pane.tsx index 2c7437d99bca8..7423565e42087 100644 --- a/x-pack/legacy/plugins/siem/public/components/fields_browser/categories_pane.tsx +++ b/x-pack/legacy/plugins/siem/public/components/fields_browser/categories_pane.tsx @@ -68,7 +68,7 @@ export const CategoriesPane = pure( }) => ( <> - <h5>{i18n.CATEGORIES}</h5> + <h5 data-test-subj="categories-pane-title">{i18n.CATEGORIES}</h5> diff --git a/x-pack/legacy/plugins/siem/public/components/fields_browser/category.test.tsx b/x-pack/legacy/plugins/siem/public/components/fields_browser/category.test.tsx new file mode 100644 index 0000000000000..3a7e170bde6a0 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/fields_browser/category.test.tsx @@ -0,0 +1,121 @@ +/* + * 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 { mount } from 'enzyme'; +import * as React from 'react'; + +import { mockBrowserFields } from '../../containers/source/mock'; + +import { Category } from './category'; +import { getFieldItems } from './field_items'; +import { FIELDS_PANE_WIDTH } from './helpers'; +import { TestProviders } from '../../mock'; + +import * as i18n from './translations'; + +describe('Category', () => { + const timelineId = 'test'; + const selectedCategoryId = 'client'; + + test('it renders the category id as the value of the title', () => { + const wrapper = mount( + + + + ); + + expect( + wrapper + .find('[data-test-subj="selected-category-title"]') + .first() + .text() + ).toEqual(selectedCategoryId); + }); + + test('it renders the Field column header', () => { + const wrapper = mount( + + + + ); + + expect( + wrapper + .find('.euiTableCellContent__text') + .at(0) + .text() + ).toEqual(i18n.FIELD); + }); + + test('it renders the Description column header', () => { + const wrapper = mount( + + + + ); + + expect( + wrapper + .find('.euiTableCellContent__text') + .at(1) + .text() + ).toEqual(i18n.DESCRIPTION); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/components/fields_browser/category_buttons.tsx b/x-pack/legacy/plugins/siem/public/components/fields_browser/category_buttons.tsx deleted file mode 100644 index 5105538c50fdb..0000000000000 --- a/x-pack/legacy/plugins/siem/public/components/fields_browser/category_buttons.tsx +++ /dev/null @@ -1,42 +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 { EuiButton, EuiToolTip } from '@elastic/eui'; -import * as React from 'react'; -import { pure } from 'recompose'; - -import { - DEFAULT_CATEGORY_NAME, - defaultHeaders, -} from '../timeline/body/column_headers/default_headers'; -import { OnUpdateColumns } from '../timeline/events'; - -import * as i18n from './translations'; - -/** - * The default category button allows the user to reset the fields shown in - * the timeline with a single click - */ -export const DefaultCategoryButton = pure<{ - isLoading: boolean; - onUpdateColumns: OnUpdateColumns; -}>(({ isLoading, onUpdateColumns }) => ( - - { - onUpdateColumns(defaultHeaders); - }} - size="s" - > - {DEFAULT_CATEGORY_NAME} - - -)); - -DefaultCategoryButton.displayName = 'DefaultCategoryButton'; diff --git a/x-pack/legacy/plugins/siem/public/components/fields_browser/category_columns.test.tsx b/x-pack/legacy/plugins/siem/public/components/fields_browser/category_columns.test.tsx new file mode 100644 index 0000000000000..3ae931b2b222d --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/fields_browser/category_columns.test.tsx @@ -0,0 +1,172 @@ +/* + * 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 { mount } from 'enzyme'; +import * as React from 'react'; +import 'jest-styled-components'; + +import { mockBrowserFields } from '../../containers/source/mock'; + +import { CATEGORY_PANE_WIDTH, getFieldCount } from './helpers'; +import { CategoriesPane } from './categories_pane'; + +const timelineId = 'test'; + +describe('getCategoryColumns', () => { + Object.keys(mockBrowserFields).forEach(categoryId => { + test(`it renders the ${categoryId} category name (from filteredBrowserFields)`, () => { + const wrapper = mount( +
+ +
+ ); + + expect( + wrapper + .find(`.field-browser-category-pane-${categoryId}-${timelineId}`) + .first() + .text() + ).toEqual(categoryId); + }); + }); + + Object.keys(mockBrowserFields).forEach(categoryId => { + test(`it renders the correct field count for the ${categoryId} category (from filteredBrowserFields)`, () => { + const wrapper = mount( +
+ +
+ ); + + expect( + wrapper + .find(`[data-test-subj="${categoryId}-category-count"]`) + .first() + .text() + ).toEqual(`${getFieldCount(mockBrowserFields[categoryId])}`); + }); + }); + + test('it renders a hover actions panel for the category name', () => { + const wrapper = mount( +
+ +
+ ); + + expect( + wrapper + .find('[data-test-subj="category-link"]') + .first() + .find('[data-test-subj="hover-actions-panel-container"]') + .first() + .exists() + ).toBe(true); + }); + + test('it renders the selected category with bold text', () => { + const selectedCategoryId = 'auditd'; + + const wrapper = mount( +
+ +
+ ); + + expect( + wrapper.find(`.field-browser-category-pane-${selectedCategoryId}-${timelineId}`).first() + ).toHaveStyleRule('font-weight', 'bold'); + }); + + test('it does NOT render an un-selected category with bold text', () => { + const selectedCategoryId = 'auditd'; + const notTheSelectedCategoryId = 'base'; + + const wrapper = mount( +
+ +
+ ); + + expect( + wrapper.find(`.field-browser-category-pane-${notTheSelectedCategoryId}-${timelineId}`).first() + ).toHaveStyleRule('font-weight', 'normal'); + }); + + test('it invokes onCategorySelected when a user clicks a category', () => { + const selectedCategoryId = 'auditd'; + const notTheSelectedCategoryId = 'base'; + + const onCategorySelected = jest.fn(); + + const wrapper = mount( +
+ +
+ ); + + wrapper + .find(`.field-browser-category-pane-${notTheSelectedCategoryId}-${timelineId}`) + .first() + .simulate('click'); + + expect(onCategorySelected).toHaveBeenCalledWith(notTheSelectedCategoryId); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/components/fields_browser/category_columns.tsx b/x-pack/legacy/plugins/siem/public/components/fields_browser/category_columns.tsx index b5bd86a55b83f..0cbf188a7f27f 100644 --- a/x-pack/legacy/plugins/siem/public/components/fields_browser/category_columns.tsx +++ b/x-pack/legacy/plugins/siem/public/components/fields_browser/category_columns.tsx @@ -78,6 +78,7 @@ export const getCategoryColumns = ({ }) => [ { field: 'categoryId', + name: '', sortable: true, truncateText: false, render: (categoryId: string) => ( @@ -133,7 +134,7 @@ export const getCategoryColumns = ({ - + {getFieldCount(filteredBrowserFields[categoryId])} diff --git a/x-pack/legacy/plugins/siem/public/components/fields_browser/category_title.test.tsx b/x-pack/legacy/plugins/siem/public/components/fields_browser/category_title.test.tsx new file mode 100644 index 0000000000000..d283a8aafab91 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/fields_browser/category_title.test.tsx @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { mount } from 'enzyme'; +import * as React from 'react'; +import 'jest-styled-components'; + +import { mockBrowserFields } from '../../containers/source/mock'; + +import { CategoryTitle } from './category_title'; +import { getFieldCount } from './helpers'; + +describe('CategoryTitle', () => { + const timelineId = 'test'; + + test('it renders the category id as the value of the title', () => { + const categoryId = 'client'; + const wrapper = mount( + + ); + + expect( + wrapper + .find('[data-test-subj="selected-category-title"]') + .first() + .text() + ).toEqual(categoryId); + }); + + test('when `categoryId` specifies a valid category in `filteredBrowserFields`, a count of the field is displayed in the badge', () => { + const validCategoryId = 'client'; + const wrapper = mount( + + ); + + expect( + wrapper + .find(`[data-test-subj="selected-category-count-badge"]`) + .first() + .text() + ).toEqual(`${getFieldCount(mockBrowserFields[validCategoryId])}`); + }); + + test('when `categoryId` specifies an INVALID category in `filteredBrowserFields`, a count of zero is displayed in the badge', () => { + const invalidCategoryId = 'this.is.not.happening'; + const wrapper = mount( + + ); + + expect( + wrapper + .find(`[data-test-subj="selected-category-count-badge"]`) + .first() + .text() + ).toEqual('0'); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/components/fields_browser/category_title.tsx b/x-pack/legacy/plugins/siem/public/components/fields_browser/category_title.tsx index ba84b2d90335e..a81f80334f1ac 100644 --- a/x-pack/legacy/plugins/siem/public/components/fields_browser/category_title.tsx +++ b/x-pack/legacy/plugins/siem/public/components/fields_browser/category_title.tsx @@ -40,7 +40,7 @@ export const CategoryTitle = pure(({ filteredBrowserFields, categoryId, t
{categoryId}
@@ -49,7 +49,9 @@ export const CategoryTitle = pure(({ filteredBrowserFields, categoryId, t - {getFieldCount(filteredBrowserFields[categoryId])} + + {getFieldCount(filteredBrowserFields[categoryId])} + diff --git a/x-pack/legacy/plugins/siem/public/components/fields_browser/field_browser.test.tsx b/x-pack/legacy/plugins/siem/public/components/fields_browser/field_browser.test.tsx new file mode 100644 index 0000000000000..91b561a156f9e --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/fields_browser/field_browser.test.tsx @@ -0,0 +1,259 @@ +/* + * 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 { mount } from 'enzyme'; +import * as React from 'react'; + +import { mockBrowserFields } from '../../containers/source/mock'; +import { TestProviders } from '../../mock'; + +import { FieldsBrowser } from './field_browser'; +import { FIELD_BROWSER_HEIGHT, FIELD_BROWSER_WIDTH } from './helpers'; + +describe('FieldsBrowser', () => { + const timelineId = 'test'; + + // `enzyme` doesn't mount the components into the global jsdom `document` + // but that's where the click detector listener is, so for testing, we + // pass the top-level mounted component's click event on to document + const triggerDocumentMouseDown = () => { + const event = new Event('mousedown'); + document.dispatchEvent(event); + }; + + const triggerDocumentMouseUp = () => { + const event = new Event('mouseup'); + document.dispatchEvent(event); + }; + + test('it invokes onOutsideClick when onFieldSelected is undefined, and the user clicks outside the fields browser', () => { + const onOutsideClick = jest.fn(); + + const wrapper = mount( + +
+ +
+
+ ); + + wrapper.find('[data-test-subj="outside"]').simulate('mousedown'); + wrapper.find('[data-test-subj="outside"]').simulate('mouseup'); + + expect(onOutsideClick).toHaveBeenCalled(); + }); + + test('it does NOT invoke onOutsideClick when onFieldSelected is defined, and the user clicks outside the fields browser', () => { + const onOutsideClick = jest.fn(); + + const wrapper = mount( + +
+ +
+
+ ); + + wrapper.find('[data-test-subj="outside"]').simulate('mousedown'); + wrapper.find('[data-test-subj="outside"]').simulate('mouseup'); + + expect(onOutsideClick).not.toHaveBeenCalled(); + }); + + test('it renders the header', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="header"]').exists()).toBe(true); + }); + + test('it renders the categories pane', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="left-categories-pane"]').exists()).toBe(true); + }); + + test('it renders the fields pane', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="fields-pane"]').exists()).toBe(true); + }); + + test('focuses the search input when the component mounts', () => { + const wrapper = mount( + + + + ); + + expect( + wrapper + .find('[data-test-subj="field-search"]') + .first() + .getDOMNode().id === document.activeElement!.id + ).toBe(true); + }); + + test('it invokes onSearchInputChange when the user types in the field search input', () => { + const onSearchInputChange = jest.fn(); + const inputText = 'event.category'; + + const wrapper = mount( + + + + ); + + const searchField = wrapper.find('[data-test-subj="field-search"]').first(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const changeEvent: any = { target: { value: inputText } }; + const onChange = searchField.props().onChange; + + onChange!(changeEvent); + searchField.simulate('change').update(); + + expect(onSearchInputChange).toBeCalledWith(inputText); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/components/fields_browser/field_browser.tsx b/x-pack/legacy/plugins/siem/public/components/fields_browser/field_browser.tsx index c2c878f6585a8..1453ab56e3df6 100644 --- a/x-pack/legacy/plugins/siem/public/components/fields_browser/field_browser.tsx +++ b/x-pack/legacy/plugins/siem/public/components/fields_browser/field_browser.tsx @@ -25,7 +25,7 @@ import { Header } from './header'; import { CategoriesPane } from './categories_pane'; import { FieldsPane } from './fields_pane'; -const TOP_OFFSET = 207; +const TOP_OFFSET = 267; const FieldsBrowserContainer = styled.div<{ top: number; @@ -148,6 +148,7 @@ export class FieldsBrowser extends React.PureComponent { width={width} >
{ + const timelineId = 'test'; + + describe('getFieldItems', () => { + Object.keys(selectedCategoryFields!).forEach(fieldId => { + test(`it renders the name of the ${fieldId} field`, () => { + const wrapper = mount( + + + + ); + + expect( + wrapper + .find(`[data-test-subj="field-name-${fieldId}"]`) + .first() + .text() + ).toEqual(fieldId); + }); + }); + + Object.keys(selectedCategoryFields!).forEach(fieldId => { + test(`it renders a checkbox for the ${fieldId} field`, () => { + const wrapper = mount( + + + + ); + + expect( + wrapper + .find(`[data-test-subj="field-${fieldId}-checkbox"]`) + .first() + .exists() + ).toBe(true); + }); + }); + + test('it renders a checkbox in the checked state when the field is selected to be displayed as a column in the timeline', () => { + const wrapper = mount( + + + + ); + + expect( + wrapper + .find(`[data-test-subj="field-${timestampFieldId}-checkbox"]`) + .first() + .props().checked + ).toBe(true); + }); + + test('it does NOT render a checkbox in the checked state when the field is NOT selected to be displayed as a column in the timeline', () => { + const wrapper = mount( + + header.id !== timestampFieldId), + highlight: '', + isLoading: false, + onUpdateColumns: jest.fn(), + timelineId, + toggleColumn: jest.fn(), + })} + width={FIELDS_PANE_WIDTH} + onCategorySelected={jest.fn()} + timelineId={timelineId} + /> + + ); + + expect( + wrapper + .find(`[data-test-subj="field-${timestampFieldId}-checkbox"]`) + .first() + .props().checked + ).toBe(false); + }); + + test('it invokes `toggleColumn` when the user interacts with the checkbox', () => { + const toggleColumn = jest.fn(); + + const wrapper = mount( + + + + ); + + wrapper + .find('input[type="checkbox"]') + .first() + .simulate('change', { + target: { checked: true }, + }); + wrapper.update(); + + expect(toggleColumn).toBeCalledWith({ + columnHeaderType: 'not-filtered', + id: '@timestamp', + width: 180, + }); + }); + + test('it renders the expected icon for a field', () => { + const wrapper = mount( + + + + ); + + expect( + wrapper + .find(`[data-test-subj="field-${timestampFieldId}-icon"]`) + .first() + .props().type + ).toEqual('clock'); + }); + + test('it renders the expected field description', () => { + const wrapper = mount( + + + + ); + + expect( + wrapper + .find(`[data-test-subj="field-${timestampFieldId}-description"]`) + .first() + .text() + ).toEqual( + 'Date/time when the event originated. For log events this is the date/time when the event was generated, and not when it was read. Required field for all events. Example: 2016-05-23T08:05:34.853Z' + ); + }); + }); + + describe('getFieldColumns', () => { + test('it returns the expected column definitions', () => { + expect(getFieldColumns().map(column => omit('render', column))).toEqual([ + { field: 'field', name: 'Field', sortable: true, width: '250px' }, + { + field: 'description', + name: 'Description', + sortable: true, + truncateText: true, + width: '400px', + }, + ]); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/components/fields_browser/field_items.tsx b/x-pack/legacy/plugins/siem/public/components/fields_browser/field_items.tsx index 8b8758facbe8e..068c545721c38 100644 --- a/x-pack/legacy/plugins/siem/public/components/fields_browser/field_items.tsx +++ b/x-pack/legacy/plugins/siem/public/components/fields_browser/field_items.tsx @@ -36,6 +36,13 @@ const TypeIcon = styled(EuiIcon)` TypeIcon.displayName = 'TypeIcon'; +export const Description = styled.span` + user-select: text; + width: 150px; +`; + +Description.displayName = 'Description'; + /** * An item rendered in the table */ @@ -73,8 +80,8 @@ export const getFieldItems = ({ ...Object.values(category != null && category.fields != null ? category.fields : {}), ]).map(field => ({ description: ( - - {`${field.description || getEmptyValue()} ${getExampleText(field.example)}`}{' '} + + {`${field.description || getEmptyValue()} ${getExampleText(field.example)}`} ), field: ( @@ -107,22 +114,28 @@ export const getFieldItems = ({ {!snapshot.isDragging ? ( - c.id === field.name) !== -1} - id={field.name || ''} - onChange={() => - toggleColumn({ - columnHeaderType: defaultColumnHeaderType, - id: field.name || '', - width: DEFAULT_COLUMN_MIN_WIDTH, - }) - } - /> + + c.id === field.name) !== -1} + data-test-subj={`field-${field.name}-checkbox`} + id={field.name || ''} + onChange={() => + toggleColumn({ + columnHeaderType: defaultColumnHeaderType, + id: field.name || '', + width: DEFAULT_COLUMN_MIN_WIDTH, + }) + } + /> + - + @@ -170,7 +183,7 @@ export const getFieldColumns = () => [ name: i18n.DESCRIPTION, render: (description: string) => ( - + {description} diff --git a/x-pack/legacy/plugins/siem/public/components/fields_browser/field_name.test.tsx b/x-pack/legacy/plugins/siem/public/components/fields_browser/field_name.test.tsx new file mode 100644 index 0000000000000..afca98b70a625 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/fields_browser/field_name.test.tsx @@ -0,0 +1,152 @@ +/* + * 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 { mount } from 'enzyme'; +import * as React from 'react'; + +import { mockBrowserFields } from '../../containers/source/mock'; +import { TestProviders } from '../../mock'; +import { getColumnsWithTimestamp } from '../event_details/helpers'; + +import { FieldName } from './field_name'; + +const categoryId = 'base'; +const timestampFieldId = '@timestamp'; + +describe('FieldName', () => { + test('it renders the field name', () => { + const wrapper = mount( + + + + ); + + expect( + wrapper + .find(`[data-test-subj="field-name-${timestampFieldId}"]`) + .first() + .text() + ).toEqual(timestampFieldId); + }); + + test('it renders a copy to clipboard action menu item a user hovers over the name', () => { + const wrapper = mount( + + + + ); + + wrapper.simulate('mouseenter'); + wrapper.update(); + expect(wrapper.find('[data-test-subj="copy-to-clipboard"]').exists()).toBe(true); + }); + + test('it renders a view category action menu item a user hovers over the name', () => { + const wrapper = mount( + + + + ); + + wrapper.simulate('mouseenter'); + wrapper.update(); + expect(wrapper.find('[data-test-subj="view-category"]').exists()).toBe(true); + }); + + test('it invokes onUpdateColumns when the view category action menu item is clicked', () => { + const onUpdateColumns = jest.fn(); + + const wrapper = mount( + + + + ); + + wrapper.simulate('mouseenter'); + wrapper.update(); + wrapper + .find('[data-test-subj="view-category"]') + .first() + .simulate('click'); + + expect(onUpdateColumns).toBeCalledWith([ + { + aggregatable: true, + category: 'base', + columnHeaderType: 'not-filtered', + description: + 'Date/time when the event originated. For log events this is the date/time when the event was generated, and not when it was read. Required field for all events.', + example: '2016-05-23T08:05:34.853Z', + id: '@timestamp', + type: 'date', + width: 240, + }, + ]); + }); + + test('it highlights the text specified by the `highlight` prop', () => { + const highlight = 'stamp'; + + const wrapper = mount( + + + + ); + + expect( + wrapper + .find('strong') + .first() + .text() + ).toEqual(highlight); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/components/fields_browser/field_name.tsx b/x-pack/legacy/plugins/siem/public/components/fields_browser/field_name.tsx index 0661d456a25b6..1e2fe28b811d4 100644 --- a/x-pack/legacy/plugins/siem/public/components/fields_browser/field_name.tsx +++ b/x-pack/legacy/plugins/siem/public/components/fields_browser/field_name.tsx @@ -83,7 +83,11 @@ export const FieldName = pure<{ > - + @@ -94,6 +98,7 @@ export const FieldName = pure<{ { onUpdateColumns(categoryColumns); }} @@ -110,7 +115,9 @@ export const FieldName = pure<{ } render={() => ( - {fieldId} + + {fieldId} + )} /> diff --git a/x-pack/legacy/plugins/siem/public/components/fields_browser/fields_pane.test.tsx b/x-pack/legacy/plugins/siem/public/components/fields_browser/fields_pane.test.tsx new file mode 100644 index 0000000000000..3be4fd356ee2d --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/fields_browser/fields_pane.test.tsx @@ -0,0 +1,134 @@ +/* + * 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 { mount } from 'enzyme'; +import * as React from 'react'; + +import { mockBrowserFields } from '../../containers/source/mock'; +import { TestProviders } from '../../mock'; + +import { FIELDS_PANE_WIDTH } from './helpers'; +import { FieldsPane } from './fields_pane'; + +const timelineId = 'test'; + +describe('FieldsPane', () => { + test('it renders the selected category', () => { + const selectedCategory = 'auditd'; + + const wrapper = mount( + + + + ); + + expect( + wrapper + .find(`[data-test-subj="selected-category-title"]`) + .first() + .text() + ).toEqual(selectedCategory); + }); + + test('it renders a unknown category that does not exist in filteredBrowserFields', () => { + const selectedCategory = 'unknown'; + + const wrapper = mount( + + + + ); + + expect( + wrapper + .find(`[data-test-subj="selected-category-title"]`) + .first() + .text() + ).toEqual(selectedCategory); + }); + + test('it renders the expected message when `filteredBrowserFields` is empty and `searchInput` is empty', () => { + const searchInput = ''; + + const wrapper = mount( + + + + ); + + expect( + wrapper + .find(`[data-test-subj="no-fields-match"]`) + .first() + .text() + ).toEqual('No fields match '); + }); + + test('it renders the expected message when `filteredBrowserFields` is empty and `searchInput` is an unknown field name', () => { + const searchInput = 'thisFieldDoesNotExist'; + + const wrapper = mount( + + + + ); + + expect( + wrapper + .find(`[data-test-subj="no-fields-match"]`) + .first() + .text() + ).toEqual(`No fields match ${searchInput}`); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/components/fields_browser/header.test.tsx b/x-pack/legacy/plugins/siem/public/components/fields_browser/header.test.tsx new file mode 100644 index 0000000000000..7e36a028961c4 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/fields_browser/header.test.tsx @@ -0,0 +1,298 @@ +/* + * 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 { mount } from 'enzyme'; +import * as React from 'react'; + +import { mockBrowserFields } from '../../containers/source/mock'; +import { TestProviders } from '../../mock'; +import { defaultHeaders } from '../timeline/body/column_headers/default_headers'; + +import { Header } from './header'; + +const timelineId = 'test'; + +describe('Header', () => { + test('it renders the field browser title', () => { + const wrapper = mount( + +
+ + ); + + expect( + wrapper + .find('[data-test-subj="field-browser-title"]') + .first() + .text() + ).toEqual('Customize Columns'); + }); + + test('it renders the Reset Fields button', () => { + const wrapper = mount( + +
+ + ); + + expect( + wrapper + .find('[data-test-subj="reset-fields"]') + .first() + .text() + ).toEqual('Reset Fields'); + }); + + test('it invokes onUpdateColumns when the user clicks the Reset Fields button', () => { + const onUpdateColumns = jest.fn(); + + const wrapper = mount( + +
+ + ); + + wrapper + .find('[data-test-subj="reset-fields"]') + .first() + .simulate('click'); + + expect(onUpdateColumns).toBeCalledWith(defaultHeaders); + }); + + test('it invokes onOutsideClick when the user clicks the Reset Fields button', () => { + const onOutsideClick = jest.fn(); + + const wrapper = mount( + +
+ + ); + + wrapper + .find('[data-test-subj="reset-fields"]') + .first() + .simulate('click'); + + expect(onOutsideClick).toBeCalled(); + }); + + test('it renders the field search input with the expected placeholder text when the searchInput prop is empty', () => { + const wrapper = mount( + +
+ + ); + + expect( + wrapper + .find('[data-test-subj="field-search"]') + .first() + .props().placeholder + ).toEqual('Field name'); + }); + + test('it renders the "current" search value in the input when searchInput is not empty', () => { + const searchInput = 'aFieldName'; + + const wrapper = mount( + +
+ + ); + + expect(wrapper.find('input').props().value).toEqual(searchInput); + }); + + test('it renders the field search input with a spinner when isSearching is true', () => { + const wrapper = mount( + +
+ + ); + + expect( + wrapper + .find('.euiLoadingSpinner') + .first() + .exists() + ).toBe(true); + }); + + test('it invokes onSearchInputChange when the user types in the search field', () => { + const onSearchInputChange = jest.fn(); + + const wrapper = mount( + +
+ + ); + + wrapper + .find('input') + .first() + .simulate('change', { target: { value: 'timestamp' } }); + wrapper.update(); + + expect(onSearchInputChange).toBeCalled(); + }); + + test('it returns the expected categories count when filteredBrowserFields is empty', () => { + const wrapper = mount( + +
+ + ); + + expect( + wrapper + .find('[data-test-subj="categories-count"]') + .first() + .text() + ).toEqual('0 categories'); + }); + + test('it returns the expected categories count when filteredBrowserFields is NOT empty', () => { + const wrapper = mount( + +
+ + ); + + expect( + wrapper + .find('[data-test-subj="categories-count"]') + .first() + .text() + ).toEqual('9 categories'); + }); + + test('it returns the expected fields count when filteredBrowserFields is empty', () => { + const wrapper = mount( + +
+ + ); + + expect( + wrapper + .find('[data-test-subj="fields-count"]') + .first() + .text() + ).toEqual('0 fields'); + }); + + test('it returns the expected fields count when filteredBrowserFields is NOT empty', () => { + const wrapper = mount( + +
+ + ); + + expect( + wrapper + .find('[data-test-subj="fields-count"]') + .first() + .text() + ).toEqual('25 fields'); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/components/fields_browser/header.tsx b/x-pack/legacy/plugins/siem/public/components/fields_browser/header.tsx index 24368ba4847e5..76d4f0c4b12e2 100644 --- a/x-pack/legacy/plugins/siem/public/components/fields_browser/header.tsx +++ b/x-pack/legacy/plugins/siem/public/components/fields_browser/header.tsx @@ -100,13 +100,14 @@ const TitleRow = pure<{ onOutsideClick: () => void; onUpdateColumns: OnUpdateCol gutterSize="none" > - +

{i18n.CUSTOMIZE_COLUMNS}

{ onUpdateColumns(defaultHeaders); onOutsideClick(); @@ -136,6 +137,7 @@ export const Header = pure( { + describe('getCategoryPaneCategoryClassName', () => { + test('it returns the expected class name', () => { + const categoryId = 'auditd'; + + expect(getCategoryPaneCategoryClassName({ categoryId, timelineId })).toEqual( + 'field-browser-category-pane-auditd-test' + ); + }); + }); + + describe('getFieldBrowserCategoryTitleClassName', () => { + test('it returns the expected class name', () => { + const categoryId = 'auditd'; + + expect(getFieldBrowserCategoryTitleClassName({ categoryId, timelineId })).toEqual( + 'field-browser-category-title-auditd-test' + ); + }); + }); + + describe('getFieldBrowserSearchInputClassName', () => { + test('it returns the expected class name', () => { + expect(getFieldBrowserSearchInputClassName(timelineId)).toEqual( + 'field-browser-search-input-test' + ); + }); + }); + + describe('categoryHasFields', () => { + test('it returns false if the category fields property is undefined', () => { + expect(categoryHasFields({})).toBe(false); + }); + + test('it returns false if the category fields property is empty', () => { + expect(categoryHasFields({ fields: {} })).toBe(false); + }); + + test('it returns true if the category has one field', () => { + expect( + categoryHasFields({ + fields: { + 'auditd.data.a0': { + aggregatable: true, + category: 'auditd', + description: null, + example: null, + indexes: ['auditbeat'], + name: 'auditd.data.a0', + searchable: true, + type: 'string', + }, + }, + }) + ).toBe(true); + }); + + test('it returns true if the category has multiple fields', () => { + expect( + categoryHasFields({ + fields: { + 'agent.ephemeral_id': { + aggregatable: true, + category: 'agent', + description: + 'Ephemeral identifier of this agent (if one exists). This id normally changes across restarts, but `agent.id` does not.', + example: '8a4f500f', + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + name: 'agent.ephemeral_id', + searchable: true, + type: 'string', + }, + 'agent.hostname': { + aggregatable: true, + category: 'agent', + description: null, + example: null, + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + name: 'agent.hostname', + searchable: true, + type: 'string', + }, + }, + }) + ).toBe(true); + }); + }); + + describe('getFieldCount', () => { + test('it returns 0 if the category fields property is undefined', () => { + expect(getFieldCount({})).toEqual(0); + }); + + test('it returns 0 if the category fields property is empty', () => { + expect(getFieldCount({ fields: {} })).toEqual(0); + }); + + test('it returns 1 if the category has one field', () => { + expect( + getFieldCount({ + fields: { + 'auditd.data.a0': { + aggregatable: true, + category: 'auditd', + description: null, + example: null, + indexes: ['auditbeat'], + name: 'auditd.data.a0', + searchable: true, + type: 'string', + }, + }, + }) + ).toEqual(1); + }); + + test('it returns the correct count when category has multiple fields', () => { + expect( + getFieldCount({ + fields: { + 'agent.ephemeral_id': { + aggregatable: true, + category: 'agent', + description: + 'Ephemeral identifier of this agent (if one exists). This id normally changes across restarts, but `agent.id` does not.', + example: '8a4f500f', + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + name: 'agent.ephemeral_id', + searchable: true, + type: 'string', + }, + 'agent.hostname': { + aggregatable: true, + category: 'agent', + description: null, + example: null, + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + name: 'agent.hostname', + searchable: true, + type: 'string', + }, + }, + }) + ).toEqual(2); + }); + }); + + describe('filterBrowserFieldsByFieldName', () => { + test('it returns an empty collection when browserFields is empty', () => { + expect(filterBrowserFieldsByFieldName({ browserFields: {}, substring: '' })).toEqual({}); + }); + + test('it returns an empty collection when browserFields is empty and substring is non empty', () => { + expect( + filterBrowserFieldsByFieldName({ browserFields: {}, substring: 'nothing to match' }) + ).toEqual({}); + }); + + test('it returns an empty collection when browserFields is NOT empty and substring does not match any fields', () => { + expect( + filterBrowserFieldsByFieldName({ + browserFields: mockBrowserFields, + substring: 'nothing to match', + }) + ).toEqual({}); + }); + + test('it returns the original collection when browserFields is NOT empty and substring is empty', () => { + expect( + filterBrowserFieldsByFieldName({ + browserFields: mockBrowserFields, + substring: '', + }) + ).toEqual(mockBrowserFields); + }); + + test('it returns (only) non-empty categories, where each category contains only the fields matching the substring', () => { + const filtered: BrowserFields = { + agent: { + fields: { + 'agent.ephemeral_id': { + aggregatable: true, + category: 'agent', + description: + 'Ephemeral identifier of this agent (if one exists). This id normally changes across restarts, but `agent.id` does not.', + example: '8a4f500f', + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + name: 'agent.ephemeral_id', + searchable: true, + type: 'string', + }, + 'agent.id': { + aggregatable: true, + category: 'agent', + description: + 'Unique identifier of this agent (if one exists). Example: For Beats this would be beat.id.', + example: '8a4f500d', + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + name: 'agent.id', + searchable: true, + type: 'string', + }, + }, + }, + cloud: { + fields: { + 'cloud.account.id': { + aggregatable: true, + category: 'cloud', + description: + 'The cloud account or organization id used to identify different entities in a multi-tenant environment. Examples: AWS account id, Google Cloud ORG Id, or other unique identifier.', + example: '666777888999', + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + name: 'cloud.account.id', + searchable: true, + type: 'string', + }, + }, + }, + container: { + fields: { + 'container.id': { + aggregatable: true, + category: 'container', + description: 'Unique container id.', + example: null, + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + name: 'container.id', + searchable: true, + type: 'string', + }, + }, + }, + }; + + expect( + filterBrowserFieldsByFieldName({ + browserFields: mockBrowserFields, + substring: 'id', + }) + ).toEqual(filtered); + }); + }); + + describe('createVirtualCategory', () => { + test('it combines the specified fields into a virtual category when the input ONLY contains field names that contain dots (e.g. agent.hostname)', () => { + const expectedMatchingFields = { + fields: { + 'agent.hostname': { + aggregatable: true, + category: 'agent', + description: null, + example: null, + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + name: 'agent.hostname', + searchable: true, + type: 'string', + }, + 'client.domain': { + aggregatable: true, + category: 'client', + description: 'Client domain.', + example: null, + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + name: 'client.domain', + searchable: true, + type: 'string', + }, + 'client.geo.country_iso_code': { + aggregatable: true, + category: 'client', + description: 'Country ISO code.', + example: 'CA', + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + name: 'client.geo.country_iso_code', + searchable: true, + type: 'string', + }, + }, + }; + + const fieldIds = ['agent.hostname', 'client.domain', 'client.geo.country_iso_code']; + + expect( + createVirtualCategory({ + browserFields: mockBrowserFields, + fieldIds, + }) + ).toEqual(expectedMatchingFields); + }); + + test('it combines the specified fields into a virtual category when the input includes field names from the base category that do NOT contain dots (e.g. @timestamp)', () => { + const expectedMatchingFields = { + fields: { + 'agent.hostname': { + aggregatable: true, + category: 'agent', + description: null, + example: null, + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + name: 'agent.hostname', + searchable: true, + type: 'string', + }, + '@timestamp': { + aggregatable: true, + category: 'base', + description: + 'Date/time when the event originated. For log events this is the date/time when the event was generated, and not when it was read. Required field for all events.', + example: '2016-05-23T08:05:34.853Z', + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + name: '@timestamp', + searchable: true, + type: 'date', + }, + 'client.domain': { + aggregatable: true, + category: 'client', + description: 'Client domain.', + example: null, + indexes: ['auditbeat', 'filebeat', 'packetbeat'], + name: 'client.domain', + searchable: true, + type: 'string', + }, + }, + }; + + const fieldIds = ['agent.hostname', '@timestamp', 'client.domain']; + + expect( + createVirtualCategory({ + browserFields: mockBrowserFields, + fieldIds, + }) + ).toEqual(expectedMatchingFields); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/components/fields_browser/helpers.tsx b/x-pack/legacy/plugins/siem/public/components/fields_browser/helpers.tsx index 594589be59d6f..e198d802d8a2e 100644 --- a/x-pack/legacy/plugins/siem/public/components/fields_browser/helpers.tsx +++ b/x-pack/legacy/plugins/siem/public/components/fields_browser/helpers.tsx @@ -93,7 +93,7 @@ export const filterBrowserFieldsByFieldName = ({ fields: filter( f => f.name != null && f.name.includes(trimmedSubstring), browserFields[categoryId].fields - ), + ).reduce((filtered, field) => ({ ...filtered, [field.name!]: field }), {}), }, }), {} diff --git a/x-pack/legacy/plugins/siem/public/components/fields_browser/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/fields_browser/index.test.tsx new file mode 100644 index 0000000000000..03017ce145e6d --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/fields_browser/index.test.tsx @@ -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 { mount } from 'enzyme'; +import * as React from 'react'; +import 'jest-styled-components'; + +import { mockBrowserFields } from '../../containers/source/mock'; +import { TestProviders } from '../../mock'; + +import { FIELD_BROWSER_HEIGHT, FIELD_BROWSER_WIDTH } from './helpers'; + +import { StatefulFieldsBrowser } from '.'; + +describe('StatefulFieldsBrowser', () => { + const timelineId = 'test'; + + test('it renders the Fields button, which displays the fields browser on click', () => { + const wrapper = mount( + + + + ); + + expect( + wrapper + .find('[data-test-subj="show-field-browser"]') + .first() + .text() + ).toEqual('Fields'); + }); + + describe('toggleShow', () => { + test('it does NOT render the fields browser until the Fields button is clicked', () => { + const wrapper = mount( + + + + ); + + expect(wrapper.find('[data-test-subj="fields-browser-container"]').exists()).toBe(false); + }); + + test('it renders the fields browser when the Fields button is clicked', () => { + const wrapper = mount( + + + + ); + + wrapper + .find('[data-test-subj="show-field-browser"]') + .first() + .simulate('click'); + + expect(wrapper.find('[data-test-subj="fields-browser-container"]').exists()).toBe(true); + }); + }); + + describe('updateSelectedCategoryId', () => { + test('it updates the selectedCategoryId state, which makes the category bold, when the user clicks a category name in the left hand side of the field browser', () => { + const wrapper = mount( + + + + ); + + wrapper + .find('[data-test-subj="show-field-browser"]') + .first() + .simulate('click'); + + wrapper + .find(`.field-browser-category-pane-auditd-${timelineId}`) + .first() + .simulate('click'); + + wrapper.update(); + + expect( + wrapper.find(`.field-browser-category-pane-auditd-${timelineId}`).first() + ).toHaveStyleRule('font-weight', 'bold'); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/components/fields_browser/index.tsx b/x-pack/legacy/plugins/siem/public/components/fields_browser/index.tsx index b24e9b0021c2e..54197defffa9a 100644 --- a/x-pack/legacy/plugins/siem/public/components/fields_browser/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/fields_browser/index.tsx @@ -100,6 +100,7 @@ export class StatefulFieldsBrowserComponent extends React.PureComponent< isLoading, onFieldSelected, timelineId, + toggleColumn, width, } = this.props; const { @@ -153,7 +154,7 @@ export class StatefulFieldsBrowserComponent extends React.PureComponent< onSearchInputChange={this.updateFilter} selectedCategoryId={selectedCategoryId} timelineId={timelineId} - toggleColumn={this.toggleColumn} + toggleColumn={toggleColumn} width={width} /> )} @@ -168,26 +169,6 @@ export class StatefulFieldsBrowserComponent extends React.PureComponent< })); }; - private toggleColumn = (column: ColumnHeader) => { - const { columnHeaders, removeColumn, timelineId, upsertColumn } = this.props; - const exists = columnHeaders.findIndex(c => c.id === column.id) !== -1; - - if (!exists && upsertColumn != null) { - upsertColumn({ - column, - id: timelineId, - index: 1, - }); - } - - if (exists && removeColumn != null) { - removeColumn({ - columnId: column.id, - id: timelineId, - }); - } - }; - /** Invoked when the user types in the filter input */ private updateFilter = (filterInput: string): void => { this.setState({ diff --git a/x-pack/legacy/plugins/siem/public/components/fields_browser/translations.ts b/x-pack/legacy/plugins/siem/public/components/fields_browser/translations.ts index ce31881dc9353..9446fe1b61bda 100644 --- a/x-pack/legacy/plugins/siem/public/components/fields_browser/translations.ts +++ b/x-pack/legacy/plugins/siem/public/components/fields_browser/translations.ts @@ -66,6 +66,10 @@ export const RESET_FIELDS = i18n.translate('xpack.siem.fieldBrowser.resetFieldsL defaultMessage: 'Reset Fields', }); +export const TOGGLE_COLUMN_TOOLTIP = i18n.translate('xpack.siem.fieldBrowser.toggleColumnTooltip', { + defaultMessage: 'Toggle column', +}); + export const VIEW_CATEGORY = (categoryId: string) => i18n.translate('xpack.siem.fieldBrowser.viewCategoryTooltip', { defaultMessage: 'View all {categoryId} fields', diff --git a/x-pack/legacy/plugins/siem/public/components/fields_browser/types.ts b/x-pack/legacy/plugins/siem/public/components/fields_browser/types.ts index e10502bf57f46..39b4d68ff3561 100644 --- a/x-pack/legacy/plugins/siem/public/components/fields_browser/types.ts +++ b/x-pack/legacy/plugins/siem/public/components/fields_browser/types.ts @@ -30,6 +30,8 @@ export interface FieldBrowserProps { onUpdateColumns: OnUpdateColumns; /** The timeline associated with this field browser */ timelineId: string; + /** Adds or removes a column to / from the timeline */ + toggleColumn: (column: ColumnHeader) => void; /** The width of the field browser */ width: number; } diff --git a/x-pack/legacy/plugins/siem/public/components/page/manage_query.tsx b/x-pack/legacy/plugins/siem/public/components/page/manage_query.tsx index c1009063685f1..e5d19dcdba803 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/manage_query.tsx +++ b/x-pack/legacy/plugins/siem/public/components/page/manage_query.tsx @@ -34,7 +34,7 @@ export function manageQuery(WrappedComponent: React.ComponentClass | React public render() { const otherProps = omit(['refetch', 'setQuery'], this.props); - return ; + return ; } } ManageQuery.displayName = `ManageQuery (${WrappedComponent.displayName || 'Unknown'})`; diff --git a/x-pack/legacy/plugins/siem/public/components/pin/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/pin/index.test.tsx index a739104423061..657976e2f4787 100644 --- a/x-pack/legacy/plugins/siem/public/components/pin/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/pin/index.test.tsx @@ -4,16 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ -import { getPinRotation } from './'; +import { getPinIcon } from './'; describe('pin', () => { describe('getPinRotation', () => { - test('it returns a vertical pin when pinned is true', () => { - expect(getPinRotation(true)).toEqual('rotate(0)'); + test('it returns a filled pin when pinned is true', () => { + expect(getPinIcon(true)).toEqual('pinFilled'); }); - test('it returns a rotated (UNpinned) pin when pinned is false', () => { - expect(getPinRotation(false)).toEqual('rotate(45)'); + test('it returns an non-filled pin when pinned is false', () => { + expect(getPinIcon(false)).toEqual('pin'); }); }); }); diff --git a/x-pack/legacy/plugins/siem/public/components/pin/index.tsx b/x-pack/legacy/plugins/siem/public/components/pin/index.tsx index 699adaff067dd..d72c4ed937d81 100644 --- a/x-pack/legacy/plugins/siem/public/components/pin/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/pin/index.tsx @@ -4,23 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiIcon } from '@elastic/eui'; +import { EuiButtonIcon } from '@elastic/eui'; import { noop } from 'lodash/fp'; import * as React from 'react'; import { pure } from 'recompose'; import styled from 'styled-components'; -export type PinRotation = 'rotate(0)' | 'rotate(45)'; +import * as i18n from '../../components/timeline/body/translations'; -export const getPinRotation = (pinned: boolean): PinRotation => - pinned ? 'rotate(0)' : 'rotate(45)'; +export type PinIcon = 'pin' | 'pinFilled'; -const PinIcon = styled(EuiIcon)<{ transform: string }>` - overflow: hidden; - transform: ${({ transform }) => transform}; -`; - -PinIcon.displayName = 'PinIcon'; +export const getPinIcon = (pinned: boolean): PinIcon => (pinned ? 'pinFilled' : 'pin'); interface Props { allowUnpinning: boolean; @@ -28,16 +22,27 @@ interface Props { onClick?: () => void; } +const PinButtonIcon = styled(EuiButtonIcon)<{ pinned: string }>` + svg { + ${({ pinned, theme }) => (pinned === 'true' ? `fill: ${theme.eui.euiColorPrimary};` : '')} + height: 22px; + ${({ pinned }) => `left: ${pinned === 'true' ? '-2' : '-1'}`}px; + position: relative; + width: 22px; + } +`; + export const Pin = pure(({ allowUnpinning, pinned, onClick = noop }) => ( - )); diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/__snapshots__/timeline.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/timeline/__snapshots__/timeline.test.tsx.snap index 05fb08a6bf6ec..8747ef8094473 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/__snapshots__/timeline.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/components/timeline/__snapshots__/timeline.test.tsx.snap @@ -749,5 +749,6 @@ In other use cases the message field can be used to concatenate different values } } start={1521830963132} + toggleColumn={[MockFunction]} /> `; diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/body/actions/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/body/actions/index.test.tsx index 88dcca0ce465e..977deec6e0ece 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/body/actions/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/body/actions/index.test.tsx @@ -150,7 +150,7 @@ describe('Actions', () => { ); wrapper - .find('[data-test-subj="pin-event"]') + .find('[data-test-subj="pin"]') .first() .simulate('click'); diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/body/actions/index.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/body/actions/index.tsx index 6bd1563e43a92..8753cf7c20a46 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/body/actions/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/body/actions/index.tsx @@ -66,6 +66,8 @@ const ActionLoading = styled(EuiLoadingSpinner)` ActionLoading.displayName = 'ActionLoading'; const PinContainer = styled.div` + position: relative; + top: -1px; width: 27px; `; @@ -79,6 +81,8 @@ SelectEventContainer.displayName = 'SelectEventContainer'; const NotesButtonContainer = styled(EuiFlexItem)` margin-left: 5px; + position: relative; + top: -3px; `; NotesButtonContainer.displayName = 'NotesButtonContainer'; diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap index f08f66a64d7b7..8aa88f06d921b 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/__snapshots__/index.test.tsx.snap @@ -446,5 +446,6 @@ exports[`ColumnHeaders rendering renders correctly against snapshot 1`] = ` } } timelineId="test" + toggleColumn={[MockFunction]} /> `; diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/header/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/header/index.test.tsx index e152ca27af0fa..57be95b5d2c0d 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/header/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/header/index.test.tsx @@ -64,7 +64,7 @@ describe('Header', () => { expect( wrapper - .find('[data-test-subj="header-text"]') + .find(`[data-test-subj="header-text-${columnHeader.id}"]`) .first() .text() ).toEqual(columnHeader.id); @@ -363,7 +363,7 @@ describe('Header', () => { ); - expect(wrapper.find('[data-test-subj="header-text"]')).toHaveStyleRule( + expect(wrapper.find(`[data-test-subj="header-text-${columnHeader.id}"]`)).toHaveStyleRule( 'text-overflow', 'ellipsis' ); diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/header/index.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/header/index.tsx index a0b7e97769d00..20e633701454e 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/header/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/header/index.tsx @@ -135,7 +135,7 @@ export class Header extends React.PureComponent { { showEventsSelect={false} sort={sort} timelineId={'test'} + toggleColumn={jest.fn()} /> ); expect(toJson(wrapper)).toMatchSnapshot(); @@ -68,6 +69,7 @@ describe('ColumnHeaders', () => { showEventsSelect={false} sort={sort} timelineId={'test'} + toggleColumn={jest.fn()} /> ); @@ -96,6 +98,7 @@ describe('ColumnHeaders', () => { showEventsSelect={false} sort={sort} timelineId={'test'} + toggleColumn={jest.fn()} /> ); @@ -126,6 +129,7 @@ describe('ColumnHeaders', () => { showEventsSelect={false} sort={sort} timelineId={'test'} + toggleColumn={jest.fn()} /> ); diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/index.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/index.tsx index 7721141835f14..9e5cb3dc38b88 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/body/column_headers/index.tsx @@ -56,6 +56,7 @@ interface Props { showEventsSelect: boolean; sort: Sort; timelineId: string; + toggleColumn: (column: ColumnHeader) => void; minWidth: number; } @@ -101,6 +102,7 @@ export const ColumnHeaders = pure( showEventsSelect, sort, timelineId, + toggleColumn, minWidth, }) => { const { isResizing, setIsResizing } = isContainerResizing(); @@ -132,6 +134,7 @@ export const ColumnHeaders = pure( isLoading={isLoading} onUpdateColumns={onUpdateColumns} timelineId={timelineId} + toggleColumn={toggleColumn} width={FIELD_BROWSER_WIDTH} /> diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/body/events/index.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/body/events/index.tsx index 81e5ecf7ba734..fb378e0288c37 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/body/events/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/body/events/index.tsx @@ -48,6 +48,7 @@ interface Props { minWidth: number; pinnedEventIds: Readonly>; rowRenderers: RowRenderer[]; + toggleColumn: (column: ColumnHeader) => void; updateNote: UpdateNote; width: number; } @@ -74,6 +75,7 @@ export class Events extends React.PureComponent { onUnPinEvent, pinnedEventIds, rowRenderers, + toggleColumn, updateNote, width, } = this.props; @@ -100,6 +102,7 @@ export class Events extends React.PureComponent { pinnedEventIds={pinnedEventIds} rowRenderers={rowRenderers} timelineId={id} + toggleColumn={toggleColumn} updateNote={updateNote} width={width} /> diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/body/events/stateful_event.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/body/events/stateful_event.tsx index 13e89de151524..24ab838dc5f8b 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/body/events/stateful_event.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/body/events/stateful_event.tsx @@ -40,6 +40,7 @@ interface Props { pinnedEventIds: Readonly>; rowRenderers: RowRenderer[]; timelineId: string; + toggleColumn: (column: ColumnHeader) => void; updateNote: UpdateNote; width: number; } @@ -77,6 +78,7 @@ export class StatefulEvent extends React.PureComponent { pinnedEventIds, rowRenderers, timelineId, + toggleColumn, updateNote, width, } = this.props; @@ -140,12 +142,14 @@ export class StatefulEvent extends React.PureComponent { diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/body/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/body/index.test.tsx index 17f7fa090e38e..f213808a1d1f0 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/body/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/body/index.test.tsx @@ -51,6 +51,7 @@ describe('Body', () => { range={'1 Day'} rowRenderers={rowRenderers} sort={mockSort} + toggleColumn={jest.fn()} updateNote={jest.fn()} width={100} /> @@ -90,6 +91,7 @@ describe('Body', () => { range={'1 Day'} rowRenderers={rowRenderers} sort={mockSort} + toggleColumn={jest.fn()} updateNote={jest.fn()} width={100} /> @@ -129,6 +131,7 @@ describe('Body', () => { range={'1 Day'} rowRenderers={rowRenderers} sort={mockSort} + toggleColumn={jest.fn()} updateNote={jest.fn()} width={100} /> @@ -170,6 +173,7 @@ describe('Body', () => { range={'1 Day'} rowRenderers={rowRenderers} sort={mockSort} + toggleColumn={jest.fn()} updateNote={jest.fn()} width={100} /> diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/body/index.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/body/index.tsx index 0268073837aa0..269ea02858de1 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/body/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/body/index.tsx @@ -54,6 +54,7 @@ interface Props { range: string; rowRenderers: RowRenderer[]; sort: Sort; + toggleColumn: (column: ColumnHeader) => void; updateNote: UpdateNote; width: number; } @@ -106,6 +107,7 @@ export const Body = pure( pinnedEventIds, rowRenderers, sort, + toggleColumn, updateNote, width, }) => { @@ -130,6 +132,7 @@ export const Body = pure( showEventsSelect={false} sort={sort} timelineId={id} + toggleColumn={toggleColumn} minWidth={columnWidths} /> @@ -155,6 +158,7 @@ export const Body = pure( onUnPinEvent={onUnPinEvent} pinnedEventIds={pinnedEventIds} rowRenderers={rowRenderers} + toggleColumn={toggleColumn} updateNote={updateNote} minWidth={columnWidths} width={width} diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/body/stateful_body.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/body/stateful_body.tsx index 71647d87f6cd1..799f9b29a5543 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/body/stateful_body.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/body/stateful_body.tsx @@ -39,6 +39,7 @@ interface OwnProps { isLoading: boolean; height: number; sort: Sort; + toggleColumn: (column: ColumnHeader) => void; width: number; } @@ -96,6 +97,7 @@ class StatefulBodyComponent extends React.PureComponent diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/expandable_event/index.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/expandable_event/index.tsx index 07ad7e99a2164..0c02d4a1b805c 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/expandable_event/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/expandable_event/index.tsx @@ -8,6 +8,7 @@ import * as React from 'react'; import styled from 'styled-components'; import { BrowserFields } from '../../../containers/source'; +import { ColumnHeader } from '../body/column_headers/column_header'; import { DetailItem } from '../../../graphql/types'; import { StatefulEventDetails } from '../../event_details/stateful_event_details'; import { LazyAccordion } from '../../lazy_accordion'; @@ -18,9 +19,8 @@ const ExpandableDetails = styled.div<{ hideExpandButton: boolean; width?: number ${({ hideExpandButton }) => hideExpandButton ? ` - .euiAccordion__button svg { - width: 0px; - height: 0px; + .euiAccordion__button { + display: none; } ` : ''}; @@ -30,6 +30,7 @@ ExpandableDetails.displayName = 'ExpandableDetails'; interface Props { browserFields: BrowserFields; + columnHeaders: ColumnHeader[]; id: string; event: DetailItem[]; forceExpand?: boolean; @@ -37,12 +38,24 @@ interface Props { isLoading: boolean; onUpdateColumns: OnUpdateColumns; timelineId: string; + toggleColumn: (column: ColumnHeader) => void; width?: number; } export class ExpandableEvent extends React.PureComponent { public render() { - const { forceExpand = false, id, timelineId, width } = this.props; + const { + browserFields, + columnHeaders, + event, + forceExpand = false, + id, + isLoading, + timelineId, + toggleColumn, + onUpdateColumns, + width, + } = this.props; return ( { > ( + + )} forceExpand={forceExpand} paddingSize="none" /> ); } - - private renderExpandedContent = () => { - const { browserFields, event, id, isLoading, onUpdateColumns, timelineId } = this.props; - - return ( - - ); - }; } diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/footer/index.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/footer/index.tsx index f19e6e334226d..6487bbc78a374 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/footer/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/footer/index.tsx @@ -134,7 +134,10 @@ export const EventsCount = pure<{ - {serverSideEventCount} {i18n.EVENTS} + + {serverSideEventCount} + {' '} + {i18n.EVENTS} diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/index.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/index.tsx index 69632d8fd667b..153ca2abd24d1 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/index.tsx @@ -80,6 +80,10 @@ interface DispatchProps { id: string; providers: DataProvider[]; }>; + removeColumn?: ActionCreator<{ + id: string; + columnId: string; + }>; removeProvider?: ActionCreator<{ id: string; providerId: string; @@ -118,6 +122,11 @@ interface DispatchProps { id: string; providerId: string; }>; + upsertColumn?: ActionCreator<{ + column: ColumnHeader; + id: string; + index: number; + }>; } type Props = OwnProps & StateReduxProps & DispatchProps; @@ -215,6 +224,7 @@ class StatefulTimelineComponent extends React.Component { showCallOutUnauthorizedMsg={showCallOutUnauthorizedMsg} start={start} sort={sort!} + toggleColumn={this.toggleColumn} /> )} @@ -276,6 +286,26 @@ class StatefulTimelineComponent extends React.Component { private onChangeDroppableAndProvider: OnChangeDroppableAndProvider = providerId => this.props.updateHighlightedDropAndProviderId!({ id: this.props.id, providerId }); + + private toggleColumn = (column: ColumnHeader) => { + const { columns, removeColumn, id, upsertColumn } = this.props; + const exists = columns.findIndex(c => c.id === column.id) !== -1; + + if (!exists && upsertColumn != null) { + upsertColumn({ + column, + id, + index: 1, + }); + } + + if (exists && removeColumn != null) { + removeColumn({ + columnId: column.id, + id, + }); + } + }; } const makeMapStateToProps = () => { @@ -331,5 +361,7 @@ export const StatefulTimeline = connect( updateItemsPerPageOptions: timelineActions.updateItemsPerPageOptions, updateSort: timelineActions.updateSort, removeProvider: timelineActions.removeProvider, + removeColumn: timelineActions.removeColumn, + upsertColumn: timelineActions.upsertColumn, } )(StatefulTimelineComponent); diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/properties/helpers.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/properties/helpers.tsx index 9ab250b32777f..be1603a474a8a 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/properties/helpers.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/properties/helpers.tsx @@ -9,6 +9,7 @@ import { EuiBadgeProps, EuiButton, EuiButtonEmpty, + EuiButtonIcon, EuiFieldText, EuiFlexGroup, EuiFlexItem, @@ -20,6 +21,7 @@ import { import * as React from 'react'; import { pure } from 'recompose'; import uuid from 'uuid'; +import styled from 'styled-components'; import { Note } from '../../../lib/note'; import { Notes } from '../../notes'; @@ -154,12 +156,20 @@ interface NotesButtonProps { const getNewNoteId = (): string => uuid.v4(); +const NotesButtonIcon = styled(EuiButtonIcon)` + svg { + height: 24px; + width: 24px; + } +`; + const NotesIcon = pure<{ count: number }>(({ count }) => ( - 0 ? 'primary' : 'subdued'} data-test-subj="timeline-notes-icon" size="l" - type="editorComment" + iconType="editorComment" /> )); diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/timeline.test.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/timeline.test.tsx index 3dc223bbda41f..643251230a1e0 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/timeline.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/timeline.test.tsx @@ -70,6 +70,7 @@ describe('Timeline', () => { showCallOutUnauthorizedMsg={false} start={startDate} sort={sort} + toggleColumn={jest.fn()} /> ); expect(toJson(wrapper)).toMatchSnapshot(); @@ -104,6 +105,7 @@ describe('Timeline', () => { showCallOutUnauthorizedMsg={false} start={startDate} sort={sort} + toggleColumn={jest.fn()} /> @@ -141,6 +143,7 @@ describe('Timeline', () => { showCallOutUnauthorizedMsg={false} start={startDate} sort={sort} + toggleColumn={jest.fn()} /> @@ -178,6 +181,7 @@ describe('Timeline', () => { showCallOutUnauthorizedMsg={false} start={startDate} sort={sort} + toggleColumn={jest.fn()} /> @@ -220,6 +224,7 @@ describe('Timeline', () => { showCallOutUnauthorizedMsg={false} start={startDate} sort={sort} + toggleColumn={jest.fn()} /> @@ -264,6 +269,7 @@ describe('Timeline', () => { showCallOutUnauthorizedMsg={false} start={startDate} sort={sort} + toggleColumn={jest.fn()} /> @@ -316,6 +322,7 @@ describe('Timeline', () => { showCallOutUnauthorizedMsg={false} start={startDate} sort={sort} + toggleColumn={jest.fn()} /> @@ -372,6 +379,7 @@ describe('Timeline', () => { showCallOutUnauthorizedMsg={false} start={startDate} sort={sort} + toggleColumn={jest.fn()} /> @@ -431,6 +439,7 @@ describe('Timeline', () => { showCallOutUnauthorizedMsg={false} start={startDate} sort={sort} + toggleColumn={jest.fn()} /> @@ -480,6 +489,7 @@ describe('Timeline', () => { showCallOutUnauthorizedMsg={false} start={startDate} sort={sort} + toggleColumn={jest.fn()} /> @@ -535,6 +545,7 @@ describe('Timeline', () => { showCallOutUnauthorizedMsg={false} start={startDate} sort={sort} + toggleColumn={jest.fn()} /> @@ -594,6 +605,7 @@ describe('Timeline', () => { showCallOutUnauthorizedMsg={false} start={startDate} sort={sort} + toggleColumn={jest.fn()} /> diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/timeline.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/timeline.tsx index e416a78121716..d47c962ba3de9 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/timeline.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/timeline.tsx @@ -78,6 +78,7 @@ interface Props { showCallOutUnauthorizedMsg: boolean; start: number; sort: Sort; + toggleColumn: (column: ColumnHeader) => void; } /** The parent Timeline component */ @@ -107,6 +108,7 @@ export const Timeline = pure( showCallOutUnauthorizedMsg, start, sort, + toggleColumn, }) => { const combinedQueries = combineQueries( dataProviders, @@ -181,6 +183,7 @@ export const Timeline = pure( timelineFooterHeight: footerHeight, })} sort={sort} + toggleColumn={toggleColumn} width={width} />