From eab0485fa390a5d950c40bed7c0e19fe0d3a698c Mon Sep 17 00:00:00 2001 From: Josh Dover <1813008+joshdover@users.noreply.github.com> Date: Thu, 6 Jan 2022 13:10:12 +0100 Subject: [PATCH 1/6] Add requireAllSpaces and disable options to FeatureKibanaPrivileges (#118001) Co-authored-by: Larry Gregory Co-authored-by: criamico Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Xavier Mouligneau Co-authored-by: Joe Portner --- .../common/feature_kibana_privileges.ts | 12 + .../plugins/features/server/feature_schema.ts | 2 + .../roles/__fixtures__/kibana_features.ts | 14 +- .../feature_table/feature_table.test.tsx | 1 + .../kibana/feature_table/feature_table.tsx | 33 +- .../privilege_form_calculator.ts | 52 ++- .../privilege_summary_table.tsx | 20 +- .../privilege_space_form.test.tsx | 303 +++++++++++++++++- .../privilege_space_form.tsx | 84 +++-- .../roles/model/kibana_privileges.ts | 2 +- .../roles/model/primary_feature_privilege.ts | 8 + .../roles/elasticsearch_role.test.ts | 251 +++++++++++++++ .../authorization/roles/elasticsearch_role.ts | 43 ++- .../privilege_deprecations.test.ts | 10 +- .../deprecations/privilege_deprecations.ts | 25 +- x-pack/plugins/security/server/plugin.ts | 10 +- .../routes/authorization/roles/get.test.ts | 2 + .../server/routes/authorization/roles/get.ts | 11 +- .../authorization/roles/get_all.test.ts | 2 + .../routes/authorization/roles/get_all.ts | 9 +- .../routes/authorization/roles/model/index.ts | 6 +- .../roles/model/put_payload.test.ts | 122 ++++++- .../authorization/roles/model/put_payload.ts | 50 ++- .../routes/authorization/roles/put.test.ts | 134 +++++--- .../server/routes/authorization/roles/put.ts | 38 ++- 25 files changed, 1117 insertions(+), 127 deletions(-) create mode 100644 x-pack/plugins/security/server/authorization/roles/elasticsearch_role.test.ts diff --git a/x-pack/plugins/features/common/feature_kibana_privileges.ts b/x-pack/plugins/features/common/feature_kibana_privileges.ts index cb403ce673f11..52cc792152ebd 100644 --- a/x-pack/plugins/features/common/feature_kibana_privileges.ts +++ b/x-pack/plugins/features/common/feature_kibana_privileges.ts @@ -14,6 +14,18 @@ export interface FeatureKibanaPrivileges { */ excludeFromBasePrivileges?: boolean; + /** + * Whether or not this privilege should only be granted to `All Spaces *`. Should be used for features that do not + * support Spaces. Defaults to `false`. + */ + requireAllSpaces?: boolean; + + /** + * Whether or not this privilege should be hidden in the roles UI and disallowed on the API. Defaults to `false`. + * @deprecated + */ + disabled?: boolean; + /** * If this feature includes management sections, you can specify them here to control visibility of those * pages based on user privileges. diff --git a/x-pack/plugins/features/server/feature_schema.ts b/x-pack/plugins/features/server/feature_schema.ts index 2694620c62d3f..cf6c44af07470 100644 --- a/x-pack/plugins/features/server/feature_schema.ts +++ b/x-pack/plugins/features/server/feature_schema.ts @@ -75,6 +75,8 @@ const appCategorySchema = schema.object({ const kibanaPrivilegeSchema = schema.object({ excludeFromBasePrivileges: schema.maybe(schema.boolean()), + requireAllSpaces: schema.maybe(schema.boolean()), + disabled: schema.maybe(schema.boolean()), management: schema.maybe(managementSchema), catalogue: schema.maybe(catalogueSchema), api: schema.maybe(schema.arrayOf(schema.string())), diff --git a/x-pack/plugins/security/public/management/roles/__fixtures__/kibana_features.ts b/x-pack/plugins/security/public/management/roles/__fixtures__/kibana_features.ts index 7bb0ba254c830..5132757e84613 100644 --- a/x-pack/plugins/security/public/management/roles/__fixtures__/kibana_features.ts +++ b/x-pack/plugins/security/public/management/roles/__fixtures__/kibana_features.ts @@ -17,9 +17,19 @@ export const createFeature = ( excludeFromBaseRead?: boolean; privileges?: KibanaFeatureConfig['privileges']; category?: KibanaFeatureConfig['category']; + requireAllSpacesOnAllPrivilege?: boolean; + disabledReadPrivilege?: boolean; } ) => { - const { excludeFromBaseAll, excludeFromBaseRead, privileges, category, ...rest } = config; + const { + excludeFromBaseAll, + excludeFromBaseRead, + privileges, + category, + requireAllSpacesOnAllPrivilege: requireAllSpaces = false, + disabledReadPrivilege: disabled = false, + ...rest + } = config; return new KibanaFeature({ app: [], category: category ?? { id: 'foo', label: 'foo' }, @@ -35,6 +45,7 @@ export const createFeature = ( read: ['read-type'], }, ui: ['read-ui', 'all-ui', `read-${config.id}`, `all-${config.id}`], + requireAllSpaces, }, read: { excludeFromBasePrivileges: excludeFromBaseRead, @@ -43,6 +54,7 @@ export const createFeature = ( read: ['read-type'], }, ui: ['read-ui', `read-${config.id}`], + disabled, }, }, ...rest, diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table.test.tsx index a7fab418f42cc..0cc4c4281b38f 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table.test.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table.test.tsx @@ -49,6 +49,7 @@ const setup = (config: TestConfig) => { onChangeAll={onChangeAll} canCustomizeSubFeaturePrivileges={config.canCustomizeSubFeaturePrivileges} privilegeIndex={config.privilegeIndex} + allSpacesSelected={true} /> ); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table.tsx index 2abc07b4e3b21..e14c28db3e740 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/feature_table/feature_table.tsx @@ -7,7 +7,7 @@ import './feature_table.scss'; -import type { EuiAccordionProps } from '@elastic/eui'; +import type { EuiAccordionProps, EuiButtonGroupOptionProps } from '@elastic/eui'; import { EuiAccordion, EuiButtonGroup, @@ -44,6 +44,7 @@ interface Props { onChange: (featureId: string, privileges: string[]) => void; onChangeAll: (privileges: string[]) => void; canCustomizeSubFeaturePrivileges: boolean; + allSpacesSelected: boolean; disabled?: boolean; } @@ -84,7 +85,8 @@ export class FeatureTable extends Component { (feature) => this.props.privilegeCalculator.getEffectivePrimaryFeaturePrivilege( feature.id, - this.props.privilegeIndex + this.props.privilegeIndex, + this.props.allSpacesSelected ) != null ).length; @@ -269,28 +271,33 @@ export class FeatureTable extends Component { const selectedPrivilegeId = this.props.privilegeCalculator.getDisplayedPrimaryFeaturePrivilegeId( feature.id, - this.props.privilegeIndex + this.props.privilegeIndex, + this.props.allSpacesSelected ); - - const options = primaryFeaturePrivileges.map((privilege) => { - return { - id: `${feature.id}_${privilege.id}`, - label: privilege.name, - isDisabled: this.props.disabled, - }; - }); + const options: EuiButtonGroupOptionProps[] = primaryFeaturePrivileges + .filter((privilege) => !privilege.disabled) // Don't show buttons for privileges that are disabled + .map((privilege) => { + const disabledDueToSpaceSelection = + privilege.requireAllSpaces && !this.props.allSpacesSelected; + return { + id: `${feature.id}_${privilege.id}`, + label: privilege.name, + isDisabled: this.props.disabled || disabledDueToSpaceSelection, + }; + }); options.push({ id: `${feature.id}_${NO_PRIVILEGE_VALUE}`, label: 'None', - isDisabled: this.props.disabled, + isDisabled: this.props.disabled ?? false, }); let warningIcon = ; if ( this.props.privilegeCalculator.hasCustomizedSubFeaturePrivileges( feature.id, - this.props.privilegeIndex + this.props.privilegeIndex, + this.props.allSpacesSelected ) ) { warningIcon = ( diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_form_calculator/privilege_form_calculator.ts b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_form_calculator/privilege_form_calculator.ts index 897ac36664f08..796cfea92b43e 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_form_calculator/privilege_form_calculator.ts +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_form_calculator/privilege_form_calculator.ts @@ -26,7 +26,6 @@ export class PrivilegeFormCalculator { */ public getBasePrivilege(privilegeIndex: number) { const entry = this.role.kibana[privilegeIndex]; - const basePrivileges = this.kibanaPrivileges.getBasePrivileges(entry); return basePrivileges.find((bp) => entry.base.includes(bp.id)); } @@ -49,8 +48,13 @@ export class PrivilegeFormCalculator { * @param featureId the feature id to get the Primary Feature KibanaPrivilege for. * @param privilegeIndex the index of the kibana privileges role component */ - public getDisplayedPrimaryFeaturePrivilegeId(featureId: string, privilegeIndex: number) { - return this.getDisplayedPrimaryFeaturePrivilege(featureId, privilegeIndex)?.id; + public getDisplayedPrimaryFeaturePrivilegeId( + featureId: string, + privilegeIndex: number, + allSpacesSelected?: boolean + ) { + return this.getDisplayedPrimaryFeaturePrivilege(featureId, privilegeIndex, allSpacesSelected) + ?.id; } /** @@ -59,10 +63,18 @@ export class PrivilegeFormCalculator { * @param featureId the feature id * @param privilegeIndex the index of the kibana privileges role component */ - public hasCustomizedSubFeaturePrivileges(featureId: string, privilegeIndex: number) { + public hasCustomizedSubFeaturePrivileges( + featureId: string, + privilegeIndex: number, + allSpacesSelected?: boolean + ) { const feature = this.kibanaPrivileges.getSecuredFeature(featureId); - const displayedPrimary = this.getDisplayedPrimaryFeaturePrivilege(featureId, privilegeIndex); + const displayedPrimary = this.getDisplayedPrimaryFeaturePrivilege( + featureId, + privilegeIndex, + allSpacesSelected + ); const formPrivileges = this.kibanaPrivileges.createCollectionFromRoleKibanaPrivileges([ this.role.kibana[privilegeIndex], @@ -81,19 +93,27 @@ export class PrivilegeFormCalculator { * * @param featureId the feature id * @param privilegeIndex the index of the kibana privileges role component + * @param allSpacesSelected indicates if the privilege form is configured to grant access to all spaces. */ - public getEffectivePrimaryFeaturePrivilege(featureId: string, privilegeIndex: number) { + public getEffectivePrimaryFeaturePrivilege( + featureId: string, + privilegeIndex: number, + allSpacesSelected?: boolean + ) { const feature = this.kibanaPrivileges.getSecuredFeature(featureId); const basePrivilege = this.getBasePrivilege(privilegeIndex); const selectedFeaturePrivileges = this.getSelectedFeaturePrivileges(featureId, privilegeIndex); - return feature + const effectivePrivilege = feature .getPrimaryFeaturePrivileges({ includeMinimalFeaturePrivileges: true }) .find((fp) => { return selectedFeaturePrivileges.includes(fp.id) || basePrivilege?.grantsPrivilege(fp); }); + const correctSpacesSelected = effectivePrivilege?.requireAllSpaces ? allSpacesSelected : true; + const availablePrivileges = correctSpacesSelected && !effectivePrivilege?.disabled; + if (availablePrivileges) return effectivePrivilege; } /** @@ -264,25 +284,29 @@ export class PrivilegeFormCalculator { * @param featureId the feature id to get the Primary Feature KibanaPrivilege for. * @param privilegeIndex the index of the kibana privileges role component */ - private getDisplayedPrimaryFeaturePrivilege(featureId: string, privilegeIndex: number) { + private getDisplayedPrimaryFeaturePrivilege( + featureId: string, + privilegeIndex: number, + allSpacesSelected?: boolean + ) { const feature = this.kibanaPrivileges.getSecuredFeature(featureId); const basePrivilege = this.getBasePrivilege(privilegeIndex); const selectedFeaturePrivileges = this.getSelectedFeaturePrivileges(featureId, privilegeIndex); - return feature.getPrimaryFeaturePrivileges().find((fp) => { + const displayedPrivilege = feature.getPrimaryFeaturePrivileges().find((fp) => { const correspondingMinimalPrivilegeId = fp.getMinimalPrivilegeId(); - const correspendingMinimalPrivilege = feature + const correspondingMinimalPrivilege = feature .getMinimalFeaturePrivileges() .find((mp) => mp.id === correspondingMinimalPrivilegeId)!; // There is only one case where the minimal privileges aren't available: // 1. Sub-feature privileges cannot be customized. When this is the case, the minimal privileges aren't registered with ES, // so they end up represented in the UI as an empty privilege. Empty privileges cannot be granted other privileges, so if we - // encounter a minimal privilege that isn't granted by it's correspending primary, then we know we've encountered this scenario. - const hasMinimalPrivileges = fp.grantsPrivilege(correspendingMinimalPrivilege); + // encounter a minimal privilege that isn't granted by it's corresponding primary, then we know we've encountered this scenario. + const hasMinimalPrivileges = fp.grantsPrivilege(correspondingMinimalPrivilege); return ( selectedFeaturePrivileges.includes(fp.id) || (hasMinimalPrivileges && @@ -290,6 +314,10 @@ export class PrivilegeFormCalculator { basePrivilege?.grantsPrivilege(fp) ); }); + + const correctSpacesSelected = displayedPrivilege?.requireAllSpaces ? allSpacesSelected : true; + const availablePrivileges = correctSpacesSelected && !displayedPrivilege?.disabled; + if (availablePrivileges) return displayedPrivilege; } private getSelectedFeaturePrivileges(featureId: string, privilegeIndex: number) { diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_table.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_table.tsx index b5eb274f5bbf4..8e5954f50a943 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_table.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/privilege_summary/privilege_summary_table.tsx @@ -22,8 +22,9 @@ import React, { Fragment, useMemo, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import type { Space, SpacesApiUi } from '../../../../../../../../spaces/public'; +import { ALL_SPACES_ID } from '../../../../../../../common/constants'; import type { Role, RoleKibanaPrivilege } from '../../../../../../../common/model'; -import type { KibanaPrivileges, SecuredFeature } from '../../../../model'; +import type { KibanaPrivileges, PrimaryFeaturePrivilege, SecuredFeature } from '../../../../model'; import { isGlobalPrivilegeDefinition } from '../../../privilege_utils'; import { FeatureTableCell } from '../feature_table_cell'; import type { EffectiveFeaturePrivileges } from './privilege_summary_calculator'; @@ -43,6 +44,17 @@ function getColumnKey(entry: RoleKibanaPrivilege) { return `privilege_entry_${entry.spaces.join('|')}`; } +function showPrivilege(allSpacesSelected: boolean, primaryFeature?: PrimaryFeaturePrivilege) { + if ( + primaryFeature?.name == null || + primaryFeature?.disabled || + (primaryFeature.requireAllSpaces && !allSpacesSelected) + ) { + return 'None'; + } + return primaryFeature?.name; +} + export const PrivilegeSummaryTable = (props: PrivilegeSummaryTableProps) => { const [expandedFeatures, setExpandedFeatures] = useState([]); @@ -145,7 +157,11 @@ export const PrivilegeSummaryTable = (props: PrivilegeSummaryTableProps) => { hasCustomizedSubFeaturePrivileges ? 'additionalPrivilegesGranted' : '' }`} > - {primary?.name ?? 'None'} {iconTip} + {showPrivilege( + props.spaces.some((space) => space.id === ALL_SPACES_ID), + primary + )}{' '} + {iconTip} ); }, diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.test.tsx index dd1e4f265266a..7976d76ff8d86 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.test.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.test.tsx @@ -12,7 +12,7 @@ import { findTestSubject, mountWithIntl } from '@kbn/test/jest'; import type { Space } from '../../../../../../../../spaces/public'; import type { Role } from '../../../../../../../common/model'; -import { kibanaFeatures } from '../../../../__fixtures__/kibana_features'; +import { createFeature, kibanaFeatures } from '../../../../__fixtures__/kibana_features'; import { createKibanaPrivileges } from '../../../../__fixtures__/kibana_privileges'; import { FeatureTable } from '../feature_table'; import { getDisplayedFeaturePrivileges } from '../feature_table/__fixtures__'; @@ -300,7 +300,7 @@ describe('PrivilegeSpaceForm', () => { expect(findTestSubject(wrapper, 'globalPrivilegeWarning')).toHaveLength(0); }); - it('allows all feature privileges to be changed via "change all"', () => { + it('allows all feature privileges to be changed via "change read"', () => { const role = createRole([ { base: [], @@ -391,4 +391,303 @@ describe('PrivilegeSpaceForm', () => { expect(wrapper.find(FeatureTable).props().canCustomizeSubFeaturePrivileges).toBe(canCustomize); }); + + describe('Feature with a disabled `read` privilege', () => { + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['all', 'with_sub_features_cool_toggle_2', 'cool_read'], + }, + spaces: ['foo'], + }, + { + base: [], + feature: { + with_sub_features: ['all'], + }, + spaces: ['bar'], + }, + ]); + const extendedKibanaFeatures = [ + ...kibanaFeatures, + createFeature({ + id: 'no_sub_features_disabled_read', + name: 'Feature 1: No Sub Features and read disabled', + disabledReadPrivilege: true, + }), + ]; + const kibanaPrivileges = createKibanaPrivileges(extendedKibanaFeatures); + const onChange = jest.fn(); + beforeEach(() => { + onChange.mockReset(); + }); + it('still allow other features privileges to be changed via "change read"', () => { + const wrapper = mountWithIntl( + + ); + + findTestSubject(wrapper, 'changeAllPrivilegesButton').simulate('click'); + findTestSubject(wrapper, 'changeAllPrivileges-read').simulate('click'); + findTestSubject(wrapper, 'createSpacePrivilegeButton').simulate('click'); + + expect(Object.keys(onChange.mock.calls[0][0].kibana[0].feature)).not.toContain( + 'no_sub_features_disabled_read' + ); + expect(onChange).toHaveBeenCalledWith( + createRole([ + { + base: [], + feature: { + excluded_from_base: ['read'], + with_excluded_sub_features: ['read'], + no_sub_features: ['read'], + with_sub_features: ['read'], + }, + spaces: ['foo'], + }, + // this set remains unchanged from the original + { + base: [], + feature: { + with_sub_features: ['all'], + }, + spaces: ['bar'], + }, + ]) + ); + }); + + it('still allow all privileges to be changed via "change all"', () => { + const wrapper = mountWithIntl( + + ); + + findTestSubject(wrapper, 'changeAllPrivilegesButton').simulate('click'); + findTestSubject(wrapper, 'changeAllPrivileges-all').simulate('click'); + findTestSubject(wrapper, 'createSpacePrivilegeButton').simulate('click'); + + expect(onChange).toHaveBeenCalledWith( + createRole([ + { + base: [], + feature: { + excluded_from_base: ['all'], + with_excluded_sub_features: ['all'], + no_sub_features: ['all'], + no_sub_features_disabled_read: ['all'], + with_sub_features: ['all'], + }, + spaces: ['foo'], + }, + // this set remains unchanged from the original + { + base: [], + feature: { + with_sub_features: ['all'], + }, + spaces: ['bar'], + }, + ]) + ); + }); + }); + + describe('Feature with requireAllSpaces on all privileges', () => { + const role = createRole([ + { + base: [], + feature: { + with_sub_features: ['all', 'with_sub_features_cool_toggle_2', 'cool_read'], + }, + spaces: ['foo'], + }, + { + base: [], + feature: { + with_sub_features: ['all'], + }, + spaces: ['bar'], + }, + ]); + const extendedKibanaFeatures = [ + ...kibanaFeatures, + createFeature({ + id: 'no_sub_features_require_all_space', + name: 'Feature 1: No Sub Features and all privilege require all space', + requireAllSpacesOnAllPrivilege: true, + }), + ]; + const kibanaPrivileges = createKibanaPrivileges(extendedKibanaFeatures); + + const onChange = jest.fn(); + + beforeEach(() => { + onChange.mockReset(); + }); + + it('still allow all features privileges to be changed via "change read" in foo space', () => { + const wrapper = mountWithIntl( + + ); + + findTestSubject(wrapper, 'changeAllPrivilegesButton').simulate('click'); + findTestSubject(wrapper, 'changeAllPrivileges-read').simulate('click'); + findTestSubject(wrapper, 'createSpacePrivilegeButton').simulate('click'); + + expect(onChange).toHaveBeenCalledWith( + createRole([ + { + base: [], + feature: { + excluded_from_base: ['read'], + with_excluded_sub_features: ['read'], + no_sub_features: ['read'], + no_sub_features_require_all_space: ['read'], + with_sub_features: ['read'], + }, + spaces: ['foo'], + }, + // this set remains unchanged from the original + { + base: [], + feature: { + with_sub_features: ['all'], + }, + spaces: ['bar'], + }, + ]) + ); + }); + + it('still allow other features privileges to be changed via "change all" in foo space', () => { + const wrapper = mountWithIntl( + + ); + + findTestSubject(wrapper, 'changeAllPrivilegesButton').simulate('click'); + findTestSubject(wrapper, 'changeAllPrivileges-all').simulate('click'); + findTestSubject(wrapper, 'createSpacePrivilegeButton').simulate('click'); + + expect(Object.keys(onChange.mock.calls[0][0].kibana[0].feature)).not.toContain( + 'no_sub_features_require_all_space' + ); + expect(onChange).toHaveBeenCalledWith( + createRole([ + { + base: [], + feature: { + excluded_from_base: ['all'], + with_excluded_sub_features: ['all'], + no_sub_features: ['all'], + with_sub_features: ['all'], + }, + spaces: ['foo'], + }, + // this set remains unchanged from the original + { + base: [], + feature: { + with_sub_features: ['all'], + }, + spaces: ['bar'], + }, + ]) + ); + }); + + it('still allow all features privileges to be changed via "change all" in all space', () => { + const roleAllSpace = createRole([ + { + base: [], + feature: { + with_sub_features: ['all', 'with_sub_features_cool_toggle_2', 'cool_read'], + }, + spaces: ['*'], + }, + { + base: [], + feature: { + with_sub_features: ['all'], + }, + spaces: ['bar'], + }, + ]); + const wrapper = mountWithIntl( + + ); + + findTestSubject(wrapper, 'changeAllPrivilegesButton').simulate('click'); + findTestSubject(wrapper, 'changeAllPrivileges-all').simulate('click'); + findTestSubject(wrapper, 'createSpacePrivilegeButton').simulate('click'); + + expect(onChange).toHaveBeenCalledWith( + createRole([ + { + base: [], + feature: { + excluded_from_base: ['all'], + with_excluded_sub_features: ['all'], + no_sub_features: ['all'], + no_sub_features_require_all_space: ['all'], + with_sub_features: ['all'], + }, + spaces: ['*'], + }, + // this set remains unchanged from the original + { + base: [], + feature: { + with_sub_features: ['all'], + }, + spaces: ['bar'], + }, + ]) + ); + }); + + test.todo( + 'should unset the feature privilege and all sub-feature privileges when "* All spaces" is removed' + ); + }); }); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.tsx index c92b981c67c7c..54c5c8770a981 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.tsx @@ -30,7 +30,8 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import type { Space } from '../../../../../../../../spaces/public'; -import type { Role } from '../../../../../../../common/model'; +import { ALL_SPACES_ID } from '../../../../../../../common/constants'; +import type { FeaturesPrivileges, Role } from '../../../../../../../common/model'; import { copyRole } from '../../../../../../../common/model'; import type { KibanaPrivileges } from '../../../../model'; import { CUSTOM_PRIVILEGE_VALUE } from '../constants'; @@ -261,6 +262,7 @@ export class PrivilegeSpaceForm extends Component { privilegeIndex={this.state.privilegeIndex} canCustomizeSubFeaturePrivileges={this.props.canCustomizeSubFeaturePrivileges} disabled={this.state.selectedBasePrivilege.length > 0 || !hasSelectedSpaces} + allSpacesSelected={this.state.selectedSpaceIds.includes(ALL_SPACES_ID)} /> {this.requiresGlobalPrivilegeWarning() && ( @@ -427,6 +429,7 @@ export class PrivilegeSpaceForm extends Component { const form = role.kibana[this.state.privilegeIndex]; form.spaces = [...selectedSpaceIds]; + form.feature = this.resetRoleFeature(form.feature, selectedSpaceIds); // Remove any feature privilege(s) that cannot currently be selected this.setState({ selectedSpaceIds, @@ -459,6 +462,28 @@ export class PrivilegeSpaceForm extends Component { }); }; + private resetRoleFeature = (roleFeature: FeaturesPrivileges, selectedSpaceIds: string[]) => { + const securedFeatures = this.props.kibanaPrivileges.getSecuredFeatures(); + return Object.entries(roleFeature).reduce((features, [featureId, privileges]) => { + if (!Array.isArray(privileges)) { + return features; + } + const securedFeature = securedFeatures.find((sf) => sf.id === featureId); + const primaryFeaturePrivilege = securedFeature + ?.getPrimaryFeaturePrivileges({ includeMinimalFeaturePrivileges: true }) + .find((pfp) => privileges.includes(pfp.id)) ?? { disabled: false, requireAllSpaces: false }; + const newFeaturePrivileges = + primaryFeaturePrivilege?.disabled || + (primaryFeaturePrivilege?.requireAllSpaces && !selectedSpaceIds.includes(ALL_SPACES_ID)) + ? [] // The primary feature privilege cannot be selected; remove that and any selected sub-feature privileges, too + : privileges; + return { + ...features, + ...(newFeaturePrivileges.length && { [featureId]: newFeaturePrivileges }), + }; + }, {}); + }; + private getDisplayedBasePrivilege = () => { const basePrivilege = this.state.privilegeCalculator.getBasePrivilege( this.state.privilegeIndex @@ -472,34 +497,53 @@ export class PrivilegeSpaceForm extends Component { }; private onFeaturePrivilegesChange = (featureId: string, privileges: string[]) => { - const role = copyRole(this.state.role); - const form = role.kibana[this.state.privilegeIndex]; - - if (privileges.length === 0) { - delete form.feature[featureId]; - } else { - form.feature[featureId] = [...privileges]; - } - - this.setState({ - role, - privilegeCalculator: new PrivilegeFormCalculator(this.props.kibanaPrivileges, role), - }); + this.setRole(privileges, featureId); }; private onChangeAllFeaturePrivileges = (privileges: string[]) => { + this.setRole(privileges); + }; + + private setRole(privileges: string[], featureId?: string) { const role = copyRole(this.state.role); const entry = role.kibana[this.state.privilegeIndex]; if (privileges.length === 0) { - entry.feature = {}; + if (featureId) { + delete entry.feature[featureId]; + } else { + entry.feature = {}; + } } else { - this.props.kibanaPrivileges.getSecuredFeatures().forEach((feature) => { + let securedFeaturesToSet = this.props.kibanaPrivileges.getSecuredFeatures(); + if (featureId) { + securedFeaturesToSet = [securedFeaturesToSet.find((sf) => sf.id === featureId)!]; + } + securedFeaturesToSet.forEach((feature) => { const nextFeaturePrivilege = feature - .getPrimaryFeaturePrivileges() - .find((pfp) => privileges.includes(pfp.id)); + .getPrimaryFeaturePrivileges({ includeMinimalFeaturePrivileges: true }) + .find((pfp) => { + if ( + pfp?.disabled || + (pfp?.requireAllSpaces && !this.state.selectedSpaceIds.includes(ALL_SPACES_ID)) + ) { + return false; + } + return Array.isArray(privileges) && privileges.includes(pfp.id); + }); + let newPrivileges: string[] = []; if (nextFeaturePrivilege) { - entry.feature[feature.id] = [nextFeaturePrivilege.id]; + newPrivileges = [nextFeaturePrivilege.id]; + feature.getSubFeaturePrivileges().forEach((psf) => { + if (Array.isArray(privileges) && privileges.includes(psf.id)) { + newPrivileges.push(psf.id); + } + }); + } + if (newPrivileges.length === 0) { + delete entry.feature[feature.id]; + } else { + entry.feature[feature.id] = newPrivileges; } }); } @@ -507,7 +551,7 @@ export class PrivilegeSpaceForm extends Component { role, privilegeCalculator: new PrivilegeFormCalculator(this.props.kibanaPrivileges, role), }); - }; + } private canSave = () => { if (this.state.selectedSpaceIds.length === 0) { diff --git a/x-pack/plugins/security/public/management/roles/model/kibana_privileges.ts b/x-pack/plugins/security/public/management/roles/model/kibana_privileges.ts index c2dd66172f751..67e000348f016 100644 --- a/x-pack/plugins/security/public/management/roles/model/kibana_privileges.ts +++ b/x-pack/plugins/security/public/management/roles/model/kibana_privileges.ts @@ -58,7 +58,7 @@ export class KibanaPrivileges { public createCollectionFromRoleKibanaPrivileges(roleKibanaPrivileges: RoleKibanaPrivilege[]) { const filterAssigned = (assignedPrivileges: string[]) => (privilege: KibanaPrivilege) => - assignedPrivileges.includes(privilege.id); + Array.isArray(assignedPrivileges) && assignedPrivileges.includes(privilege.id); const privileges: KibanaPrivilege[] = roleKibanaPrivileges .map((entry) => { diff --git a/x-pack/plugins/security/public/management/roles/model/primary_feature_privilege.ts b/x-pack/plugins/security/public/management/roles/model/primary_feature_privilege.ts index 323575862de52..cda4d203305b4 100644 --- a/x-pack/plugins/security/public/management/roles/model/primary_feature_privilege.ts +++ b/x-pack/plugins/security/public/management/roles/model/primary_feature_privilege.ts @@ -27,4 +27,12 @@ export class PrimaryFeaturePrivilege extends KibanaPrivilege { } return `minimal_${this.id}`; } + + public get requireAllSpaces() { + return this.config.requireAllSpaces ?? false; + } + + public get disabled() { + return this.config.disabled ?? false; + } } diff --git a/x-pack/plugins/security/server/authorization/roles/elasticsearch_role.test.ts b/x-pack/plugins/security/server/authorization/roles/elasticsearch_role.test.ts new file mode 100644 index 0000000000000..ba7f453aded75 --- /dev/null +++ b/x-pack/plugins/security/server/authorization/roles/elasticsearch_role.test.ts @@ -0,0 +1,251 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { omit, pick } from 'lodash'; + +import { KibanaFeature } from '../../../../features/server'; +import { transformElasticsearchRoleToRole } from './elasticsearch_role'; +import type { ElasticsearchRole } from './elasticsearch_role'; + +const roles = [ + { + name: 'global-base-all', + cluster: [], + indices: [], + applications: [ + { + application: 'kibana-.kibana', + privileges: ['all'], + resources: ['*'], + }, + ], + run_as: [], + metadata: {}, + transient_metadata: { + enabled: true, + }, + }, + { + name: 'global-base-read', + cluster: [], + indices: [], + applications: [ + { + application: 'kibana-.kibana', + privileges: ['read'], + resources: ['*'], + }, + ], + run_as: [], + metadata: {}, + transient_metadata: { + enabled: true, + }, + }, + { + name: 'global-foo-all', + cluster: [], + indices: [], + applications: [ + { + application: 'kibana-.kibana', + privileges: ['feature_foo.all'], + resources: ['*'], + }, + ], + run_as: [], + metadata: {}, + transient_metadata: { + enabled: true, + }, + }, + { + name: 'global-foo-read', + cluster: [], + indices: [], + applications: [ + { + application: 'kibana-.kibana', + privileges: ['feature_foo.read'], + resources: ['*'], + }, + ], + run_as: [], + metadata: {}, + transient_metadata: { + enabled: true, + }, + }, + { + name: 'default-base-all', + cluster: [], + indices: [], + applications: [ + { + application: 'kibana-.kibana', + privileges: ['space_all'], + resources: ['space:default'], + }, + ], + run_as: [], + metadata: {}, + transient_metadata: { + enabled: true, + }, + }, + { + name: 'default-base-read', + cluster: [], + indices: [], + applications: [ + { + application: 'kibana-.kibana', + privileges: ['space_read'], + resources: ['space:default'], + }, + ], + run_as: [], + metadata: {}, + transient_metadata: { + enabled: true, + }, + }, + { + name: 'default-foo-all', + cluster: [], + indices: [], + applications: [ + { + application: 'kibana-.kibana', + privileges: ['feature_foo.all'], + resources: ['space:default'], + }, + ], + run_as: [], + metadata: {}, + transient_metadata: { + enabled: true, + }, + }, + { + name: 'default-foo-read', + cluster: [], + indices: [], + applications: [ + { + application: 'kibana-.kibana', + privileges: ['feature_foo.read'], + resources: ['space:default'], + }, + ], + run_as: [], + metadata: {}, + transient_metadata: { + enabled: true, + }, + }, +]; + +function testRoles( + testName: string, + features: KibanaFeature[], + elasticsearchRoles: ElasticsearchRole[], + expected: any +) { + const transformedRoles = elasticsearchRoles.map((role) => { + const transformedRole = transformElasticsearchRoleToRole( + features, + omit(role, 'name'), + role.name, + 'kibana-.kibana' + ); + return pick(transformedRole, ['name', '_transform_error']); + }); + + it(`${testName}`, () => { + expect(transformedRoles).toEqual(expected); + }); +} + +describe('#transformElasticsearchRoleToRole', () => { + const featuresWithRequireAllSpaces: KibanaFeature[] = [ + new KibanaFeature({ + id: 'foo', + name: 'KibanaFeatureWithAllSpaces', + app: ['kibana-.kibana'], + category: { id: 'foo', label: 'foo' }, + privileges: { + all: { + requireAllSpaces: true, + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + read: { + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + }, + }), + ]; + const featuresWithReadDisabled: KibanaFeature[] = [ + new KibanaFeature({ + id: 'foo', + name: 'Foo KibanaFeatureWithReadDisabled', + app: ['kibana-.kibana'], + category: { id: 'foo', label: 'foo' }, + privileges: { + all: { + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + read: { + disabled: true, + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + }, + }), + ]; + + testRoles('#When features has requireAllSpaces=true', featuresWithRequireAllSpaces, roles, [ + { name: 'global-base-all', _transform_error: [] }, + { name: 'global-base-read', _transform_error: [] }, + { name: 'global-foo-all', _transform_error: [] }, + { name: 'global-foo-read', _transform_error: [] }, + { name: 'default-base-all', _transform_error: [] }, + { name: 'default-base-read', _transform_error: [] }, + { name: 'default-foo-all', _transform_error: ['kibana'] }, + { name: 'default-foo-read', _transform_error: [] }, + ]); + + testRoles( + '#When features has requireAllSpaces=false and read disabled', + featuresWithReadDisabled, + roles, + [ + { name: 'global-base-all', _transform_error: [] }, + { name: 'global-base-read', _transform_error: [] }, + { name: 'global-foo-all', _transform_error: [] }, + { name: 'global-foo-read', _transform_error: ['kibana'] }, + { name: 'default-base-all', _transform_error: [] }, + { name: 'default-base-read', _transform_error: [] }, + { name: 'default-foo-all', _transform_error: [] }, + { name: 'default-foo-read', _transform_error: ['kibana'] }, + ] + ); +}); diff --git a/x-pack/plugins/security/server/authorization/roles/elasticsearch_role.ts b/x-pack/plugins/security/server/authorization/roles/elasticsearch_role.ts index c0dab16f97af8..ec1f6e026b143 100644 --- a/x-pack/plugins/security/server/authorization/roles/elasticsearch_role.ts +++ b/x-pack/plugins/security/server/authorization/roles/elasticsearch_role.ts @@ -5,6 +5,7 @@ * 2.0. */ +import type { KibanaFeature } from '../../../../features/common'; import { GLOBAL_RESOURCE, RESERVED_PRIVILEGES_APPLICATION_WILDCARD, @@ -25,15 +26,16 @@ export type ElasticsearchRole = Pick, name: string, application: string ): Role { const kibanaTransformResult = transformRoleApplicationsToKibanaPrivileges( + features, elasticsearchRole.applications, application ); - return { name, metadata: elasticsearchRole.metadata, @@ -53,6 +55,7 @@ export function transformElasticsearchRoleToRole( } function transformRoleApplicationsToKibanaPrivileges( + features: KibanaFeature[], roleApplications: ElasticsearchRole['applications'], application: string ) { @@ -184,6 +187,44 @@ function transformRoleApplicationsToKibanaPrivileges( }; } + // if a feature privilege requires all spaces, but is assigned to other spaces, we won't transform these + if ( + roleKibanaApplications.some( + (entry) => + !entry.resources.includes(GLOBAL_RESOURCE) && + features.some((f) => + Object.entries(f.privileges ?? {}).some( + ([privName, featurePrivilege]) => + featurePrivilege.requireAllSpaces && + entry.privileges.includes( + PrivilegeSerializer.serializeFeaturePrivilege(f.id, privName) + ) + ) + ) + ) + ) { + return { + success: false, + }; + } + + // if a feature privilege has been disabled we won't transform these + if ( + roleKibanaApplications.some((entry) => + features.some((f) => + Object.entries(f.privileges ?? {}).some( + ([privName, featurePrivilege]) => + featurePrivilege.disabled && + entry.privileges.includes(PrivilegeSerializer.serializeFeaturePrivilege(f.id, privName)) + ) + ) + ) + ) { + return { + success: false, + }; + } + return { success: true, value: roleKibanaApplications.map(({ resources, privileges }) => { diff --git a/x-pack/plugins/security/server/deprecations/privilege_deprecations.test.ts b/x-pack/plugins/security/server/deprecations/privilege_deprecations.test.ts index e889eb17d5af9..eb229bfe2dc22 100644 --- a/x-pack/plugins/security/server/deprecations/privilege_deprecations.test.ts +++ b/x-pack/plugins/security/server/deprecations/privilege_deprecations.test.ts @@ -17,15 +17,17 @@ const application = `kibana-${kibanaIndexName}`; describe('#getPrivilegeDeprecationsService', () => { describe('#getKibanaRolesByFeatureId', () => { const mockAsCurrentUser = elasticsearchServiceMock.createScopedClusterClient(); + const mockGetFeatures = jest.fn().mockResolvedValue([]); const mockLicense = licenseMock.create(); const mockLogger = loggingSystemMock.createLogger(); const authz = { applicationName: application }; - const { getKibanaRolesByFeatureId } = getPrivilegeDeprecationsService( + const { getKibanaRolesByFeatureId } = getPrivilegeDeprecationsService({ authz, - mockLicense, - mockLogger - ); + getFeatures: mockGetFeatures, + license: mockLicense, + logger: mockLogger, + }); it('happy path to find siem roles with feature_siem privileges', async () => { mockAsCurrentUser.asCurrentUser.security.getRole.mockResolvedValue( diff --git a/x-pack/plugins/security/server/deprecations/privilege_deprecations.ts b/x-pack/plugins/security/server/deprecations/privilege_deprecations.ts index df212d5c7bde3..08413ccc74cf9 100644 --- a/x-pack/plugins/security/server/deprecations/privilege_deprecations.ts +++ b/x-pack/plugins/security/server/deprecations/privilege_deprecations.ts @@ -8,6 +8,7 @@ import { i18n } from '@kbn/i18n'; import type { Logger } from 'src/core/server'; +import type { KibanaFeature } from '../../../features/common'; import type { SecurityLicense } from '../../common/licensing'; import type { PrivilegeDeprecationsRolesByFeatureIdRequest, @@ -17,11 +18,17 @@ import { transformElasticsearchRoleToRole } from '../authorization'; import type { AuthorizationServiceSetupInternal, ElasticsearchRole } from '../authorization'; import { getDetailedErrorMessage, getErrorStatusCode } from '../errors'; -export const getPrivilegeDeprecationsService = ( - authz: Pick, - license: SecurityLicense, - logger: Logger -) => { +export const getPrivilegeDeprecationsService = ({ + authz, + getFeatures, + license, + logger, +}: { + authz: Pick; + getFeatures(): Promise; + license: SecurityLicense; + logger: Logger; +}) => { const getKibanaRolesByFeatureId = async ({ context, featureId, @@ -34,11 +41,13 @@ export const getPrivilegeDeprecationsService = ( } let kibanaRoles; try { - const { body: elasticsearchRoles } = await context.esClient.asCurrentUser.security.getRole< - Record - >(); + const [features, { body: elasticsearchRoles }] = await Promise.all([ + getFeatures(), + context.esClient.asCurrentUser.security.getRole>(), + ]); kibanaRoles = Object.entries(elasticsearchRoles).map(([roleName, elasticsearchRole]) => transformElasticsearchRoleToRole( + features, // @ts-expect-error `SecurityIndicesPrivileges.names` expected to be `string[]` elasticsearchRole, roleName, diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts index e8f7aa2aacfdd..1fc3932bb551b 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -324,11 +324,13 @@ export class SecurityPlugin mode: this.authorizationSetup.mode, }, license, - privilegeDeprecationsService: getPrivilegeDeprecationsService( - this.authorizationSetup, + privilegeDeprecationsService: getPrivilegeDeprecationsService({ + authz: this.authorizationSetup, + getFeatures: () => + startServicesPromise.then((services) => services.features.getKibanaFeatures()), license, - this.logger.get('deprecations') - ), + logger: this.logger.get('deprecations'), + }), }); } diff --git a/x-pack/plugins/security/server/routes/authorization/roles/get.test.ts b/x-pack/plugins/security/server/routes/authorization/roles/get.test.ts index d2385adc99162..a511d3aa52e21 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/get.test.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/get.test.ts @@ -32,6 +32,8 @@ describe('GET role', () => { test(description, async () => { const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); mockRouteDefinitionParams.authz.applicationName = application; + mockRouteDefinitionParams.getFeatures = jest.fn().mockResolvedValue([]); + const mockContext = { core: coreMock.createRequestHandlerContext(), licensing: { license: { check: jest.fn().mockReturnValue(licenseCheckResult) } } as any, diff --git a/x-pack/plugins/security/server/routes/authorization/roles/get.ts b/x-pack/plugins/security/server/routes/authorization/roles/get.ts index 6e010b69a3711..4c54854d3279b 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/get.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/get.ts @@ -12,7 +12,7 @@ import { wrapIntoCustomErrorResponse } from '../../../errors'; import { createLicensedRouteHandler } from '../../licensed_route_handler'; import { transformElasticsearchRoleToRole } from './model'; -export function defineGetRolesRoutes({ router, authz }: RouteDefinitionParams) { +export function defineGetRolesRoutes({ router, authz, getFeatures }: RouteDefinitionParams) { router.get( { path: '/api/security/role/{name}', @@ -22,15 +22,18 @@ export function defineGetRolesRoutes({ router, authz }: RouteDefinitionParams) { }, createLicensedRouteHandler(async (context, request, response) => { try { - const { body: elasticsearchRoles } = + const [features, { body: elasticsearchRoles }] = await Promise.all([ + getFeatures(), await context.core.elasticsearch.client.asCurrentUser.security.getRole({ name: request.params.name, - }); - + }), + ]); const elasticsearchRole = elasticsearchRoles[request.params.name]; + if (elasticsearchRole) { return response.ok({ body: transformElasticsearchRoleToRole( + features, // @ts-expect-error `SecurityIndicesPrivileges.names` expected to be `string[]` elasticsearchRole, request.params.name, diff --git a/x-pack/plugins/security/server/routes/authorization/roles/get_all.test.ts b/x-pack/plugins/security/server/routes/authorization/roles/get_all.test.ts index 09262d7cbbadd..8ecd5b7bd0913 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/get_all.test.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/get_all.test.ts @@ -32,6 +32,8 @@ describe('GET all roles', () => { test(description, async () => { const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); mockRouteDefinitionParams.authz.applicationName = application; + mockRouteDefinitionParams.getFeatures = jest.fn().mockResolvedValue([]); + const mockContext = { core: coreMock.createRequestHandlerContext(), licensing: { license: { check: jest.fn().mockReturnValue(licenseCheckResult) } } as any, diff --git a/x-pack/plugins/security/server/routes/authorization/roles/get_all.ts b/x-pack/plugins/security/server/routes/authorization/roles/get_all.ts index ba5133b780d5e..89b39ddae0118 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/get_all.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/get_all.ts @@ -11,21 +11,24 @@ import { createLicensedRouteHandler } from '../../licensed_route_handler'; import type { ElasticsearchRole } from './model'; import { transformElasticsearchRoleToRole } from './model'; -export function defineGetAllRolesRoutes({ router, authz }: RouteDefinitionParams) { +export function defineGetAllRolesRoutes({ router, authz, getFeatures }: RouteDefinitionParams) { router.get( { path: '/api/security/role', validate: false }, createLicensedRouteHandler(async (context, request, response) => { try { - const { body: elasticsearchRoles } = + const [features, { body: elasticsearchRoles }] = await Promise.all([ + getFeatures(), await context.core.elasticsearch.client.asCurrentUser.security.getRole< Record - >(); + >(), + ]); // Transform elasticsearch roles into Kibana roles and return in a list sorted by the role name. return response.ok({ body: Object.entries(elasticsearchRoles) .map(([roleName, elasticsearchRole]) => transformElasticsearchRoleToRole( + features, // @ts-expect-error @elastic/elasticsearch SecurityIndicesPrivileges.names expected to be string[] elasticsearchRole, roleName, diff --git a/x-pack/plugins/security/server/routes/authorization/roles/model/index.ts b/x-pack/plugins/security/server/routes/authorization/roles/model/index.ts index cb5bb8a91152c..ef27f20f09a55 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/model/index.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/model/index.ts @@ -7,4 +7,8 @@ export type { ElasticsearchRole } from '../../../../authorization'; export { transformElasticsearchRoleToRole } from '../../../../authorization'; -export { getPutPayloadSchema, transformPutPayloadToElasticsearchRole } from './put_payload'; +export { + getPutPayloadSchema, + transformPutPayloadToElasticsearchRole, + validateKibanaPrivileges, +} from './put_payload'; diff --git a/x-pack/plugins/security/server/routes/authorization/roles/model/put_payload.test.ts b/x-pack/plugins/security/server/routes/authorization/roles/model/put_payload.test.ts index aa49788b7fd55..377729dc5b095 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/model/put_payload.test.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/model/put_payload.test.ts @@ -5,7 +5,9 @@ * 2.0. */ -import { getPutPayloadSchema } from './put_payload'; +import { KibanaFeature } from '../../../../../../features/common'; +import { ALL_SPACES_ID } from '../../../../../common/constants'; +import { getPutPayloadSchema, validateKibanaPrivileges } from './put_payload'; const basePrivilegeNamesMap = { global: ['all', 'read'], @@ -345,3 +347,121 @@ describe('Put payload schema', () => { `); }); }); + +describe('validateKibanaPrivileges', () => { + const fooFeature = new KibanaFeature({ + id: 'foo', + name: 'Foo', + privileges: { + all: { + requireAllSpaces: true, + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + read: { + disabled: true, + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + }, + app: [], + category: { id: 'foo', label: 'foo' }, + }); + + test('allows valid privileges', () => { + expect( + validateKibanaPrivileges( + [fooFeature], + [ + { + spaces: [ALL_SPACES_ID], + base: [], + feature: { + foo: ['all'], + }, + }, + ] + ).validationErrors + ).toEqual([]); + }); + + test('does not reject unknown features', () => { + expect( + validateKibanaPrivileges( + [fooFeature], + [ + { + spaces: [ALL_SPACES_ID], + base: [], + feature: { + foo: ['all'], + bar: ['all'], + }, + }, + ] + ).validationErrors + ).toEqual([]); + }); + + test('returns errors if requireAllSpaces: true and not all spaces specified', () => { + expect( + validateKibanaPrivileges( + [fooFeature], + [ + { + spaces: ['foo-space'], + base: [], + feature: { + foo: ['all'], + }, + }, + ] + ).validationErrors + ).toEqual([ + `Feature privilege [foo.all] requires all spaces to be selected but received [foo-space]`, + ]); + }); + + test('returns errors if disabled: true and privilege is specified', () => { + expect( + validateKibanaPrivileges( + [fooFeature], + [ + { + spaces: [ALL_SPACES_ID], + base: [], + feature: { + foo: ['read'], + }, + }, + ] + ).validationErrors + ).toEqual([`Feature [foo] does not support privilege [read].`]); + }); + + test('returns multiple errors when necessary', () => { + expect( + validateKibanaPrivileges( + [fooFeature], + [ + { + spaces: ['foo-space'], + base: [], + feature: { + foo: ['all', 'read'], + }, + }, + ] + ).validationErrors + ).toEqual([ + `Feature privilege [foo.all] requires all spaces to be selected but received [foo-space]`, + `Feature [foo] does not support privilege [read].`, + ]); + }); +}); diff --git a/x-pack/plugins/security/server/routes/authorization/roles/model/put_payload.ts b/x-pack/plugins/security/server/routes/authorization/roles/model/put_payload.ts index 7fb2baf0fd410..015891b7d7ffa 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/model/put_payload.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/model/put_payload.ts @@ -11,7 +11,8 @@ import type { TypeOf } from '@kbn/config-schema'; import { schema } from '@kbn/config-schema'; import type { ElasticsearchRole } from '.'; -import { GLOBAL_RESOURCE } from '../../../../../common/constants'; +import type { KibanaFeature } from '../../../../../../features/common'; +import { ALL_SPACES_ID, GLOBAL_RESOURCE } from '../../../../../common/constants'; import { PrivilegeSerializer } from '../../../../authorization/privilege_serializer'; import { ResourceSerializer } from '../../../../authorization/resource_serializer'; @@ -302,3 +303,50 @@ const transformPrivilegesToElasticsearchPrivileges = ( }; }); }; + +export const validateKibanaPrivileges = ( + kibanaFeatures: KibanaFeature[], + kibanaPrivileges: PutPayloadSchemaType['kibana'] +) => { + const validationErrors = (kibanaPrivileges ?? []).flatMap((priv) => { + const forAllSpaces = priv.spaces.includes(ALL_SPACES_ID); + + return Object.entries(priv.feature ?? {}).flatMap(([featureId, feature]) => { + const errors: string[] = []; + const kibanaFeature = kibanaFeatures.find((f) => f.id === featureId); + if (!kibanaFeature) return errors; + + if (feature.includes('all')) { + if (kibanaFeature.privileges?.all.disabled) { + errors.push(`Feature [${featureId}] does not support privilege [all].`); + } + + if (kibanaFeature.privileges?.all.requireAllSpaces && !forAllSpaces) { + errors.push( + `Feature privilege [${featureId}.all] requires all spaces to be selected but received [${priv.spaces.join( + ',' + )}]` + ); + } + } + + if (feature.includes('read')) { + if (kibanaFeature.privileges?.read.disabled) { + errors.push(`Feature [${featureId}] does not support privilege [read].`); + } + + if (kibanaFeature.privileges?.read.requireAllSpaces && !forAllSpaces) { + errors.push( + `Feature privilege [${featureId}.read] requires all spaces to be selected but received [${priv.spaces.join( + ',' + )}]` + ); + } + } + + return errors; + }); + }); + + return { validationErrors }; +}; diff --git a/x-pack/plugins/security/server/routes/authorization/roles/put.test.ts b/x-pack/plugins/security/server/routes/authorization/roles/put.test.ts index ae4647ca4f515..72d78ba5aaca4 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/put.test.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/put.test.ts @@ -56,11 +56,19 @@ interface TestOptions { apiArguments?: { get: unknown[]; put: unknown[] }; recordSubFeaturePrivilegeUsage?: boolean; }; + features?: KibanaFeature[]; } const putRoleTest = ( description: string, - { name, payload, licenseCheckResult = { state: 'valid' }, apiResponses, asserts }: TestOptions + { + name, + payload, + licenseCheckResult = { state: 'valid' }, + apiResponses, + asserts, + features, + }: TestOptions ) => { test(description, async () => { const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); @@ -88,43 +96,45 @@ const putRoleTest = ( securityFeatureUsageServiceMock.createStartContract() ); - mockRouteDefinitionParams.getFeatures.mockResolvedValue([ - new KibanaFeature({ - id: 'feature_1', - name: 'feature 1', - app: [], - category: { id: 'foo', label: 'foo' }, - privileges: { - all: { - ui: [], - savedObject: { all: [], read: [] }, - }, - read: { - ui: [], - savedObject: { all: [], read: [] }, - }, - }, - subFeatures: [ - { - name: 'sub feature 1', - privilegeGroups: [ - { - groupType: 'independent', - privileges: [ - { - id: 'sub_feature_privilege_1', - name: 'first sub-feature privilege', - includeIn: 'none', - ui: [], - savedObject: { all: [], read: [] }, - }, - ], - }, - ], + mockRouteDefinitionParams.getFeatures.mockResolvedValue( + features ?? [ + new KibanaFeature({ + id: 'feature_1', + name: 'feature 1', + app: [], + category: { id: 'foo', label: 'foo' }, + privileges: { + all: { + ui: [], + savedObject: { all: [], read: [] }, + }, + read: { + ui: [], + savedObject: { all: [], read: [] }, + }, }, - ], - }), - ]); + subFeatures: [ + { + name: 'sub feature 1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'sub_feature_privilege_1', + name: 'first sub-feature privilege', + includeIn: 'none', + ui: [], + savedObject: { all: [], read: [] }, + }, + ], + }, + ], + }, + ], + }), + ] + ); definePutRolesRoutes(mockRouteDefinitionParams); const [[{ validate }, handler]] = mockRouteDefinitionParams.router.put.mock.calls; @@ -207,6 +217,56 @@ describe('PUT role', () => { licenseCheckResult: { state: 'invalid', message: 'test forbidden message' }, asserts: { statusCode: 403, result: { message: 'test forbidden message' } }, }); + + describe('feature validation', () => { + const fooFeature = new KibanaFeature({ + id: 'bar', + name: 'bar', + privileges: { + all: { + requireAllSpaces: true, + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + read: { + disabled: true, + savedObject: { + all: [], + read: [], + }, + ui: [], + }, + }, + app: [], + category: { id: 'bar', label: 'bar' }, + }); + + putRoleTest('returns validation errors', { + name: 'bar-role', + payload: { + kibana: [ + { + spaces: ['bar-space'], + base: [], + feature: { + bar: ['all', 'read'], + }, + }, + ], + }, + features: [fooFeature], + asserts: { + statusCode: 400, + result: { + message: + 'Role cannot be updated due to validation errors: ["Feature privilege [bar.all] requires all spaces to be selected but received [bar-space]","Feature [bar] does not support privilege [read]."]', + }, + }, + }); + }); }); describe('success', () => { diff --git a/x-pack/plugins/security/server/routes/authorization/roles/put.ts b/x-pack/plugins/security/server/routes/authorization/roles/put.ts index af69db1f6bd43..c1b8cc56c32a4 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/put.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/put.ts @@ -12,7 +12,11 @@ import type { KibanaFeature } from '../../../../../features/common'; import { wrapIntoCustomErrorResponse } from '../../../errors'; import type { RouteDefinitionParams } from '../../index'; import { createLicensedRouteHandler } from '../../licensed_route_handler'; -import { getPutPayloadSchema, transformPutPayloadToElasticsearchRole } from './model'; +import { + getPutPayloadSchema, + transformPutPayloadToElasticsearchRole, + validateKibanaPrivileges, +} from './model'; const roleGrantsSubFeaturePrivileges = ( features: KibanaFeature[], @@ -62,11 +66,24 @@ export function definePutRolesRoutes({ const { name } = request.params; try { - const { body: rawRoles } = - await context.core.elasticsearch.client.asCurrentUser.security.getRole( + const [features, { body: rawRoles }] = await Promise.all([ + getFeatures(), + context.core.elasticsearch.client.asCurrentUser.security.getRole( { name: request.params.name }, { ignore: [404] } - ); + ), + ]); + + const { validationErrors } = validateKibanaPrivileges(features, request.body.kibana); + if (validationErrors.length) { + return response.badRequest({ + body: { + message: `Role cannot be updated due to validation errors: ${JSON.stringify( + validationErrors + )}`, + }, + }); + } const body = transformPutPayloadToElasticsearchRole( request.body, @@ -74,14 +91,11 @@ export function definePutRolesRoutes({ rawRoles[name] ? rawRoles[name].applications : [] ); - const [features] = await Promise.all([ - getFeatures(), - context.core.elasticsearch.client.asCurrentUser.security.putRole({ - name: request.params.name, - // @ts-expect-error RoleIndexPrivilege is not compatible. grant is required in IndicesPrivileges.field_security - body, - }), - ]); + await context.core.elasticsearch.client.asCurrentUser.security.putRole({ + name: request.params.name, + // @ts-expect-error RoleIndexPrivilege is not compatible. grant is required in IndicesPrivileges.field_security + body, + }); if (roleGrantsSubFeaturePrivileges(features, request.body)) { getFeatureUsageService().recordSubFeaturePrivilegeUsage(); From 00d1ad30f40e7006c9ac09da4fb3a7111d2f19bd Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Thu, 6 Jan 2022 13:22:17 +0100 Subject: [PATCH 2/6] [SearchBar] Improve rendering performance (#119189) --- package.json | 3 +- .../public/query/timefilter/time_history.ts | 4 + .../timefilter/timefilter_service.mock.ts | 2 + .../data/public/ui/filter_bar/filter_bar.tsx | 4 +- .../query_string_input/language_switcher.tsx | 4 +- .../query_bar_top_row.test.tsx | 3 +- .../query_string_input/query_bar_top_row.tsx | 528 ++++++++++-------- .../query_string_input/query_string_input.tsx | 141 +++-- .../lib/use_query_string_manager.ts | 12 +- .../data/public/ui/search_bar/search_bar.tsx | 49 +- .../suggestions_component.test.tsx.snap | 129 ----- .../typeahead/suggestion_component.test.tsx | 3 +- .../ui/typeahead/suggestion_component.tsx | 34 +- .../typeahead/suggestions_component.test.tsx | 24 +- .../ui/typeahead/suggestions_component.tsx | 238 ++++++-- src/plugins/data/public/ui/typeahead/types.ts | 2 + src/plugins/data/public/ui/utils/index.ts | 9 + src/plugins/data/public/ui/utils/on_raf.ts | 22 + .../data/public/utils/shallow_equal.ts | 36 ++ yarn.lock | 10 +- 20 files changed, 722 insertions(+), 535 deletions(-) delete mode 100644 src/plugins/data/public/ui/typeahead/__snapshots__/suggestions_component.test.tsx.snap create mode 100644 src/plugins/data/public/ui/utils/index.ts create mode 100644 src/plugins/data/public/ui/utils/on_raf.ts create mode 100644 src/plugins/data/public/utils/shallow_equal.ts diff --git a/package.json b/package.json index ed0bbf49339e8..f31baf75e07af 100644 --- a/package.json +++ b/package.json @@ -287,7 +287,7 @@ "markdown-it": "^10.0.0", "md5": "^2.1.0", "mdast-util-to-hast": "10.0.1", - "memoize-one": "^5.0.0", + "memoize-one": "^6.0.0", "mime": "^2.4.4", "mime-types": "^2.1.27", "mini-css-extract-plugin": "1.1.0", @@ -611,7 +611,6 @@ "@types/lz-string": "^1.3.34", "@types/markdown-it": "^0.0.7", "@types/md5": "^2.2.0", - "@types/memoize-one": "^4.1.0", "@types/mime": "^2.0.1", "@types/mime-types": "^2.1.0", "@types/minimatch": "^2.0.29", diff --git a/src/plugins/data/public/query/timefilter/time_history.ts b/src/plugins/data/public/query/timefilter/time_history.ts index ff08c89c98b49..f83e5e685b581 100644 --- a/src/plugins/data/public/query/timefilter/time_history.ts +++ b/src/plugins/data/public/query/timefilter/time_history.ts @@ -42,6 +42,10 @@ export class TimeHistory { get() { return this.history.get(); } + + get$() { + return this.history.get$(); + } } export type TimeHistoryContract = PublicMethodsOf; diff --git a/src/plugins/data/public/query/timefilter/timefilter_service.mock.ts b/src/plugins/data/public/query/timefilter/timefilter_service.mock.ts index 2b6a65e6c9bd7..53d9aeeec9afe 100644 --- a/src/plugins/data/public/query/timefilter/timefilter_service.mock.ts +++ b/src/plugins/data/public/query/timefilter/timefilter_service.mock.ts @@ -9,6 +9,7 @@ import type { PublicMethodsOf } from '@kbn/utility-types'; import { TimefilterService, TimeHistoryContract, TimefilterContract } from '.'; import { Observable } from 'rxjs'; +import { TimeRange } from '../../../common'; export type TimefilterServiceClientContract = PublicMethodsOf; @@ -43,6 +44,7 @@ const createSetupContractMock = () => { const historyMock: jest.Mocked = { add: jest.fn(), get: jest.fn(), + get$: jest.fn(() => new Observable()), }; const setupContract = { diff --git a/src/plugins/data/public/ui/filter_bar/filter_bar.tsx b/src/plugins/data/public/ui/filter_bar/filter_bar.tsx index acfafa6dd6bd8..9bc64eb1f6919 100644 --- a/src/plugins/data/public/ui/filter_bar/filter_bar.tsx +++ b/src/plugins/data/public/ui/filter_bar/filter_bar.tsx @@ -40,7 +40,7 @@ interface Props { timeRangeForSuggestionsOverride?: boolean; } -function FilterBarUI(props: Props) { +const FilterBarUI = React.memo(function FilterBarUI(props: Props) { const groupRef = useRef(null); const [isAddFilterPopoverOpen, setIsAddFilterPopoverOpen] = useState(false); const kibana = useKibana(); @@ -226,6 +226,6 @@ function FilterBarUI(props: Props) { ); -} +}); export const FilterBar = injectI18n(FilterBarUI); diff --git a/src/plugins/data/public/ui/query_string_input/language_switcher.tsx b/src/plugins/data/public/ui/query_string_input/language_switcher.tsx index 6a52220086194..3d66edd6a022c 100644 --- a/src/plugins/data/public/ui/query_string_input/language_switcher.tsx +++ b/src/plugins/data/public/ui/query_string_input/language_switcher.tsx @@ -32,7 +32,7 @@ export interface QueryLanguageSwitcherProps { nonKqlModeHelpText?: string; } -export function QueryLanguageSwitcher({ +export const QueryLanguageSwitcher = React.memo(function QueryLanguageSwitcher({ language, anchorPosition, onSelectLanguage, @@ -148,4 +148,4 @@ export function QueryLanguageSwitcher({ ); -} +}); diff --git a/src/plugins/data/public/ui/query_string_input/query_bar_top_row.test.tsx b/src/plugins/data/public/ui/query_string_input/query_bar_top_row.test.tsx index 0fce65f2a47b1..56dd901055fbc 100644 --- a/src/plugins/data/public/ui/query_string_input/query_bar_top_row.test.tsx +++ b/src/plugins/data/public/ui/query_string_input/query_bar_top_row.test.tsx @@ -11,9 +11,9 @@ import { mockPersistedLogFactory } from './query_string_input.test.mocks'; import React from 'react'; import { mount } from 'enzyme'; import { render } from '@testing-library/react'; +import { EMPTY } from 'rxjs'; import QueryBarTopRow from './query_bar_top_row'; - import { coreMock } from '../../../../../core/public/mocks'; import { dataPluginMock } from '../../mocks'; import { KibanaContextProvider } from 'src/plugins/kibana_react/public'; @@ -26,6 +26,7 @@ const mockTimeHistory = { get: () => { return []; }, + get$: () => EMPTY, }; startMock.uiSettings.get.mockImplementation((key: string) => { diff --git a/src/plugins/data/public/ui/query_string_input/query_bar_top_row.tsx b/src/plugins/data/public/ui/query_string_input/query_bar_top_row.tsx index 76d4b9dd8e801..bb5e61bdb1946 100644 --- a/src/plugins/data/public/ui/query_string_input/query_bar_top_row.tsx +++ b/src/plugins/data/public/ui/query_string_input/query_bar_top_row.tsx @@ -8,7 +8,11 @@ import dateMath from '@elastic/datemath'; import classNames from 'classnames'; -import React, { useState } from 'react'; +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import deepEqual from 'fast-deep-equal'; +import useObservable from 'react-use/lib/useObservable'; +import { EMPTY } from 'rxjs'; +import { map } from 'rxjs/operators'; import { EuiFlexGroup, @@ -17,9 +21,10 @@ import { EuiFieldText, prettyDuration, EuiIconProps, + EuiSuperUpdateButton, + OnRefreshProps, } from '@elastic/eui'; -// @ts-ignore -import { EuiSuperUpdateButton, OnRefreshProps } from '@elastic/eui'; + import { IDataPluginServices, IIndexPattern, TimeRange, TimeHistoryContract, Query } from '../..'; import { useKibana, withKibana } from '../../../../kibana_react/public'; import QueryStringInputUI from './query_string_input'; @@ -27,6 +32,14 @@ import { UI_SETTINGS } from '../../../common'; import { getQueryLog } from '../../query'; import type { PersistedLog } from '../../query'; import { NoDataPopover } from './no_data_popover'; +import { shallowEqual } from '../../utils/shallow_equal'; + +const SuperDatePicker = React.memo( + EuiSuperDatePicker as any +) as unknown as typeof EuiSuperDatePicker; +const SuperUpdateButton = React.memo( + EuiSuperUpdateButton as any +) as unknown as typeof EuiSuperUpdateButton; const QueryStringInput = withKibana(QueryStringInputUI); @@ -63,265 +76,328 @@ export interface QueryBarTopRowProps { timeRangeForSuggestionsOverride?: boolean; } -// Needed for React.lazy -// eslint-disable-next-line import/no-default-export -export default function QueryBarTopRow(props: QueryBarTopRowProps) { - const [isDateRangeInvalid, setIsDateRangeInvalid] = useState(false); - const [isQueryInputFocused, setIsQueryInputFocused] = useState(false); - - const kibana = useKibana(); - const { uiSettings, storage, appName } = kibana.services; - - const queryLanguage = props.query && props.query.language; - const persistedLog: PersistedLog | undefined = React.useMemo( - () => - queryLanguage && uiSettings && storage && appName - ? getQueryLog(uiSettings!, storage, appName, queryLanguage) - : undefined, - [appName, queryLanguage, uiSettings, storage] - ); - - function onClickSubmitButton(event: React.MouseEvent) { - if (persistedLog && props.query) { - persistedLog.add(props.query.query); +const SharingMetaFields = React.memo(function SharingMetaFields({ + from, + to, + dateFormat, +}: { + from: string; + to: string; + dateFormat: string; +}) { + function toAbsoluteString(value: string, roundUp = false) { + const valueAsMoment = dateMath.parse(value, { roundUp }); + if (!valueAsMoment) { + return value; } - event.preventDefault(); - onSubmit({ query: props.query, dateRange: getDateRange() }); + return valueAsMoment.toISOString(); } - function getDateRange() { - const defaultTimeSetting = uiSettings!.get(UI_SETTINGS.TIMEPICKER_TIME_DEFAULTS); - return { - from: props.dateRangeFrom || defaultTimeSetting.from, - to: props.dateRangeTo || defaultTimeSetting.to, - }; - } + const dateRangePretty = prettyDuration( + toAbsoluteString(from), + toAbsoluteString(to), + [], + dateFormat + ); - function onQueryChange(query: Query) { - props.onChange({ - query, - dateRange: getDateRange(), - }); - } + return ( +
+ ); +}); - function onChangeQueryInputFocus(isFocused: boolean) { - setIsQueryInputFocused(isFocused); - } +export const QueryBarTopRow = React.memo( + function QueryBarTopRow(props: QueryBarTopRowProps) { + const { showQueryInput = true, showDatePicker = true, showAutoRefreshOnly = false } = props; - function onTimeChange({ - start, - end, - isInvalid, - isQuickSelection, - }: { - start: string; - end: string; - isInvalid: boolean; - isQuickSelection: boolean; - }) { - setIsDateRangeInvalid(isInvalid); - const retVal = { - query: props.query, - dateRange: { - from: start, - to: end, - }, - }; + const [isDateRangeInvalid, setIsDateRangeInvalid] = useState(false); + const [isQueryInputFocused, setIsQueryInputFocused] = useState(false); - if (isQuickSelection) { - props.onSubmit(retVal); - } else { - props.onChange(retVal); - } - } + const kibana = useKibana(); + const { uiSettings, storage, appName } = kibana.services; - function onRefresh({ start, end }: OnRefreshProps) { - const retVal = { - dateRange: { - from: start, - to: end, - }, - }; - if (props.onRefresh) { - props.onRefresh(retVal); - } - } + const queryLanguage = props.query && props.query.language; + const queryRef = useRef(props.query); + queryRef.current = props.query; + + const persistedLog: PersistedLog | undefined = React.useMemo( + () => + queryLanguage && uiSettings && storage && appName + ? getQueryLog(uiSettings!, storage, appName, queryLanguage) + : undefined, + [appName, queryLanguage, uiSettings, storage] + ); - function onSubmit({ query, dateRange }: { query?: Query; dateRange: TimeRange }) { - if (props.timeHistory) { - props.timeHistory.add(dateRange); + function getDateRange() { + const defaultTimeSetting = uiSettings!.get(UI_SETTINGS.TIMEPICKER_TIME_DEFAULTS); + return { + from: props.dateRangeFrom || defaultTimeSetting.from, + to: props.dateRangeTo || defaultTimeSetting.to, + }; } - props.onSubmit({ query, dateRange }); - } + const currentDateRange = getDateRange(); + const dateRangeRef = useRef<{ from: string; to: string }>(currentDateRange); + dateRangeRef.current = currentDateRange; - function onInputSubmit(query: Query) { - onSubmit({ - query, - dateRange: getDateRange(), - }); - } + const propsOnSubmit = props.onSubmit; - function toAbsoluteString(value: string, roundUp = false) { - const valueAsMoment = dateMath.parse(value, { roundUp }); - if (!valueAsMoment) { - return value; - } - return valueAsMoment.toISOString(); - } - - function renderQueryInput() { - if (!shouldRenderQueryInput()) return; + const toRecentlyUsedRanges = (ranges: TimeRange[]) => + ranges.map(({ from, to }: { from: string; to: string }) => { + return { + start: from, + end: to, + }; + }); + const timeHistory = props.timeHistory; + const timeHistory$ = useMemo( + () => timeHistory?.get$().pipe(map(toRecentlyUsedRanges)) ?? EMPTY, + [timeHistory] + ); - return ( - - - + const recentlyUsedRanges = useObservable( + timeHistory$, + toRecentlyUsedRanges(timeHistory?.get() ?? []) ); - } + const [commonlyUsedRanges] = useState(() => { + return ( + uiSettings + ?.get(UI_SETTINGS.TIMEPICKER_QUICK_RANGES) + ?.map(({ from, to, display }: { from: string; to: string; display: string }) => { + return { + start: from, + end: to, + label: display, + }; + }) ?? [] + ); + }); + + const onSubmit = useCallback( + ({ query, dateRange }: { query?: Query; dateRange: TimeRange }) => { + if (timeHistory) { + timeHistory.add(dateRange); + } - function renderSharingMetaFields() { - const { from, to } = getDateRange(); - const dateRangePretty = prettyDuration( - toAbsoluteString(from), - toAbsoluteString(to), - [], - uiSettings.get('dateFormat') + propsOnSubmit({ query, dateRange }); + }, + [timeHistory, propsOnSubmit] ); - return ( -
+ + const onClickSubmitButton = useCallback( + (event: React.MouseEvent) => { + if (persistedLog && queryRef.current) { + persistedLog.add(queryRef.current.query); + } + event.preventDefault(); + onSubmit({ + query: queryRef.current, + dateRange: dateRangeRef.current, + }); + }, + [persistedLog, onSubmit] ); - } - function shouldRenderDatePicker(): boolean { - return Boolean(props.showDatePicker || props.showAutoRefreshOnly); - } + const propsOnChange = props.onChange; + const onQueryChange = useCallback( + (query: Query) => { + return propsOnChange({ + query, + dateRange: dateRangeRef.current, + }); + }, + [propsOnChange] + ); - function shouldRenderQueryInput(): boolean { - return Boolean(props.showQueryInput && props.indexPatterns && props.query && storage); - } + const onChangeQueryInputFocus = useCallback((isFocused: boolean) => { + setIsQueryInputFocused(isFocused); + }, []); + + const onTimeChange = useCallback( + ({ + start, + end, + isInvalid, + isQuickSelection, + }: { + start: string; + end: string; + isInvalid: boolean; + isQuickSelection: boolean; + }) => { + setIsDateRangeInvalid(isInvalid); + const retVal = { + query: queryRef.current, + dateRange: { + from: start, + to: end, + }, + }; - function renderUpdateButton() { - const button = props.customSubmitButton ? ( - React.cloneElement(props.customSubmitButton, { onClick: onClickSubmitButton }) - ) : ( - + if (isQuickSelection) { + onSubmit(retVal); + } else { + propsOnChange(retVal); + } + }, + [propsOnChange, onSubmit] ); - if (!shouldRenderDatePicker()) { - return button; - } + const propsOnRefresh = props.onRefresh; + const onRefresh = useCallback( + ({ start, end }: OnRefreshProps) => { + const retVal = { + dateRange: { + from: start, + to: end, + }, + }; + if (propsOnRefresh) { + propsOnRefresh(retVal); + } + }, + [propsOnRefresh] + ); - return ( - - - {renderDatePicker()} - {button} - - + const onInputSubmit = useCallback( + (query: Query) => { + onSubmit({ + query, + dateRange: dateRangeRef.current, + }); + }, + [onSubmit] ); - } - function renderDatePicker() { - if (!shouldRenderDatePicker()) { - return null; + function shouldRenderQueryInput(): boolean { + return Boolean(showQueryInput && props.indexPatterns && props.query && storage); } - let recentlyUsedRanges; - if (props.timeHistory) { - recentlyUsedRanges = props.timeHistory - .get() - .map(({ from, to }: { from: string; to: string }) => { - return { - start: from, - end: to, - }; - }); + function shouldRenderDatePicker(): boolean { + return Boolean(showDatePicker || showAutoRefreshOnly); } - const commonlyUsedRanges = uiSettings! - .get(UI_SETTINGS.TIMEPICKER_QUICK_RANGES) - .map(({ from, to, display }: { from: string; to: string; display: string }) => { - return { - start: from, - end: to, - label: display, - }; + function renderDatePicker() { + if (!shouldRenderDatePicker()) { + return null; + } + + const wrapperClasses = classNames('kbnQueryBar__datePickerWrapper', { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'kbnQueryBar__datePickerWrapper-isHidden': isQueryInputFocused, }); - const wrapperClasses = classNames('kbnQueryBar__datePickerWrapper', { - // eslint-disable-next-line @typescript-eslint/naming-convention - 'kbnQueryBar__datePickerWrapper-isHidden': isQueryInputFocused, + return ( + + + + ); + } + + function renderUpdateButton() { + const button = props.customSubmitButton ? ( + React.cloneElement(props.customSubmitButton, { onClick: onClickSubmitButton }) + ) : ( + + ); + + if (!shouldRenderDatePicker()) { + return button; + } + + return ( + + + {renderDatePicker()} + {button} + + + ); + } + + function renderQueryInput() { + if (!shouldRenderQueryInput()) return; + + return ( + + + + ); + } + + const classes = classNames('kbnQueryBar', { + 'kbnQueryBar--withDatePicker': showDatePicker, }); return ( - - + {renderQueryInput()} + - + {renderUpdateButton()} + ); - } - - const classes = classNames('kbnQueryBar', { - 'kbnQueryBar--withDatePicker': props.showDatePicker, - }); + }, + ({ query: prevQuery, ...prevProps }, { query: nextQuery, ...nextProps }) => { + let isQueryEqual = true; + if (prevQuery !== nextQuery) { + if (!deepEqual(prevQuery, nextQuery)) { + isQueryEqual = false; + } + } - return ( - - {renderQueryInput()} - {renderSharingMetaFields()} - {renderUpdateButton()} - - ); -} + return isQueryEqual && shallowEqual(prevProps, nextProps); + } +); -QueryBarTopRow.defaultProps = { - showQueryInput: true, - showDatePicker: true, - showAutoRefreshOnly: false, -}; +// Needed for React.lazy +// eslint-disable-next-line import/no-default-export +export default QueryBarTopRow; diff --git a/src/plugins/data/public/ui/query_string_input/query_string_input.tsx b/src/plugins/data/public/ui/query_string_input/query_string_input.tsx index 2e150b2c1e1bc..a0b214d1be8c7 100644 --- a/src/plugins/data/public/ui/query_string_input/query_string_input.tsx +++ b/src/plugins/data/public/ui/query_string_input/query_string_input.tsx @@ -6,31 +6,31 @@ * Side Public License, v 1. */ -import React, { Component, RefObject, createRef } from 'react'; +import React, { PureComponent } from 'react'; import { i18n } from '@kbn/i18n'; import classNames from 'classnames'; + import { - EuiTextArea, - EuiOutsideClickDetector, - PopoverAnchorPosition, + EuiButton, EuiFlexGroup, EuiFlexItem, - EuiButton, - EuiLink, - htmlIdGenerator, - EuiPortal, EuiIcon, EuiIconProps, + EuiLink, + EuiOutsideClickDetector, + EuiPortal, + EuiTextArea, + htmlIdGenerator, + PopoverAnchorPosition, } from '@elastic/eui'; - import { FormattedMessage } from '@kbn/i18n-react'; -import { debounce, compact, isEqual, isFunction } from 'lodash'; +import { compact, debounce, isEqual, isFunction } from 'lodash'; import { Toast } from 'src/core/public'; import { METRIC_TYPE } from '@kbn/analytics'; + import { IDataPluginServices, IIndexPattern, Query } from '../..'; import { QuerySuggestion, QuerySuggestionTypes } from '../../autocomplete'; - import { KibanaReactContextValue, toMountPoint } from '../../../../kibana_react/public'; import { fetchIndexPatterns } from './fetch_index_patterns'; import { QueryLanguageSwitcher } from './language_switcher'; @@ -38,7 +38,8 @@ import { getQueryLog, matchPairs, toUser, fromUser } from '../../query'; import type { PersistedLog } from '../../query'; import type { SuggestionsListSize } from '../typeahead/suggestions_component'; import { SuggestionsComponent } from '..'; -import { KIBANA_USER_QUERY_LANGUAGE_KEY, getFieldSubtypeNested } from '../../../common'; +import { getFieldSubtypeNested, KIBANA_USER_QUERY_LANGUAGE_KEY } from '../../../common'; +import { onRaf } from '../utils'; export interface QueryStringInputProps { indexPatterns: Array; @@ -96,7 +97,11 @@ interface State { selectionStart: number | null; selectionEnd: number | null; indexPatterns: IIndexPattern[]; - queryBarRect: DOMRect | undefined; + + /** + * Part of state because passed down to child components + */ + queryBarInputDiv: HTMLDivElement | null; } const KEY_CODES = { @@ -113,7 +118,7 @@ const KEY_CODES = { // Needed for React.lazy // eslint-disable-next-line import/no-default-export -export default class QueryStringInputUI extends Component { +export default class QueryStringInputUI extends PureComponent { static defaultProps = { storageKey: KIBANA_USER_QUERY_LANGUAGE_KEY, }; @@ -126,7 +131,7 @@ export default class QueryStringInputUI extends Component { selectionStart: null, selectionEnd: null, indexPatterns: [], - queryBarRect: undefined, + queryBarInputDiv: null, }; public inputRef: HTMLTextAreaElement | null = null; @@ -140,7 +145,6 @@ export default class QueryStringInputUI extends Component { this.services.appName ); private componentIsUnmounting = false; - private queryBarInputDivRefInstance: RefObject = createRef(); /** * If any element within the container is currently focused @@ -280,7 +284,9 @@ export default class QueryStringInputUI extends Component { suggestionLimit: 50, }); - this.onChange({ query: value, language: this.props.query.language }); + if (this.props.query.query !== value) { + this.onChange({ query: value, language: this.props.query.language }); + } }; private onInputChange = (event: React.ChangeEvent) => { @@ -318,10 +324,16 @@ export default class QueryStringInputUI extends Component { const { value, selectionStart, selectionEnd } = target; const updateQuery = (query: string, newSelectionStart: number, newSelectionEnd: number) => { this.onQueryStringChange(query); - this.setState({ - selectionStart: newSelectionStart, - selectionEnd: newSelectionEnd, - }); + + if ( + this.inputRef?.selectionStart !== newSelectionStart || + this.inputRef?.selectionEnd !== newSelectionEnd + ) { + this.setState({ + selectionStart: newSelectionStart, + selectionEnd: newSelectionEnd, + }); + } }; switch (event.keyCode) { @@ -576,7 +588,7 @@ export default class QueryStringInputUI extends Component { : getQueryLog(uiSettings, storage, appName, this.props.query.language); }; - public onMouseEnterSuggestion = (index: number) => { + public onMouseEnterSuggestion = (suggestion: QuerySuggestion, index: number) => { this.setState({ index }); }; @@ -590,13 +602,9 @@ export default class QueryStringInputUI extends Component { this.initPersistedLog(); this.fetchIndexPatterns(); - this.handleListUpdate(); + this.handleAutoHeight(); window.addEventListener('resize', this.handleAutoHeight); - window.addEventListener('scroll', this.handleListUpdate, { - passive: true, // for better performance as we won't call preventDefault - capture: true, // scroll events don't bubble, they must be captured instead - }); } public componentDidUpdate(prevProps: Props) { @@ -621,11 +629,12 @@ export default class QueryStringInputUI extends Component { selectionStart: null, selectionEnd: null, }); - if (document.activeElement !== null && document.activeElement.id === this.textareaId) { - this.handleAutoHeight(); - } else { - this.handleRemoveHeight(); - } + } + + if (document.activeElement !== null && document.activeElement.id === this.textareaId) { + this.handleAutoHeight(); + } else { + this.handleRemoveHeight(); } } @@ -634,47 +643,35 @@ export default class QueryStringInputUI extends Component { if (this.updateSuggestions.cancel) this.updateSuggestions.cancel(); this.componentIsUnmounting = true; window.removeEventListener('resize', this.handleAutoHeight); - window.removeEventListener('scroll', this.handleListUpdate, { capture: true }); } - handleListUpdate = () => { - if (this.componentIsUnmounting) return; - - return this.setState({ - queryBarRect: this.queryBarInputDivRefInstance.current?.getBoundingClientRect(), - }); - }; - - handleAutoHeight = () => { + handleAutoHeight = onRaf(() => { if (this.inputRef !== null && document.activeElement === this.inputRef) { this.inputRef.classList.add('kbnQueryBar__textarea--autoHeight'); this.inputRef.style.setProperty('height', `${this.inputRef.scrollHeight}px`, 'important'); } - this.handleListUpdate(); - }; + }); - handleRemoveHeight = () => { + handleRemoveHeight = onRaf(() => { if (this.inputRef !== null) { this.inputRef.style.removeProperty('height'); this.inputRef.classList.remove('kbnQueryBar__textarea--autoHeight'); } - }; + }); - handleBlurHeight = () => { + handleBlurHeight = onRaf(() => { if (this.inputRef !== null) { this.handleRemoveHeight(); this.inputRef.scrollTop = 0; } - }; + }); handleOnFocus = () => { if (this.props.onChangeQueryInputFocus) { this.props.onChangeQueryInputFocus(true); } - requestAnimationFrame(() => { - this.handleAutoHeight(); - }); + this.handleAutoHeight(); }; public render() { @@ -700,16 +697,7 @@ export default class QueryStringInputUI extends Component { ); return ( -
{ - this.isFocusWithin = true; - }} - onBlur={(e) => { - this.isFocusWithin = false; - this.scheduleOnInputBlur(); - }} - > +
{this.props.prepend}
{ aria-expanded={this.state.isSuggestionsVisible} data-skip-axe="aria-required-children" > -
+
{ autoFocus={ this.props.onChangeQueryInputFocus ? false : !this.props.disableAutoFocus } - inputRef={(node: any) => { - if (node) { - this.inputRef = node; - } - }} + inputRef={this.assignInputRef} autoComplete="off" spellCheck={false} aria-label={i18n.translate('data.query.queryBar.searchInputAriaLabel', { @@ -810,8 +790,8 @@ export default class QueryStringInputUI extends Component { onClick={this.onClickSuggestion} onMouseEnter={this.onMouseEnterSuggestion} loadMore={this.increaseLimit} - queryBarRect={this.state.queryBarRect} size={this.props.size} + inputContainer={this.state.queryBarInputDiv} />
@@ -858,4 +838,21 @@ export default class QueryStringInputUI extends Component { return formattedNewQueryString; } } + + private assignInputRef = (node: HTMLTextAreaElement | null) => { + this.inputRef = node; + }; + + private assignQueryInputDivRef = (node: HTMLDivElement | null) => { + this.setState({ queryBarInputDiv: node }); + }; + + private onFocusWithin = () => { + this.isFocusWithin = true; + }; + + private onBlurWithin = () => { + this.isFocusWithin = false; + this.scheduleOnInputBlur(); + }; } diff --git a/src/plugins/data/public/ui/search_bar/lib/use_query_string_manager.ts b/src/plugins/data/public/ui/search_bar/lib/use_query_string_manager.ts index 713020f249ae3..20c4b683b3253 100644 --- a/src/plugins/data/public/ui/search_bar/lib/use_query_string_manager.ts +++ b/src/plugins/data/public/ui/search_bar/lib/use_query_string_manager.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { Subscription } from 'rxjs'; import { Query } from '../../..'; import type { QueryStringContract } from '../../../query/query_string'; @@ -36,5 +36,13 @@ export const useQueryStringManager = (props: UseQueryStringProps) => { }; }, [props.queryStringManager]); - return { query }; + const stableQuery = useMemo( + () => ({ + language: query.language, + query: query.query, + }), + [query.language, query.query] + ); + + return { query: stableQuery }; }; diff --git a/src/plugins/data/public/ui/search_bar/search_bar.tsx b/src/plugins/data/public/ui/search_bar/search_bar.tsx index 87b6480096551..e33977f8d9048 100644 --- a/src/plugins/data/public/ui/search_bar/search_bar.tsx +++ b/src/plugins/data/public/ui/search_bar/search_bar.tsx @@ -12,6 +12,7 @@ import classNames from 'classnames'; import React, { Component } from 'react'; import { get, isEqual } from 'lodash'; import { EuiIconProps } from '@elastic/eui'; +import memoizeOne from 'memoize-one'; import { METRIC_TYPE } from '@kbn/analytics'; import { Query, Filter } from '@kbn/es-query'; @@ -186,6 +187,10 @@ class SearchBarUI extends Component { ); }; + componentWillUnmount() { + this.renderSavedQueryManagement.clear(); + } + private shouldRenderQueryBar() { const showDatePicker = this.props.showDatePicker || this.props.showAutoRefreshOnly; const showQueryInput = @@ -343,18 +348,6 @@ class SearchBarUI extends Component { }; public render() { - const savedQueryManagement = this.state.query && this.props.onClearSavedQuery && ( - - ); - const timeRangeForSuggestionsOverride = this.props.showDatePicker ? undefined : false; let queryBar; @@ -368,7 +361,15 @@ class SearchBarUI extends Component { indexPatterns={this.props.indexPatterns} isLoading={this.props.isLoading} fillSubmitButton={this.props.fillSubmitButton || false} - prepend={this.props.showFilterBar ? savedQueryManagement : undefined} + prepend={ + this.props.showFilterBar && this.state.query + ? this.renderSavedQueryManagement( + this.props.onClearSavedQuery, + this.props.showSaveQuery, + this.props.savedQuery + ) + : undefined + } showDatePicker={this.props.showDatePicker} dateRangeFrom={this.state.dateRangeFrom} dateRangeTo={this.state.dateRangeTo} @@ -447,6 +448,28 @@ class SearchBarUI extends Component {
); } + + private renderSavedQueryManagement = memoizeOne( + ( + onClearSavedQuery: SearchBarOwnProps['onClearSavedQuery'], + showSaveQuery: SearchBarOwnProps['showSaveQuery'], + savedQuery: SearchBarOwnProps['savedQuery'] + ) => { + const savedQueryManagement = onClearSavedQuery && ( + + ); + + return savedQueryManagement; + } + ); } // Needed for React.lazy diff --git a/src/plugins/data/public/ui/typeahead/__snapshots__/suggestions_component.test.tsx.snap b/src/plugins/data/public/ui/typeahead/__snapshots__/suggestions_component.test.tsx.snap deleted file mode 100644 index 9185e6a77d102..0000000000000 --- a/src/plugins/data/public/ui/typeahead/__snapshots__/suggestions_component.test.tsx.snap +++ /dev/null @@ -1,129 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`SuggestionsComponent Passing the index should control which suggestion is selected 1`] = ` - -
-
-
- - -
-
-
-
-`; - -exports[`SuggestionsComponent Should display given suggestions if the show prop is true 1`] = ` - -
-
-
- - -
-
-
-
-`; diff --git a/src/plugins/data/public/ui/typeahead/suggestion_component.test.tsx b/src/plugins/data/public/ui/typeahead/suggestion_component.test.tsx index 6f08493a714e5..56b156c3062be 100644 --- a/src/plugins/data/public/ui/typeahead/suggestion_component.test.tsx +++ b/src/plugins/data/public/ui/typeahead/suggestion_component.test.tsx @@ -59,9 +59,10 @@ describe('SuggestionComponent', () => { }); it('Should call innerRef with a reference to the root div element', () => { - const innerRefCallback = (ref: HTMLDivElement) => { + const innerRefCallback = (index: number, ref: HTMLDivElement) => { expect(ref.className).toBe('kbnTypeahead__item'); expect(ref.id).toBe('suggestion-1'); + expect(index).toBe(0); }; mount( diff --git a/src/plugins/data/public/ui/typeahead/suggestion_component.tsx b/src/plugins/data/public/ui/typeahead/suggestion_component.tsx index d4dadefcbddee..8f555f710612b 100644 --- a/src/plugins/data/public/ui/typeahead/suggestion_component.tsx +++ b/src/plugins/data/public/ui/typeahead/suggestion_component.tsx @@ -8,9 +8,9 @@ import { EuiIcon } from '@elastic/eui'; import classNames from 'classnames'; -import React from 'react'; +import React, { useCallback } from 'react'; import { QuerySuggestion } from '../../autocomplete'; -import { SuggestionOnClick } from './types'; +import { SuggestionOnClick, SuggestionOnMouseEnter } from './types'; function getEuiIconType(type: string) { switch (type) { @@ -31,16 +31,32 @@ function getEuiIconType(type: string) { interface Props { onClick: SuggestionOnClick; - onMouseEnter: () => void; + onMouseEnter: SuggestionOnMouseEnter; selected: boolean; index: number; suggestion: QuerySuggestion; - innerRef: (node: HTMLDivElement) => void; + innerRef: (index: number, node: HTMLDivElement) => void; ariaId: string; shouldDisplayDescription: boolean; } -export function SuggestionComponent(props: Props) { +export const SuggestionComponent = React.memo(function SuggestionComponent(props: Props) { + const { index, innerRef, onClick, onMouseEnter, suggestion } = props; + const setRef = useCallback( + (node: HTMLDivElement) => { + innerRef(index, node); + }, + [index, innerRef] + ); + + const handleClick = useCallback(() => { + onClick(suggestion, index); + }, [index, onClick, suggestion]); + + const handleMouseEnter = useCallback(() => { + onMouseEnter(suggestion, index); + }, [index, onMouseEnter, suggestion]); + return ( // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/interactive-supports-focus
props.onClick(props.suggestion, props.index)} - onMouseEnter={props.onMouseEnter} - ref={props.innerRef} + onMouseEnter={handleMouseEnter} + onClick={handleClick} + ref={setRef} id={props.ariaId} aria-selected={props.selected} data-test-subj={`autocompleteSuggestion-${ @@ -72,4 +88,4 @@ export function SuggestionComponent(props: Props) {
); -} +}); diff --git a/src/plugins/data/public/ui/typeahead/suggestions_component.test.tsx b/src/plugins/data/public/ui/typeahead/suggestions_component.test.tsx index dce8d5bdcfcd1..d34f48e2957a9 100644 --- a/src/plugins/data/public/ui/typeahead/suggestions_component.test.tsx +++ b/src/plugins/data/public/ui/typeahead/suggestions_component.test.tsx @@ -16,6 +16,8 @@ const noop = () => { return; }; +const mockContainerDiv = document.createElement('div'); + const mockSuggestions: QuerySuggestion[] = [ { description: 'This is not a helpful suggestion', @@ -43,7 +45,7 @@ describe('SuggestionsComponent', () => { show={false} suggestions={mockSuggestions} loadMore={noop} - queryBarRect={{ top: 0 } as DOMRect} + inputContainer={mockContainerDiv} /> ); @@ -59,7 +61,7 @@ describe('SuggestionsComponent', () => { show={true} suggestions={[]} loadMore={noop} - queryBarRect={{ top: 0 } as DOMRect} + inputContainer={mockContainerDiv} /> ); @@ -67,7 +69,7 @@ describe('SuggestionsComponent', () => { }); it('Should display given suggestions if the show prop is true', () => { - const component = shallow( + const component = mount( { show={true} suggestions={mockSuggestions} loadMore={noop} - queryBarRect={{ top: 0 } as DOMRect} + inputContainer={mockContainerDiv} /> ); expect(component.isEmptyRender()).toBe(false); - expect(component).toMatchSnapshot(); + expect(component.find(SuggestionComponent)).toHaveLength(2); }); it('Passing the index should control which suggestion is selected', () => { - const component = shallow( + const component = mount( { show={true} suggestions={mockSuggestions} loadMore={noop} - queryBarRect={{ top: 0 } as DOMRect} + inputContainer={mockContainerDiv} /> ); - expect(component).toMatchSnapshot(); + expect(component.find(SuggestionComponent).at(1).prop('selected')).toBe(true); }); it('Should call onClick with the selected suggestion when it is clicked', () => { @@ -109,7 +111,7 @@ describe('SuggestionsComponent', () => { show={true} suggestions={mockSuggestions} loadMore={noop} - queryBarRect={{ top: 0 } as DOMRect} + inputContainer={mockContainerDiv} /> ); @@ -128,12 +130,12 @@ describe('SuggestionsComponent', () => { show={true} suggestions={mockSuggestions} loadMore={noop} - queryBarRect={{ top: 0 } as DOMRect} + inputContainer={mockContainerDiv} /> ); component.find(SuggestionComponent).at(1).simulate('mouseenter'); expect(mockCallback).toHaveBeenCalledTimes(1); - expect(mockCallback).toHaveBeenCalledWith(1); + expect(mockCallback).toHaveBeenCalledWith(mockSuggestions[1], 1); }); }); diff --git a/src/plugins/data/public/ui/typeahead/suggestions_component.tsx b/src/plugins/data/public/ui/typeahead/suggestions_component.tsx index f7d6e2c3d6403..7a0a4378c4ddc 100644 --- a/src/plugins/data/public/ui/typeahead/suggestions_component.tsx +++ b/src/plugins/data/public/ui/typeahead/suggestions_component.tsx @@ -7,9 +7,11 @@ */ import { isEmpty } from 'lodash'; -import React, { Component } from 'react'; +import React, { PureComponent, ReactNode } from 'react'; import classNames from 'classnames'; import styled from 'styled-components'; + +import useRafState from 'react-use/lib/useRafState'; import { QuerySuggestion } from '../../autocomplete'; import { SuggestionComponent } from './suggestion_component'; import { @@ -17,86 +19,86 @@ import { SUGGESTIONS_LIST_REQUIRED_TOP_OFFSET, SUGGESTIONS_LIST_REQUIRED_WIDTH, } from './constants'; -import { SuggestionOnClick } from './types'; +import { SuggestionOnClick, SuggestionOnMouseEnter } from './types'; +import { onRaf } from '../utils'; +import { shallowEqual } from '../../utils/shallow_equal'; interface SuggestionsComponentProps { index: number | null; onClick: SuggestionOnClick; - onMouseEnter: (index: number) => void; + onMouseEnter: SuggestionOnMouseEnter; show: boolean; suggestions: QuerySuggestion[]; loadMore: () => void; - queryBarRect?: DOMRect; size?: SuggestionsListSize; + inputContainer: HTMLElement | null; } export type SuggestionsListSize = 's' | 'l'; // Needed for React.lazy // eslint-disable-next-line import/no-default-export -export default class SuggestionsComponent extends Component { +export default class SuggestionsComponent extends PureComponent { private childNodes: HTMLDivElement[] = []; private parentNode: HTMLDivElement | null = null; + constructor(props: SuggestionsComponentProps) { + super(props); + + this.assignParentNode = this.assignParentNode.bind(this); + this.assignChildNode = this.assignChildNode.bind(this); + } + + private assignParentNode(node: HTMLDivElement) { + this.parentNode = node; + } + + private assignChildNode(index: number, node: HTMLDivElement) { + this.childNodes[index] = node; + } + public render() { - if (!this.props.queryBarRect || !this.props.show || isEmpty(this.props.suggestions)) { + if (!this.props.inputContainer || !this.props.show || isEmpty(this.props.suggestions)) { return null; } - const suggestions = this.props.suggestions.map((suggestion, index) => { - const isDescriptionFittable = - this.props.queryBarRect!.width >= SUGGESTIONS_LIST_REQUIRED_WIDTH; - return ( - (this.childNodes[index] = node)} - selected={index === this.props.index} - index={index} - suggestion={suggestion} - onClick={this.props.onClick} - onMouseEnter={() => this.props.onMouseEnter(index)} - ariaId={'suggestion-' + index} - key={`${suggestion.type} - ${suggestion.text}`} - shouldDisplayDescription={isDescriptionFittable} - /> - ); - }); - - const documentHeight = document.documentElement.clientHeight || window.innerHeight; - const { queryBarRect } = this.props; + const renderSuggestions = (containerWidth: number) => { + const isDescriptionFittable = containerWidth >= SUGGESTIONS_LIST_REQUIRED_WIDTH; + const suggestions = this.props.suggestions.map((suggestion, index) => { + return ( + + ); + }); - // reflects if the suggestions list has enough space below to be opened down - const isSuggestionsListFittable = - documentHeight - (queryBarRect.top + queryBarRect.height) > - SUGGESTIONS_LIST_REQUIRED_BOTTOM_SPACE; - const verticalListPosition = isSuggestionsListFittable - ? `top: ${window.scrollY + queryBarRect.bottom - SUGGESTIONS_LIST_REQUIRED_TOP_OFFSET}px;` - : `bottom: ${documentHeight - (window.scrollY + queryBarRect.top)}px;`; + return suggestions; + }; return ( - -
+ {(containerWidth: number) => (
-
(this.parentNode = node)} - onScroll={this.handleScroll} - > - {suggestions} -
+ {renderSuggestions(containerWidth)}
-
-
+ )} + ); } @@ -106,7 +108,7 @@ export default class SuggestionsComponent extends Component { + private scrollIntoView = onRaf(() => { if (this.props.index === null) { return; } @@ -123,9 +125,9 @@ export default class SuggestionsComponent extends Component { + private handleScroll = onRaf(() => { if (!this.props.loadMore || !this.parentNode) { return; } @@ -141,14 +143,130 @@ export default class SuggestionsComponent extends Component ` + ${(props: { left: number; width: number; verticalListPosition: string }) => ` position: absolute; z-index: 4001; - left: ${props.queryBarRect.left}px; - width: ${props.queryBarRect.width}px; + left: ${props.left}px; + width: ${props.width}px; ${props.verticalListPosition}`} `; + +const ResizableSuggestionsListDiv: React.FC<{ + inputContainer: HTMLElement; + suggestionsSize?: SuggestionsListSize; +}> = React.memo((props) => { + const inputContainer = props.inputContainer; + const children = props.children as (rect: DOMRect) => ReactNode; + + const [{ documentHeight }, { pageYOffset }, containerRect] = useDimensions(inputContainer); + + if (!containerRect) return null; + + // reflects if the suggestions list has enough space below to be opened down + const isSuggestionsListFittable = + documentHeight - (containerRect.top + containerRect.height) > + SUGGESTIONS_LIST_REQUIRED_BOTTOM_SPACE; + const verticalListPosition = isSuggestionsListFittable + ? `top: ${pageYOffset + containerRect.bottom - SUGGESTIONS_LIST_REQUIRED_TOP_OFFSET}px;` + : `bottom: ${documentHeight - (pageYOffset + containerRect.top)}px;`; + + return ( + +
+
+ {children(containerRect)} +
+
+
+ ); +}); + +function useDimensions( + container: HTMLElement | null +): [{ documentHeight: number }, { pageYOffset: number; pageXOffset: number }, DOMRect | null] { + const [documentHeight, setDocumentHeight] = useRafState( + () => document.documentElement.clientHeight || window.innerHeight + ); + + const [pageOffset, setPageOffset] = useRafState<{ pageXOffset: number; pageYOffset: number }>( + () => ({ + pageXOffset: window.pageXOffset, + pageYOffset: window.pageYOffset, + }) + ); + + const [containerRect, setContainerRect] = useRafState(() => { + return container?.getBoundingClientRect() ?? null; + }); + + const updateContainerRect = React.useCallback(() => { + setContainerRect((oldRect: DOMRect | null) => { + const newRect = container?.getBoundingClientRect() ?? null; + const rectsEqual = shallowEqual(oldRect?.toJSON(), newRect?.toJSON()); + return rectsEqual ? oldRect : newRect; + }); + }, [container, setContainerRect]); + + React.useEffect(() => { + const handler = () => { + setDocumentHeight(document.documentElement.clientHeight || window.innerHeight); + }; + + window.addEventListener('resize', handler, { passive: true }); + + return () => { + window.removeEventListener('resize', handler); + }; + }, [setDocumentHeight]); + + React.useEffect(() => { + const handler = () => { + setPageOffset((state) => { + const { pageXOffset, pageYOffset } = window; + return state.pageXOffset !== pageXOffset || state.pageYOffset !== pageYOffset + ? { + pageXOffset, + pageYOffset, + } + : state; + }); + + updateContainerRect(); + }; + + window.addEventListener('scroll', handler, { passive: true, capture: true }); + + const resizeObserver = + typeof window.ResizeObserver !== 'undefined' && + new ResizeObserver(() => { + updateContainerRect(); + }); + if (container && resizeObserver) { + resizeObserver.observe(container); + } + + return () => { + window.removeEventListener('scroll', handler, { capture: true }); + if (resizeObserver) resizeObserver.disconnect(); + }; + }, [setPageOffset, container, updateContainerRect]); + + return [{ documentHeight }, pageOffset, containerRect]; +} diff --git a/src/plugins/data/public/ui/typeahead/types.ts b/src/plugins/data/public/ui/typeahead/types.ts index d0be717b2bf9b..16a18bc68430f 100644 --- a/src/plugins/data/public/ui/typeahead/types.ts +++ b/src/plugins/data/public/ui/typeahead/types.ts @@ -9,3 +9,5 @@ import { QuerySuggestion } from '../../autocomplete'; export type SuggestionOnClick = (suggestion: QuerySuggestion, index: number) => void; + +export type SuggestionOnMouseEnter = (suggestion: QuerySuggestion, index: number) => void; diff --git a/src/plugins/data/public/ui/utils/index.ts b/src/plugins/data/public/ui/utils/index.ts new file mode 100644 index 0000000000000..38c04459aae2a --- /dev/null +++ b/src/plugins/data/public/ui/utils/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export { onRaf } from './on_raf'; diff --git a/src/plugins/data/public/ui/utils/on_raf.ts b/src/plugins/data/public/ui/utils/on_raf.ts new file mode 100644 index 0000000000000..fbe37e5d5c594 --- /dev/null +++ b/src/plugins/data/public/ui/utils/on_raf.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +/** + * Debounce a function till next animation frame + * @param fn + */ +export function onRaf(fn: Function) { + let req: number | null; + return (...args: unknown[]) => { + if (req) window.cancelAnimationFrame(req); + req = window.requestAnimationFrame(() => { + req = null; + fn(...args); + }); + }; +} diff --git a/src/plugins/data/public/utils/shallow_equal.ts b/src/plugins/data/public/utils/shallow_equal.ts new file mode 100644 index 0000000000000..e0387e07a163c --- /dev/null +++ b/src/plugins/data/public/utils/shallow_equal.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +/** + * Shallow Equal check adapted from react-redux + * Copy-pasted to avoid importing copy of react-redux into data plugin async chunk + **/ +export function shallowEqual(objA: unknown, objB: unknown): boolean { + if (Object.is(objA, objB)) return true; + + if (typeof objA !== 'object' || objA === null || typeof objB !== 'object' || objB === null) { + return false; + } + + const keysA = Object.keys(objA); + const keysB = Object.keys(objB); + + if (keysA.length !== keysB.length) return false; + + for (let i = 0; i < keysA.length; i++) { + if ( + !Object.prototype.hasOwnProperty.call(objB, keysA[i]) || + // @ts-ignore + !Object.is(objA[keysA[i]], objB[keysA[i]]) + ) { + return false; + } + } + + return true; +} diff --git a/yarn.lock b/yarn.lock index 2467b78c1e81b..d564d3bce5282 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6178,11 +6178,6 @@ dependencies: "@types/unist" "*" -"@types/memoize-one@^4.1.0": - version "4.1.1" - resolved "https://registry.yarnpkg.com/@types/memoize-one/-/memoize-one-4.1.1.tgz#41dd138a4335b5041f7d8fc038f9d593d88b3369" - integrity sha512-+9djKUUn8hOyktLCfCy4hLaIPgDNovaU36fsnZe9trFHr6ddlbIn2q0SEsnkCkNR+pBWEU440Molz/+Mpyf+gQ== - "@types/micromatch@^4.0.1": version "4.0.1" resolved "https://registry.yarnpkg.com/@types/micromatch/-/micromatch-4.0.1.tgz#9381449dd659fc3823fd2a4190ceacc985083bc7" @@ -19571,6 +19566,11 @@ memfs@^3.1.2: resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.1.1.tgz#047b6e3199b508eaec03504de71229b8eb1d75c0" integrity sha512-HKeeBpWvqiVJD57ZUAsJNm71eHTykffzcLZVYWiVfQeI1rJtuEaS7hQiEpWfVVk18donPwJEcFKIkCmPJNOhHA== +memoize-one@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-6.0.0.tgz#b2591b871ed82948aee4727dc6abceeeac8c1045" + integrity sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw== + memoizee@0.4.X: version "0.4.14" resolved "https://registry.yarnpkg.com/memoizee/-/memoizee-0.4.14.tgz#07a00f204699f9a95c2d9e77218271c7cd610d57" From e62768a9c884409fd0781769fca26c8b888d3823 Mon Sep 17 00:00:00 2001 From: Matthew Kime Date: Thu, 6 Jan 2022 07:15:05 -0600 Subject: [PATCH 3/6] [data views] data_views REST API (#112916) * initial stab at changing endpoints to data_view while keeping existing endpoints * partial progress * partial progress * partial progress on multiple fields returned * partial progress on multiple fields returned * partial progress on multiple fields returned * correct response - index_pattern => data_view * fix legacy key * update runtime field responses to return array of fields instead of since field value * complete tests for data views api * fix export * more tests * fix types for data view create Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- src/plugins/data_views/server/constants.ts | 27 + src/plugins/data_views/server/index.ts | 21 + src/plugins/data_views/server/routes.ts | 107 ++- .../server/routes/create_index_pattern.ts | 116 ++- .../server/routes/default_index_pattern.ts | 141 +-- .../server/routes/delete_index_pattern.ts | 95 +- .../server/routes/fields/update_fields.ts | 200 +++-- .../server/routes/get_index_pattern.ts | 110 ++- .../server/routes/has_user_index_pattern.ts | 75 +- .../runtime_fields/create_runtime_field.ts | 133 +-- .../runtime_fields/delete_runtime_field.ts | 103 ++- .../runtime_fields/get_runtime_field.ts | 126 +-- .../runtime_fields/put_runtime_field.ts | 137 +-- .../runtime_fields/update_runtime_field.ts | 154 ++-- .../server/routes/update_index_pattern.ts | 259 +++--- .../apis/index_patterns/constants.ts | 32 + .../default_index_pattern.ts | 67 +- .../fields_api/update_fields/errors.ts | 95 +- .../fields_api/update_fields/main.ts | 811 +++++++++--------- .../has_user_index_pattern.ts | 245 +++--- .../create_index_pattern/main.ts | 482 ++++++----- .../create_index_pattern/validation.ts | 123 +-- .../delete_index_pattern/errors.ts | 29 +- .../delete_index_pattern/main.ts | 81 +- .../get_index_pattern/errors.ts | 29 +- .../get_index_pattern/main.ts | 27 +- .../update_index_pattern/errors.ts | 95 +- .../update_index_pattern/main.ts | 471 +++++----- .../create_runtime_field/errors.ts | 35 +- .../create_runtime_field/main.ts | 116 +-- .../delete_runtime_field/errors.ts | 99 +-- .../delete_runtime_field/main.ts | 61 +- .../get_runtime_field/errors.ts | 99 +-- .../get_runtime_field/main.ts | 76 +- .../put_runtime_field/errors.ts | 71 +- .../put_runtime_field/main.ts | 164 ++-- .../update_runtime_field/errors.ts | 57 +- .../update_runtime_field/main.ts | 84 +- 38 files changed, 2865 insertions(+), 2388 deletions(-) create mode 100644 src/plugins/data_views/server/constants.ts create mode 100644 test/api_integration/apis/index_patterns/constants.ts diff --git a/src/plugins/data_views/server/constants.ts b/src/plugins/data_views/server/constants.ts new file mode 100644 index 0000000000000..15ca2c97f50ec --- /dev/null +++ b/src/plugins/data_views/server/constants.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export const SERVICE_PATH = '/api/data_views'; +export const SERVICE_PATH_LEGACY = '/api/index_patterns'; +export const DATA_VIEW_PATH = `${SERVICE_PATH}/data_view`; +export const DATA_VIEW_PATH_LEGACY = `${SERVICE_PATH_LEGACY}/index_pattern`; +export const SPECIFIC_DATA_VIEW_PATH = `${DATA_VIEW_PATH}/{id}`; +export const SPECIFIC_DATA_VIEW_PATH_LEGACY = `${DATA_VIEW_PATH_LEGACY}/{id}`; +export const RUNTIME_FIELD_PATH = `${SPECIFIC_DATA_VIEW_PATH}/runtime_field`; +export const RUNTIME_FIELD_PATH_LEGACY = `${SPECIFIC_DATA_VIEW_PATH_LEGACY}/runtime_field`; +export const SPECIFIC_RUNTIME_FIELD_PATH = `${RUNTIME_FIELD_PATH}/{name}`; +export const SPECIFIC_RUNTIME_FIELD_PATH_LEGACY = `${RUNTIME_FIELD_PATH_LEGACY}/{name}`; + +export const SCRIPTED_FIELD_PATH = `${SPECIFIC_DATA_VIEW_PATH}/scripted_field`; +export const SCRIPTED_FIELD_PATH_LEGACY = `${SPECIFIC_DATA_VIEW_PATH_LEGACY}/scripted_field`; +export const SPECIFIC_SCRIPTED_FIELD_PATH = `${SCRIPTED_FIELD_PATH}/{name}`; +export const SPECIFIC_SCRIPTED_FIELD_PATH_LEGACY = `${SCRIPTED_FIELD_PATH_LEGACY}/{name}`; + +export const SERVICE_KEY = 'data_view'; +export const SERVICE_KEY_LEGACY = 'index_pattern'; +export type SERVICE_KEY_TYPE = typeof SERVICE_KEY | typeof SERVICE_KEY_LEGACY; diff --git a/src/plugins/data_views/server/index.ts b/src/plugins/data_views/server/index.ts index 7a4df9518b435..4ba79373ee5bb 100644 --- a/src/plugins/data_views/server/index.ts +++ b/src/plugins/data_views/server/index.ts @@ -35,3 +35,24 @@ export type { DataViewsServerPluginStart as PluginStart, }; export { DataViewsServerPlugin as Plugin }; + +export { + SERVICE_PATH, + SERVICE_PATH_LEGACY, + DATA_VIEW_PATH, + DATA_VIEW_PATH_LEGACY, + SPECIFIC_DATA_VIEW_PATH, + SPECIFIC_DATA_VIEW_PATH_LEGACY, + RUNTIME_FIELD_PATH, + RUNTIME_FIELD_PATH_LEGACY, + SPECIFIC_RUNTIME_FIELD_PATH, + SPECIFIC_RUNTIME_FIELD_PATH_LEGACY, + SCRIPTED_FIELD_PATH, + SCRIPTED_FIELD_PATH_LEGACY, + SPECIFIC_SCRIPTED_FIELD_PATH, + SPECIFIC_SCRIPTED_FIELD_PATH_LEGACY, + SERVICE_KEY, + SERVICE_KEY_LEGACY, +} from './constants'; + +export type { SERVICE_KEY_TYPE } from './constants'; diff --git a/src/plugins/data_views/server/routes.ts b/src/plugins/data_views/server/routes.ts index f6e91980ea34f..2a2f561997cd6 100644 --- a/src/plugins/data_views/server/routes.ts +++ b/src/plugins/data_views/server/routes.ts @@ -9,24 +9,62 @@ import { schema } from '@kbn/config-schema'; import { HttpServiceSetup, StartServicesAccessor } from 'kibana/server'; import { IndexPatternsFetcher } from './fetcher'; -import { registerCreateIndexPatternRoute } from './routes/create_index_pattern'; -import { registerGetIndexPatternRoute } from './routes/get_index_pattern'; -import { registerDeleteIndexPatternRoute } from './routes/delete_index_pattern'; -import { registerUpdateIndexPatternRoute } from './routes/update_index_pattern'; -import { registerUpdateFieldsRoute } from './routes/fields/update_fields'; +import { + registerCreateDataViewRoute, + registerCreateDataViewRouteLegacy, +} from './routes/create_index_pattern'; +import { + registerGetDataViewRoute, + registerGetDataViewRouteLegacy, +} from './routes/get_index_pattern'; +import { + registerDeleteDataViewRoute, + registerDeleteDataViewRouteLegacy, +} from './routes/delete_index_pattern'; +import { + registerUpdateDataViewRoute, + registerUpdateDataViewRouteLegacy, +} from './routes/update_index_pattern'; +import { + registerUpdateFieldsRoute, + registerUpdateFieldsRouteLegacy, +} from './routes/fields/update_fields'; import { registerCreateScriptedFieldRoute } from './routes/scripted_fields/create_scripted_field'; import { registerPutScriptedFieldRoute } from './routes/scripted_fields/put_scripted_field'; import { registerGetScriptedFieldRoute } from './routes/scripted_fields/get_scripted_field'; import { registerDeleteScriptedFieldRoute } from './routes/scripted_fields/delete_scripted_field'; import { registerUpdateScriptedFieldRoute } from './routes/scripted_fields/update_scripted_field'; +import { + registerManageDefaultDataViewRoute, + registerManageDefaultDataViewRouteLegacy, +} from './routes/default_index_pattern'; import type { DataViewsServerPluginStart, DataViewsServerPluginStartDependencies } from './types'; -import { registerManageDefaultIndexPatternRoutes } from './routes/default_index_pattern'; -import { registerCreateRuntimeFieldRoute } from './routes/runtime_fields/create_runtime_field'; -import { registerGetRuntimeFieldRoute } from './routes/runtime_fields/get_runtime_field'; -import { registerDeleteRuntimeFieldRoute } from './routes/runtime_fields/delete_runtime_field'; -import { registerPutRuntimeFieldRoute } from './routes/runtime_fields/put_runtime_field'; -import { registerUpdateRuntimeFieldRoute } from './routes/runtime_fields/update_runtime_field'; -import { registerHasUserIndexPatternRoute } from './routes/has_user_index_pattern'; + +import { + registerCreateRuntimeFieldRoute, + registerCreateRuntimeFieldRouteLegacy, +} from './routes/runtime_fields/create_runtime_field'; +import { + registerGetRuntimeFieldRoute, + registerGetRuntimeFieldRouteLegacy, +} from './routes/runtime_fields/get_runtime_field'; +import { + registerDeleteRuntimeFieldRoute, + registerDeleteRuntimeFieldRouteLegacy, +} from './routes/runtime_fields/delete_runtime_field'; +import { + registerPutRuntimeFieldRoute, + registerPutRuntimeFieldRouteLegacy, +} from './routes/runtime_fields/put_runtime_field'; +import { + registerUpdateRuntimeFieldRoute, + registerUpdateRuntimeFieldRouteLegacy, +} from './routes/runtime_fields/update_runtime_field'; +import { + registerHasUserDataViewRoute, + registerHasUserDataViewRouteLegacy, +} from './routes/has_user_index_pattern'; + import { registerFieldForWildcard } from './fields_for'; export function registerRoutes( @@ -48,17 +86,37 @@ export function registerRoutes( const router = http.createRouter(); - // Index Patterns API - registerCreateIndexPatternRoute(router, getStartServices); - registerGetIndexPatternRoute(router, getStartServices); - registerDeleteIndexPatternRoute(router, getStartServices); - registerUpdateIndexPatternRoute(router, getStartServices); - registerManageDefaultIndexPatternRoutes(router, getStartServices); - registerHasUserIndexPatternRoute(router, getStartServices); + // Data Views API + registerCreateDataViewRoute(router, getStartServices); + registerGetDataViewRoute(router, getStartServices); + registerDeleteDataViewRoute(router, getStartServices); + registerUpdateDataViewRoute(router, getStartServices); + registerManageDefaultDataViewRoute(router, getStartServices); + registerHasUserDataViewRoute(router, getStartServices); // Fields API registerUpdateFieldsRoute(router, getStartServices); + // Runtime Fields API + registerCreateRuntimeFieldRoute(router, getStartServices); + registerGetRuntimeFieldRoute(router, getStartServices); + registerDeleteRuntimeFieldRoute(router, getStartServices); + registerPutRuntimeFieldRoute(router, getStartServices); + registerUpdateRuntimeFieldRoute(router, getStartServices); + + // ### + // Legacy Index Pattern API + // ### + registerCreateDataViewRouteLegacy(router, getStartServices); + registerGetDataViewRouteLegacy(router, getStartServices); + registerDeleteDataViewRouteLegacy(router, getStartServices); + registerUpdateDataViewRouteLegacy(router, getStartServices); + registerManageDefaultDataViewRouteLegacy(router, getStartServices); + registerHasUserDataViewRouteLegacy(router, getStartServices); + + // Fields API + registerUpdateFieldsRouteLegacy(router, getStartServices); + // Scripted Field API registerCreateScriptedFieldRoute(router, getStartServices); registerPutScriptedFieldRoute(router, getStartServices); @@ -67,11 +125,12 @@ export function registerRoutes( registerUpdateScriptedFieldRoute(router, getStartServices); // Runtime Fields API - registerCreateRuntimeFieldRoute(router, getStartServices); - registerGetRuntimeFieldRoute(router, getStartServices); - registerDeleteRuntimeFieldRoute(router, getStartServices); - registerPutRuntimeFieldRoute(router, getStartServices); - registerUpdateRuntimeFieldRoute(router, getStartServices); + registerCreateRuntimeFieldRouteLegacy(router, getStartServices); + registerGetRuntimeFieldRouteLegacy(router, getStartServices); + registerDeleteRuntimeFieldRouteLegacy(router, getStartServices); + registerPutRuntimeFieldRouteLegacy(router, getStartServices); + registerUpdateRuntimeFieldRouteLegacy(router, getStartServices); + // ### registerFieldForWildcard(router, getStartServices); diff --git a/src/plugins/data_views/server/routes/create_index_pattern.ts b/src/plugins/data_views/server/routes/create_index_pattern.ts index d50012596ee56..7f5575c8e7cae 100644 --- a/src/plugins/data_views/server/routes/create_index_pattern.ts +++ b/src/plugins/data_views/server/routes/create_index_pattern.ts @@ -7,7 +7,7 @@ */ import { schema } from '@kbn/config-schema'; -import { IndexPatternSpec } from 'src/plugins/data_views/common'; +import { DataViewSpec } from 'src/plugins/data_views/common'; import { handleErrors } from './util/handle_errors'; import { fieldSpecSchema, @@ -15,7 +15,13 @@ import { serializedFieldFormatSchema, } from './util/schemas'; import { IRouter, StartServicesAccessor } from '../../../../core/server'; -import type { DataViewsServerPluginStart, DataViewsServerPluginStartDependencies } from '../types'; +import type { DataViewsServerPluginStartDependencies, DataViewsServerPluginStart } from '../types'; +import { + DATA_VIEW_PATH, + DATA_VIEW_PATH_LEGACY, + SERVICE_KEY, + SERVICE_KEY_LEGACY, +} from '../constants'; const indexPatternSpecSchema = schema.object({ title: schema.string(), @@ -46,51 +52,67 @@ const indexPatternSpecSchema = schema.object({ runtimeFieldMap: schema.maybe(schema.recordOf(schema.string(), runtimeFieldSpecSchema)), }); -export const registerCreateIndexPatternRoute = ( - router: IRouter, - getStartServices: StartServicesAccessor< - DataViewsServerPluginStartDependencies, - DataViewsServerPluginStart - > -) => { - router.post( - { - path: '/api/index_patterns/index_pattern', - validate: { - body: schema.object({ - override: schema.maybe(schema.boolean({ defaultValue: false })), - refresh_fields: schema.maybe(schema.boolean({ defaultValue: false })), - index_pattern: indexPatternSpecSchema, - }), +const registerCreateDataViewRouteFactory = + (path: string, serviceKey: string) => + ( + router: IRouter, + getStartServices: StartServicesAccessor< + DataViewsServerPluginStartDependencies, + DataViewsServerPluginStart + > + ) => { + router.post( + { + path, + validate: { + body: schema.object({ + override: schema.maybe(schema.boolean({ defaultValue: false })), + refresh_fields: schema.maybe(schema.boolean({ defaultValue: false })), + data_view: serviceKey === SERVICE_KEY ? indexPatternSpecSchema : schema.never(), + index_pattern: + serviceKey === SERVICE_KEY_LEGACY ? indexPatternSpecSchema : schema.never(), + }), + }, }, - }, - router.handleLegacyErrors( - handleErrors(async (ctx, req, res) => { - const savedObjectsClient = ctx.core.savedObjects.client; - const elasticsearchClient = ctx.core.elasticsearch.client.asCurrentUser; - const [, , { indexPatternsServiceFactory }] = await getStartServices(); - const indexPatternsService = await indexPatternsServiceFactory( - savedObjectsClient, - elasticsearchClient, - req - ); - const body = req.body; + router.handleLegacyErrors( + handleErrors(async (ctx, req, res) => { + const savedObjectsClient = ctx.core.savedObjects.client; + const elasticsearchClient = ctx.core.elasticsearch.client.asCurrentUser; + const [, , { dataViewsServiceFactory }] = await getStartServices(); + const indexPatternsService = await dataViewsServiceFactory( + savedObjectsClient, + elasticsearchClient, + req + ); + const body = req.body; - const indexPattern = await indexPatternsService.createAndSave( - body.index_pattern as IndexPatternSpec, - body.override, - !body.refresh_fields - ); + const spec = serviceKey === SERVICE_KEY ? body.data_view : body.index_pattern; - return res.ok({ - headers: { - 'content-type': 'application/json', - }, - body: JSON.stringify({ - index_pattern: indexPattern.toSpec(), - }), - }); - }) - ) - ); -}; + const indexPattern = await indexPatternsService.createAndSave( + spec as DataViewSpec, + body.override, + !body.refresh_fields + ); + + return res.ok({ + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ + [serviceKey]: indexPattern.toSpec(), + }), + }); + }) + ) + ); + }; + +export const registerCreateDataViewRoute = registerCreateDataViewRouteFactory( + DATA_VIEW_PATH, + SERVICE_KEY +); + +export const registerCreateDataViewRouteLegacy = registerCreateDataViewRouteFactory( + DATA_VIEW_PATH_LEGACY, + SERVICE_KEY_LEGACY +); diff --git a/src/plugins/data_views/server/routes/default_index_pattern.ts b/src/plugins/data_views/server/routes/default_index_pattern.ts index 1fe56db6c7488..ae6653e638ef6 100644 --- a/src/plugins/data_views/server/routes/default_index_pattern.ts +++ b/src/plugins/data_views/server/routes/default_index_pattern.ts @@ -8,76 +8,89 @@ import { schema } from '@kbn/config-schema'; import { IRouter, StartServicesAccessor } from '../../../../core/server'; -import type { DataViewsServerPluginStart, DataViewsServerPluginStartDependencies } from '../types'; +import type { DataViewsServerPluginStartDependencies, DataViewsServerPluginStart } from '../types'; import { handleErrors } from './util/handle_errors'; +import { SERVICE_PATH, SERVICE_PATH_LEGACY, SERVICE_KEY, SERVICE_KEY_LEGACY } from '../constants'; -export const registerManageDefaultIndexPatternRoutes = ( - router: IRouter, - getStartServices: StartServicesAccessor< - DataViewsServerPluginStartDependencies, - DataViewsServerPluginStart - > -) => { - router.get( - { - path: '/api/index_patterns/default', - validate: {}, - }, - handleErrors(async (ctx, req, res) => { - const savedObjectsClient = ctx.core.savedObjects.client; - const elasticsearchClient = ctx.core.elasticsearch.client.asCurrentUser; - const [, , { indexPatternsServiceFactory }] = await getStartServices(); - const indexPatternsService = await indexPatternsServiceFactory( - savedObjectsClient, - elasticsearchClient, - req - ); +const manageDefaultIndexPatternRoutesFactory = + (path: string, serviceKey: string) => + ( + router: IRouter, + getStartServices: StartServicesAccessor< + DataViewsServerPluginStartDependencies, + DataViewsServerPluginStart + > + ) => { + router.get( + { + path, + validate: {}, + }, + handleErrors(async (ctx, req, res) => { + const savedObjectsClient = ctx.core.savedObjects.client; + const elasticsearchClient = ctx.core.elasticsearch.client.asCurrentUser; + const [, , { dataViewsServiceFactory }] = await getStartServices(); + const indexPatternsService = await dataViewsServiceFactory( + savedObjectsClient, + elasticsearchClient, + req + ); - const defaultIndexPatternId = await indexPatternsService.getDefaultId(); + const defaultIndexPatternId = await indexPatternsService.getDefaultId(); - return res.ok({ - body: { - index_pattern_id: defaultIndexPatternId, - }, - }); - }) - ); + return res.ok({ + body: { + [`${serviceKey}_id`]: defaultIndexPatternId, + }, + }); + }) + ); - router.post( - { - path: '/api/index_patterns/default', - validate: { - body: schema.object({ - index_pattern_id: schema.nullable( - schema.string({ - minLength: 1, - maxLength: 1_000, - }) - ), - force: schema.boolean({ defaultValue: false }), - }), + router.post( + { + path, + validate: { + body: schema.object({ + [`${serviceKey}_id`]: schema.nullable( + schema.string({ + minLength: 1, + maxLength: 1_000, + }) + ), + force: schema.boolean({ defaultValue: false }), + }), + }, }, - }, - handleErrors(async (ctx, req, res) => { - const savedObjectsClient = ctx.core.savedObjects.client; - const elasticsearchClient = ctx.core.elasticsearch.client.asCurrentUser; - const [, , { indexPatternsServiceFactory }] = await getStartServices(); - const indexPatternsService = await indexPatternsServiceFactory( - savedObjectsClient, - elasticsearchClient, - req - ); + handleErrors(async (ctx, req, res) => { + const savedObjectsClient = ctx.core.savedObjects.client; + const elasticsearchClient = ctx.core.elasticsearch.client.asCurrentUser; + const [, , { dataViewsServiceFactory }] = await getStartServices(); + const indexPatternsService = await dataViewsServiceFactory( + savedObjectsClient, + elasticsearchClient, + req + ); - const newDefaultId = req.body.index_pattern_id; - const force = req.body.force; + const newDefaultId = req.body[`${serviceKey}_id`] as string; + const force = req.body.force as boolean; - await indexPatternsService.setDefault(newDefaultId, force); + await indexPatternsService.setDefault(newDefaultId, force); - return res.ok({ - body: { - acknowledged: true, - }, - }); - }) - ); -}; + return res.ok({ + body: { + acknowledged: true, + }, + }); + }) + ); + }; + +export const registerManageDefaultDataViewRoute = manageDefaultIndexPatternRoutesFactory( + `${SERVICE_PATH}/default`, + SERVICE_KEY +); + +export const registerManageDefaultDataViewRouteLegacy = manageDefaultIndexPatternRoutesFactory( + `${SERVICE_PATH_LEGACY}/default`, + SERVICE_KEY_LEGACY +); diff --git a/src/plugins/data_views/server/routes/delete_index_pattern.ts b/src/plugins/data_views/server/routes/delete_index_pattern.ts index 151fb0b0224b6..077795a6b7c36 100644 --- a/src/plugins/data_views/server/routes/delete_index_pattern.ts +++ b/src/plugins/data_views/server/routes/delete_index_pattern.ts @@ -9,50 +9,59 @@ import { schema } from '@kbn/config-schema'; import { handleErrors } from './util/handle_errors'; import { IRouter, StartServicesAccessor } from '../../../../core/server'; -import type { DataViewsServerPluginStart, DataViewsServerPluginStartDependencies } from '../types'; +import type { DataViewsServerPluginStartDependencies, DataViewsServerPluginStart } from '../types'; +import { SPECIFIC_DATA_VIEW_PATH, SPECIFIC_DATA_VIEW_PATH_LEGACY } from '../constants'; -export const registerDeleteIndexPatternRoute = ( - router: IRouter, - getStartServices: StartServicesAccessor< - DataViewsServerPluginStartDependencies, - DataViewsServerPluginStart - > -) => { - router.delete( - { - path: '/api/index_patterns/index_pattern/{id}', - validate: { - params: schema.object( - { - id: schema.string({ - minLength: 1, - maxLength: 1_000, - }), - }, - { unknowns: 'allow' } - ), +const deleteIndexPatternRouteFactory = + (path: string) => + ( + router: IRouter, + getStartServices: StartServicesAccessor< + DataViewsServerPluginStartDependencies, + DataViewsServerPluginStart + > + ) => { + router.delete( + { + path, + validate: { + params: schema.object( + { + id: schema.string({ + minLength: 1, + maxLength: 1_000, + }), + }, + { unknowns: 'allow' } + ), + }, }, - }, - router.handleLegacyErrors( - handleErrors(async (ctx, req, res) => { - const savedObjectsClient = ctx.core.savedObjects.client; - const elasticsearchClient = ctx.core.elasticsearch.client.asCurrentUser; - const [, , { indexPatternsServiceFactory }] = await getStartServices(); - const indexPatternsService = await indexPatternsServiceFactory( - savedObjectsClient, - elasticsearchClient, - req - ); - const id = req.params.id; + router.handleLegacyErrors( + handleErrors(async (ctx, req, res) => { + const savedObjectsClient = ctx.core.savedObjects.client; + const elasticsearchClient = ctx.core.elasticsearch.client.asCurrentUser; + const [, , { dataViewsServiceFactory }] = await getStartServices(); + const indexPatternsService = await dataViewsServiceFactory( + savedObjectsClient, + elasticsearchClient, + req + ); + const id = req.params.id; - await indexPatternsService.delete(id); + await indexPatternsService.delete(id); - return res.ok({ - headers: { - 'content-type': 'application/json', - }, - }); - }) - ) - ); -}; + return res.ok({ + headers: { + 'content-type': 'application/json', + }, + }); + }) + ) + ); + }; + +export const registerDeleteDataViewRoute = deleteIndexPatternRouteFactory(SPECIFIC_DATA_VIEW_PATH); + +export const registerDeleteDataViewRouteLegacy = deleteIndexPatternRouteFactory( + SPECIFIC_DATA_VIEW_PATH_LEGACY +); diff --git a/src/plugins/data_views/server/routes/fields/update_fields.ts b/src/plugins/data_views/server/routes/fields/update_fields.ts index 258ae9ebec3af..7b0e83dbf2e9b 100644 --- a/src/plugins/data_views/server/routes/fields/update_fields.ts +++ b/src/plugins/data_views/server/routes/fields/update_fields.ts @@ -11,111 +11,129 @@ import { handleErrors } from '../util/handle_errors'; import { serializedFieldFormatSchema } from '../util/schemas'; import { IRouter, StartServicesAccessor } from '../../../../../core/server'; import type { - DataViewsServerPluginStart, DataViewsServerPluginStartDependencies, + DataViewsServerPluginStart, } from '../../types'; +import { + SPECIFIC_DATA_VIEW_PATH, + SPECIFIC_DATA_VIEW_PATH_LEGACY, + SERVICE_KEY, + SERVICE_KEY_LEGACY, +} from '../../constants'; -export const registerUpdateFieldsRoute = ( - router: IRouter, - getStartServices: StartServicesAccessor< - DataViewsServerPluginStartDependencies, - DataViewsServerPluginStart - > -) => { - router.post( - { - path: '/api/index_patterns/index_pattern/{id}/fields', - validate: { - params: schema.object( - { - id: schema.string({ - minLength: 1, - maxLength: 1_000, - }), - }, - { unknowns: 'allow' } - ), - body: schema.object({ - fields: schema.recordOf( - schema.string({ - minLength: 1, - maxLength: 1_000, - }), - schema.object({ - customLabel: schema.maybe( - schema.nullable( - schema.string({ - minLength: 1, - maxLength: 1_000, - }) - ) - ), - count: schema.maybe(schema.nullable(schema.number())), - format: schema.maybe(schema.nullable(serializedFieldFormatSchema)), - }) +const updateFieldsActionRouteFactory = (path: string, serviceKey: string) => { + return ( + router: IRouter, + getStartServices: StartServicesAccessor< + DataViewsServerPluginStartDependencies, + DataViewsServerPluginStart + > + ) => { + router.post( + { + path, + validate: { + params: schema.object( + { + id: schema.string({ + minLength: 1, + maxLength: 1_000, + }), + }, + { unknowns: 'allow' } ), - }), + body: schema.object({ + fields: schema.recordOf( + schema.string({ + minLength: 1, + maxLength: 1_000, + }), + schema.object({ + customLabel: schema.maybe( + schema.nullable( + schema.string({ + minLength: 1, + maxLength: 1_000, + }) + ) + ), + count: schema.maybe(schema.nullable(schema.number())), + format: schema.maybe(schema.nullable(serializedFieldFormatSchema)), + }) + ), + }), + }, }, - }, - router.handleLegacyErrors( - handleErrors(async (ctx, req, res) => { - const savedObjectsClient = ctx.core.savedObjects.client; - const elasticsearchClient = ctx.core.elasticsearch.client.asCurrentUser; - const [, , { indexPatternsServiceFactory }] = await getStartServices(); - const indexPatternsService = await indexPatternsServiceFactory( - savedObjectsClient, - elasticsearchClient, - req - ); - const id = req.params.id; - const { fields } = req.body; - const fieldNames = Object.keys(fields); + router.handleLegacyErrors( + handleErrors(async (ctx, req, res) => { + const savedObjectsClient = ctx.core.savedObjects.client; + const elasticsearchClient = ctx.core.elasticsearch.client.asCurrentUser; + const [, , { dataViewsServiceFactory }] = await getStartServices(); + const indexPatternsService = await dataViewsServiceFactory( + savedObjectsClient, + elasticsearchClient, + req + ); + const id = req.params.id; + const { fields } = req.body; + const fieldNames = Object.keys(fields); - if (fieldNames.length < 1) { - throw new Error('No fields provided.'); - } + if (fieldNames.length < 1) { + throw new Error('No fields provided.'); + } - const indexPattern = await indexPatternsService.get(id); + const indexPattern = await indexPatternsService.get(id); - let changeCount = 0; - for (const fieldName of fieldNames) { - const field = fields[fieldName]; + let changeCount = 0; + for (const fieldName of fieldNames) { + const field = fields[fieldName]; - if (field.customLabel !== undefined) { - changeCount++; - indexPattern.setFieldCustomLabel(fieldName, field.customLabel); - } + if (field.customLabel !== undefined) { + changeCount++; + indexPattern.setFieldCustomLabel(fieldName, field.customLabel); + } - if (field.count !== undefined) { - changeCount++; - indexPattern.setFieldCount(fieldName, field.count); - } + if (field.count !== undefined) { + changeCount++; + indexPattern.setFieldCount(fieldName, field.count); + } - if (field.format !== undefined) { - changeCount++; - if (field.format) { - indexPattern.setFieldFormat(fieldName, field.format); - } else { - indexPattern.deleteFieldFormat(fieldName); + if (field.format !== undefined) { + changeCount++; + if (field.format) { + indexPattern.setFieldFormat(fieldName, field.format); + } else { + indexPattern.deleteFieldFormat(fieldName); + } } } - } - if (changeCount < 1) { - throw new Error('Change set is empty.'); - } + if (changeCount < 1) { + throw new Error('Change set is empty.'); + } - await indexPatternsService.updateSavedObject(indexPattern); + await indexPatternsService.updateSavedObject(indexPattern); - return res.ok({ - headers: { - 'content-type': 'application/json', - }, - body: JSON.stringify({ - index_pattern: indexPattern.toSpec(), - }), - }); - }) - ) - ); + return res.ok({ + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ + [serviceKey]: indexPattern.toSpec(), + }), + }); + }) + ) + ); + }; }; + +export const registerUpdateFieldsRouteLegacy = updateFieldsActionRouteFactory( + `${SPECIFIC_DATA_VIEW_PATH}/fields`, + SERVICE_KEY +); + +export const registerUpdateFieldsRoute = updateFieldsActionRouteFactory( + `${SPECIFIC_DATA_VIEW_PATH_LEGACY}/fields`, + SERVICE_KEY_LEGACY +); diff --git a/src/plugins/data_views/server/routes/get_index_pattern.ts b/src/plugins/data_views/server/routes/get_index_pattern.ts index b7d95fe687a0a..40e67e972f5d7 100644 --- a/src/plugins/data_views/server/routes/get_index_pattern.ts +++ b/src/plugins/data_views/server/routes/get_index_pattern.ts @@ -9,52 +9,70 @@ import { schema } from '@kbn/config-schema'; import { handleErrors } from './util/handle_errors'; import { IRouter, StartServicesAccessor } from '../../../../core/server'; -import type { DataViewsServerPluginStart, DataViewsServerPluginStartDependencies } from '../types'; +import type { DataViewsServerPluginStartDependencies, DataViewsServerPluginStart } from '../types'; +import { + SPECIFIC_DATA_VIEW_PATH, + SPECIFIC_DATA_VIEW_PATH_LEGACY, + SERVICE_KEY, + SERVICE_KEY_LEGACY, +} from '../constants'; -export const registerGetIndexPatternRoute = ( - router: IRouter, - getStartServices: StartServicesAccessor< - DataViewsServerPluginStartDependencies, - DataViewsServerPluginStart - > -) => { - router.get( - { - path: '/api/index_patterns/index_pattern/{id}', - validate: { - params: schema.object( - { - id: schema.string({ - minLength: 1, - maxLength: 1_000, - }), - }, - { unknowns: 'allow' } - ), +const getDataViewRouteFactory = + (path: string, serviceKey: string) => + ( + router: IRouter, + getStartServices: StartServicesAccessor< + DataViewsServerPluginStartDependencies, + DataViewsServerPluginStart + > + ) => { + router.get( + { + path, + validate: { + params: schema.object( + { + id: schema.string({ + minLength: 1, + maxLength: 1_000, + }), + }, + { unknowns: 'allow' } + ), + }, }, - }, - router.handleLegacyErrors( - handleErrors(async (ctx, req, res) => { - const savedObjectsClient = ctx.core.savedObjects.client; - const elasticsearchClient = ctx.core.elasticsearch.client.asCurrentUser; - const [, , { indexPatternsServiceFactory }] = await getStartServices(); - const indexPatternsService = await indexPatternsServiceFactory( - savedObjectsClient, - elasticsearchClient, - req - ); - const id = req.params.id; - const indexPattern = await indexPatternsService.get(id); + router.handleLegacyErrors( + handleErrors(async (ctx, req, res) => { + const savedObjectsClient = ctx.core.savedObjects.client; + const elasticsearchClient = ctx.core.elasticsearch.client.asCurrentUser; + const [, , { dataViewsServiceFactory }] = await getStartServices(); + const indexPatternsService = await dataViewsServiceFactory( + savedObjectsClient, + elasticsearchClient, + req + ); + const id = req.params.id; + const indexPattern = await indexPatternsService.get(id); + + return res.ok({ + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ + [serviceKey]: indexPattern.toSpec(), + }), + }); + }) + ) + ); + }; + +export const registerGetDataViewRoute = getDataViewRouteFactory( + SPECIFIC_DATA_VIEW_PATH, + SERVICE_KEY +); - return res.ok({ - headers: { - 'content-type': 'application/json', - }, - body: JSON.stringify({ - index_pattern: indexPattern.toSpec(), - }), - }); - }) - ) - ); -}; +export const registerGetDataViewRouteLegacy = getDataViewRouteFactory( + SPECIFIC_DATA_VIEW_PATH_LEGACY, + SERVICE_KEY_LEGACY +); diff --git a/src/plugins/data_views/server/routes/has_user_index_pattern.ts b/src/plugins/data_views/server/routes/has_user_index_pattern.ts index 6562d06df6f65..883a7ff3111ae 100644 --- a/src/plugins/data_views/server/routes/has_user_index_pattern.ts +++ b/src/plugins/data_views/server/routes/has_user_index_pattern.ts @@ -8,37 +8,48 @@ import { handleErrors } from './util/handle_errors'; import { IRouter, StartServicesAccessor } from '../../../../core/server'; -import type { DataViewsServerPluginStart, DataViewsServerPluginStartDependencies } from '../types'; +import type { DataViewsServerPluginStartDependencies, DataViewsServerPluginStart } from '../types'; +import { SERVICE_PATH, SERVICE_PATH_LEGACY } from '../constants'; -export const registerHasUserIndexPatternRoute = ( - router: IRouter, - getStartServices: StartServicesAccessor< - DataViewsServerPluginStartDependencies, - DataViewsServerPluginStart - > -) => { - router.get( - { - path: '/api/index_patterns/has_user_index_pattern', - validate: {}, - }, - router.handleLegacyErrors( - handleErrors(async (ctx, req, res) => { - const savedObjectsClient = ctx.core.savedObjects.client; - const elasticsearchClient = ctx.core.elasticsearch.client.asCurrentUser; - const [, , { indexPatternsServiceFactory }] = await getStartServices(); - const indexPatternsService = await indexPatternsServiceFactory( - savedObjectsClient, - elasticsearchClient, - req - ); +const hasUserDataViewRouteFactory = + (path: string) => + ( + router: IRouter, + getStartServices: StartServicesAccessor< + DataViewsServerPluginStartDependencies, + DataViewsServerPluginStart + > + ) => { + router.get( + { + path, + validate: {}, + }, + router.handleLegacyErrors( + handleErrors(async (ctx, req, res) => { + const savedObjectsClient = ctx.core.savedObjects.client; + const elasticsearchClient = ctx.core.elasticsearch.client.asCurrentUser; + const [, , { dataViewsServiceFactory }] = await getStartServices(); + const indexPatternsService = await dataViewsServiceFactory( + savedObjectsClient, + elasticsearchClient, + req + ); - return res.ok({ - body: { - result: await indexPatternsService.hasUserDataView(), - }, - }); - }) - ) - ); -}; + return res.ok({ + body: { + result: await indexPatternsService.hasUserDataView(), + }, + }); + }) + ) + ); + }; + +export const registerHasUserDataViewRoute = hasUserDataViewRouteFactory( + `${SERVICE_PATH}/has_user_data_view` +); + +export const registerHasUserDataViewRouteLegacy = hasUserDataViewRouteFactory( + `${SERVICE_PATH_LEGACY}/has_user_index_pattern` +); diff --git a/src/plugins/data_views/server/routes/runtime_fields/create_runtime_field.ts b/src/plugins/data_views/server/routes/runtime_fields/create_runtime_field.ts index 434d57f1aeecb..41890dd1c5f31 100644 --- a/src/plugins/data_views/server/routes/runtime_fields/create_runtime_field.ts +++ b/src/plugins/data_views/server/routes/runtime_fields/create_runtime_field.ts @@ -14,68 +14,95 @@ import type { DataViewsServerPluginStart, DataViewsServerPluginStartDependencies, } from '../../types'; +import { + RUNTIME_FIELD_PATH, + RUNTIME_FIELD_PATH_LEGACY, + SERVICE_KEY, + SERVICE_KEY_LEGACY, + SERVICE_KEY_TYPE, +} from '../../constants'; -export const registerCreateRuntimeFieldRoute = ( - router: IRouter, - getStartServices: StartServicesAccessor< - DataViewsServerPluginStartDependencies, - DataViewsServerPluginStart - > -) => { - router.post( - { - path: '/api/index_patterns/index_pattern/{id}/runtime_field', - validate: { - params: schema.object({ - id: schema.string({ - minLength: 1, - maxLength: 1_000, +const runtimeCreateFieldRouteFactory = + (path: string, serviceKey: SERVICE_KEY_TYPE) => + ( + router: IRouter, + getStartServices: StartServicesAccessor< + DataViewsServerPluginStartDependencies, + DataViewsServerPluginStart + > + ) => { + router.post( + { + path, + validate: { + params: schema.object({ + id: schema.string({ + minLength: 1, + maxLength: 1_000, + }), }), - }), - body: schema.object({ - name: schema.string({ - minLength: 1, - maxLength: 1_000, + body: schema.object({ + name: schema.string({ + minLength: 1, + maxLength: 1_000, + }), + runtimeField: runtimeFieldSpecSchema, }), - runtimeField: runtimeFieldSpecSchema, - }), + }, }, - }, + handleErrors(async (ctx, req, res) => { + const savedObjectsClient = ctx.core.savedObjects.client; + const elasticsearchClient = ctx.core.elasticsearch.client.asCurrentUser; + const [, , { indexPatternsServiceFactory }] = await getStartServices(); + const indexPatternsService = await indexPatternsServiceFactory( + savedObjectsClient, + elasticsearchClient, + req + ); + const id = req.params.id; + const { name, runtimeField } = req.body; - handleErrors(async (ctx, req, res) => { - const savedObjectsClient = ctx.core.savedObjects.client; - const elasticsearchClient = ctx.core.elasticsearch.client.asCurrentUser; - const [, , { indexPatternsServiceFactory }] = await getStartServices(); - const indexPatternsService = await indexPatternsServiceFactory( - savedObjectsClient, - elasticsearchClient, - req - ); - const id = req.params.id; - const { name, runtimeField } = req.body; + const indexPattern = await indexPatternsService.get(id); - const indexPattern = await indexPatternsService.get(id); + if (indexPattern.fields.getByName(name)) { + throw new Error(`Field [name = ${name}] already exists.`); + } - if (indexPattern.fields.getByName(name)) { - throw new Error(`Field [name = ${name}] already exists.`); - } + indexPattern.addRuntimeField(name, runtimeField); - indexPattern.addRuntimeField(name, runtimeField); + const addedField = indexPattern.fields.getByName(name); + if (!addedField) throw new Error(`Could not create a field [name = ${name}].`); - const addedField = indexPattern.fields.getByName(name); - if (!addedField) throw new Error(`Could not create a field [name = ${name}].`); + await indexPatternsService.updateSavedObject(indexPattern); - await indexPatternsService.updateSavedObject(indexPattern); + const savedField = indexPattern.fields.getByName(name); + if (!savedField) throw new Error(`Could not create a field [name = ${name}].`); - const savedField = indexPattern.fields.getByName(name); - if (!savedField) throw new Error(`Could not create a field [name = ${name}].`); + const response = { + body: { + fields: [savedField.toSpec()], + [serviceKey]: indexPattern.toSpec(), + }, + }; - return res.ok({ - body: { - field: savedField.toSpec(), - index_pattern: indexPattern.toSpec(), - }, - }); - }) - ); -}; + const legacyResponse = { + body: { + [serviceKey]: indexPattern.toSpec(), + field: savedField.toSpec(), + }, + }; + + return res.ok(serviceKey === SERVICE_KEY_LEGACY ? legacyResponse : response); + }) + ); + }; + +export const registerCreateRuntimeFieldRoute = runtimeCreateFieldRouteFactory( + RUNTIME_FIELD_PATH, + SERVICE_KEY +); + +export const registerCreateRuntimeFieldRouteLegacy = runtimeCreateFieldRouteFactory( + RUNTIME_FIELD_PATH_LEGACY, + SERVICE_KEY_LEGACY +); diff --git a/src/plugins/data_views/server/routes/runtime_fields/delete_runtime_field.ts b/src/plugins/data_views/server/routes/runtime_fields/delete_runtime_field.ts index d15365647f2a0..4c68a91e8f743 100644 --- a/src/plugins/data_views/server/routes/runtime_fields/delete_runtime_field.ts +++ b/src/plugins/data_views/server/routes/runtime_fields/delete_runtime_field.ts @@ -14,58 +14,69 @@ import type { DataViewsServerPluginStart, DataViewsServerPluginStartDependencies, } from '../../types'; +import { SPECIFIC_RUNTIME_FIELD_PATH, SPECIFIC_RUNTIME_FIELD_PATH_LEGACY } from '../../constants'; -export const registerDeleteRuntimeFieldRoute = ( - router: IRouter, - getStartServices: StartServicesAccessor< - DataViewsServerPluginStartDependencies, - DataViewsServerPluginStart - > -) => { - router.delete( - { - path: '/api/index_patterns/index_pattern/{id}/runtime_field/{name}', - validate: { - params: schema.object({ - id: schema.string({ - minLength: 1, - maxLength: 1_000, +const deleteRuntimeFieldRouteFactory = + (path: string) => + ( + router: IRouter, + getStartServices: StartServicesAccessor< + DataViewsServerPluginStartDependencies, + DataViewsServerPluginStart + > + ) => { + router.delete( + { + path, + validate: { + params: schema.object({ + id: schema.string({ + minLength: 1, + maxLength: 1_000, + }), + name: schema.string({ + minLength: 1, + maxLength: 1_000, + }), }), - name: schema.string({ - minLength: 1, - maxLength: 1_000, - }), - }), + }, }, - }, - handleErrors(async (ctx, req, res) => { - const savedObjectsClient = ctx.core.savedObjects.client; - const elasticsearchClient = ctx.core.elasticsearch.client.asCurrentUser; - const [, , { indexPatternsServiceFactory }] = await getStartServices(); - const indexPatternsService = await indexPatternsServiceFactory( - savedObjectsClient, - elasticsearchClient, - req - ); - const id = req.params.id; - const name = req.params.name; + handleErrors(async (ctx, req, res) => { + const savedObjectsClient = ctx.core.savedObjects.client; + const elasticsearchClient = ctx.core.elasticsearch.client.asCurrentUser; + const [, , { indexPatternsServiceFactory }] = await getStartServices(); + const indexPatternsService = await indexPatternsServiceFactory( + savedObjectsClient, + elasticsearchClient, + req + ); + const id = req.params.id; + const name = req.params.name; + + const indexPattern = await indexPatternsService.get(id); + const field = indexPattern.fields.getByName(name); + + if (!field) { + throw new ErrorIndexPatternFieldNotFound(id, name); + } - const indexPattern = await indexPatternsService.get(id); - const field = indexPattern.fields.getByName(name); + if (!field.runtimeField) { + throw new Error('Only runtime fields can be deleted.'); + } - if (!field) { - throw new ErrorIndexPatternFieldNotFound(id, name); - } + indexPattern.removeRuntimeField(name); - if (!field.runtimeField) { - throw new Error('Only runtime fields can be deleted.'); - } + await indexPatternsService.updateSavedObject(indexPattern); - indexPattern.removeRuntimeField(name); + return res.ok(); + }) + ); + }; - await indexPatternsService.updateSavedObject(indexPattern); +export const registerDeleteRuntimeFieldRoute = deleteRuntimeFieldRouteFactory( + SPECIFIC_RUNTIME_FIELD_PATH +); - return res.ok(); - }) - ); -}; +export const registerDeleteRuntimeFieldRouteLegacy = deleteRuntimeFieldRouteFactory( + SPECIFIC_RUNTIME_FIELD_PATH_LEGACY +); diff --git a/src/plugins/data_views/server/routes/runtime_fields/get_runtime_field.ts b/src/plugins/data_views/server/routes/runtime_fields/get_runtime_field.ts index a6f45b81af149..e79aa9cb76fbe 100644 --- a/src/plugins/data_views/server/routes/runtime_fields/get_runtime_field.ts +++ b/src/plugins/data_views/server/routes/runtime_fields/get_runtime_field.ts @@ -14,61 +14,89 @@ import type { DataViewsServerPluginStart, DataViewsServerPluginStartDependencies, } from '../../types'; +import { + SPECIFIC_RUNTIME_FIELD_PATH, + SPECIFIC_RUNTIME_FIELD_PATH_LEGACY, + SERVICE_KEY, + SERVICE_KEY_LEGACY, + SERVICE_KEY_TYPE, +} from '../../constants'; -export const registerGetRuntimeFieldRoute = ( - router: IRouter, - getStartServices: StartServicesAccessor< - DataViewsServerPluginStartDependencies, - DataViewsServerPluginStart - > -) => { - router.get( - { - path: '/api/index_patterns/index_pattern/{id}/runtime_field/{name}', - validate: { - params: schema.object({ - id: schema.string({ - minLength: 1, - maxLength: 1_000, +const getRuntimeFieldRouteFactory = + (path: string, serviceKey: SERVICE_KEY_TYPE) => + ( + router: IRouter, + getStartServices: StartServicesAccessor< + DataViewsServerPluginStartDependencies, + DataViewsServerPluginStart + > + ) => { + router.get( + { + path, + validate: { + params: schema.object({ + id: schema.string({ + minLength: 1, + maxLength: 1_000, + }), + name: schema.string({ + minLength: 1, + maxLength: 1_000, + }), }), - name: schema.string({ - minLength: 1, - maxLength: 1_000, - }), - }), + }, }, - }, - handleErrors(async (ctx, req, res) => { - const savedObjectsClient = ctx.core.savedObjects.client; - const elasticsearchClient = ctx.core.elasticsearch.client.asCurrentUser; - const [, , { indexPatternsServiceFactory }] = await getStartServices(); - const indexPatternsService = await indexPatternsServiceFactory( - savedObjectsClient, - elasticsearchClient, - req - ); - const id = req.params.id; - const name = req.params.name; + handleErrors(async (ctx, req, res) => { + const savedObjectsClient = ctx.core.savedObjects.client; + const elasticsearchClient = ctx.core.elasticsearch.client.asCurrentUser; + const [, , { indexPatternsServiceFactory }] = await getStartServices(); + const indexPatternsService = await indexPatternsServiceFactory( + savedObjectsClient, + elasticsearchClient, + req + ); + const id = req.params.id; + const name = req.params.name; - const indexPattern = await indexPatternsService.get(id); + const indexPattern = await indexPatternsService.get(id); - const field = indexPattern.fields.getByName(name); + const field = indexPattern.fields.getByName(name); - if (!field) { - throw new ErrorIndexPatternFieldNotFound(id, name); - } + if (!field) { + throw new ErrorIndexPatternFieldNotFound(id, name); + } - if (!field.runtimeField) { - throw new Error('Only runtime fields can be retrieved.'); - } + if (!field.runtimeField) { + throw new Error('Only runtime fields can be retrieved.'); + } - return res.ok({ - body: { - field: field.toSpec(), - runtimeField: indexPattern.getRuntimeField(name), - }, - }); - }) - ); -}; + const legacyResponse = { + body: { + field: field.toSpec(), + runtimeField: indexPattern.getRuntimeField(name), + }, + }; + + const response = { + body: { + fields: [field.toSpec()], + runtimeField: indexPattern.getRuntimeField(name), + }, + }; + + return res.ok(serviceKey === SERVICE_KEY_LEGACY ? legacyResponse : response); + }) + ); + }; + +export const registerGetRuntimeFieldRoute = getRuntimeFieldRouteFactory( + SPECIFIC_RUNTIME_FIELD_PATH, + SERVICE_KEY +); + +export const registerGetRuntimeFieldRouteLegacy = getRuntimeFieldRouteFactory( + SPECIFIC_RUNTIME_FIELD_PATH_LEGACY, + SERVICE_KEY_LEGACY +); diff --git a/src/plugins/data_views/server/routes/runtime_fields/put_runtime_field.ts b/src/plugins/data_views/server/routes/runtime_fields/put_runtime_field.ts index 7cea9864f17dd..7acd212f4f09c 100644 --- a/src/plugins/data_views/server/routes/runtime_fields/put_runtime_field.ts +++ b/src/plugins/data_views/server/routes/runtime_fields/put_runtime_field.ts @@ -14,70 +14,97 @@ import type { DataViewsServerPluginStart, DataViewsServerPluginStartDependencies, } from '../../types'; +import { + RUNTIME_FIELD_PATH, + RUNTIME_FIELD_PATH_LEGACY, + SERVICE_KEY, + SERVICE_KEY_LEGACY, +} from '../../constants'; -export const registerPutRuntimeFieldRoute = ( - router: IRouter, - getStartServices: StartServicesAccessor< - DataViewsServerPluginStartDependencies, - DataViewsServerPluginStart - > -) => { - router.put( - { - path: '/api/index_patterns/index_pattern/{id}/runtime_field', - validate: { - params: schema.object({ - id: schema.string({ - minLength: 1, - maxLength: 1_000, +const putRuntimeFieldRouteFactory = + (path: string, serviceKey: string) => + ( + router: IRouter, + getStartServices: StartServicesAccessor< + DataViewsServerPluginStartDependencies, + DataViewsServerPluginStart + > + ) => { + router.put( + { + path, + validate: { + params: schema.object({ + id: schema.string({ + minLength: 1, + maxLength: 1_000, + }), }), - }), - body: schema.object({ - name: schema.string({ - minLength: 1, - maxLength: 1_000, + body: schema.object({ + name: schema.string({ + minLength: 1, + maxLength: 1_000, + }), + runtimeField: runtimeFieldSpecSchema, }), - runtimeField: runtimeFieldSpecSchema, - }), + }, }, - }, - handleErrors(async (ctx, req, res) => { - const savedObjectsClient = ctx.core.savedObjects.client; - const elasticsearchClient = ctx.core.elasticsearch.client.asCurrentUser; - const [, , { indexPatternsServiceFactory }] = await getStartServices(); - const indexPatternsService = await indexPatternsServiceFactory( - savedObjectsClient, - elasticsearchClient, - req - ); - const id = req.params.id; - const { name, runtimeField } = req.body; + handleErrors(async (ctx, req, res) => { + const savedObjectsClient = ctx.core.savedObjects.client; + const elasticsearchClient = ctx.core.elasticsearch.client.asCurrentUser; + const [, , { indexPatternsServiceFactory }] = await getStartServices(); + const indexPatternsService = await indexPatternsServiceFactory( + savedObjectsClient, + elasticsearchClient, + req + ); + const id = req.params.id; + const { name, runtimeField } = req.body; - const indexPattern = await indexPatternsService.get(id); + const indexPattern = await indexPatternsService.get(id); - const oldFieldObject = indexPattern.fields.getByName(name); + const oldFieldObject = indexPattern.fields.getByName(name); - if (oldFieldObject && !oldFieldObject.runtimeField) { - throw new Error('Only runtime fields can be updated'); - } + if (oldFieldObject && !oldFieldObject.runtimeField) { + throw new Error('Only runtime fields can be updated'); + } - if (oldFieldObject) { - indexPattern.removeRuntimeField(name); - } + if (oldFieldObject) { + indexPattern.removeRuntimeField(name); + } - indexPattern.addRuntimeField(name, runtimeField); + indexPattern.addRuntimeField(name, runtimeField); - await indexPatternsService.updateSavedObject(indexPattern); + await indexPatternsService.updateSavedObject(indexPattern); - const fieldObject = indexPattern.fields.getByName(name); - if (!fieldObject) throw new Error(`Could not create a field [name = ${name}].`); + const fieldObject = indexPattern.fields.getByName(name); + if (!fieldObject) throw new Error(`Could not create a field [name = ${name}].`); - return res.ok({ - body: { - field: fieldObject.toSpec(), - index_pattern: indexPattern.toSpec(), - }, - }); - }) - ); -}; + const legacyResponse = { + body: { + field: fieldObject.toSpec(), + [serviceKey]: indexPattern.toSpec(), + }, + }; + + const response = { + body: { + fields: [fieldObject.toSpec()], + [serviceKey]: indexPattern.toSpec(), + }, + }; + + return res.ok(serviceKey === SERVICE_KEY_LEGACY ? legacyResponse : response); + }) + ); + }; + +export const registerPutRuntimeFieldRoute = putRuntimeFieldRouteFactory( + RUNTIME_FIELD_PATH, + SERVICE_KEY +); + +export const registerPutRuntimeFieldRouteLegacy = putRuntimeFieldRouteFactory( + RUNTIME_FIELD_PATH_LEGACY, + SERVICE_KEY_LEGACY +); diff --git a/src/plugins/data_views/server/routes/runtime_fields/update_runtime_field.ts b/src/plugins/data_views/server/routes/runtime_fields/update_runtime_field.ts index b2c6bf0576b9b..4cca07a59cfbd 100644 --- a/src/plugins/data_views/server/routes/runtime_fields/update_runtime_field.ts +++ b/src/plugins/data_views/server/routes/runtime_fields/update_runtime_field.ts @@ -16,76 +16,104 @@ import type { DataViewsServerPluginStart, DataViewsServerPluginStartDependencies, } from '../../types'; +import { + SPECIFIC_RUNTIME_FIELD_PATH, + SPECIFIC_RUNTIME_FIELD_PATH_LEGACY, + SERVICE_KEY, + SERVICE_KEY_LEGACY, + SERVICE_KEY_TYPE, +} from '../../constants'; -export const registerUpdateRuntimeFieldRoute = ( - router: IRouter, - getStartServices: StartServicesAccessor< - DataViewsServerPluginStartDependencies, - DataViewsServerPluginStart - > -) => { - router.post( - { - path: '/api/index_patterns/index_pattern/{id}/runtime_field/{name}', - validate: { - params: schema.object({ - id: schema.string({ - minLength: 1, - maxLength: 1_000, +const updateRuntimeFieldRouteFactory = + (path: string, serviceKey: SERVICE_KEY_TYPE) => + ( + router: IRouter, + getStartServices: StartServicesAccessor< + DataViewsServerPluginStartDependencies, + DataViewsServerPluginStart + > + ) => { + router.post( + { + path, + validate: { + params: schema.object({ + id: schema.string({ + minLength: 1, + maxLength: 1_000, + }), + name: schema.string({ + minLength: 1, + maxLength: 1_000, + }), }), - name: schema.string({ - minLength: 1, - maxLength: 1_000, + body: schema.object({ + name: schema.never(), + runtimeField: schema.object({ + ...runtimeFieldSpec, + // We need to overwrite the below fields on top of `runtimeFieldSpec`, + // because some fields would be optional + type: schema.maybe(runtimeFieldSpecTypeSchema), + }), }), - }), - body: schema.object({ - name: schema.never(), - runtimeField: schema.object({ - ...runtimeFieldSpec, - // We need to overwrite the below fields on top of `runtimeFieldSpec`, - // because some fields would be optional - type: schema.maybe(runtimeFieldSpecTypeSchema), - }), - }), + }, }, - }, - handleErrors(async (ctx, req, res) => { - const savedObjectsClient = ctx.core.savedObjects.client; - const elasticsearchClient = ctx.core.elasticsearch.client.asCurrentUser; - const [, , { indexPatternsServiceFactory }] = await getStartServices(); - const indexPatternsService = await indexPatternsServiceFactory( - savedObjectsClient, - elasticsearchClient, - req - ); - const id = req.params.id; - const name = req.params.name; - const runtimeField = req.body.runtimeField as Partial; + handleErrors(async (ctx, req, res) => { + const savedObjectsClient = ctx.core.savedObjects.client; + const elasticsearchClient = ctx.core.elasticsearch.client.asCurrentUser; + const [, , { indexPatternsServiceFactory }] = await getStartServices(); + const indexPatternsService = await indexPatternsServiceFactory( + savedObjectsClient, + elasticsearchClient, + req + ); + const id = req.params.id; + const name = req.params.name; + const runtimeField = req.body.runtimeField as Partial; - const indexPattern = await indexPatternsService.get(id); - const existingRuntimeField = indexPattern.getRuntimeField(name); + const indexPattern = await indexPatternsService.get(id); + const existingRuntimeField = indexPattern.getRuntimeField(name); - if (!existingRuntimeField) { - throw new ErrorIndexPatternFieldNotFound(id, name); - } + if (!existingRuntimeField) { + throw new ErrorIndexPatternFieldNotFound(id, name); + } - indexPattern.removeRuntimeField(name); - indexPattern.addRuntimeField(name, { - ...existingRuntimeField, - ...runtimeField, - }); + indexPattern.removeRuntimeField(name); + indexPattern.addRuntimeField(name, { + ...existingRuntimeField, + ...runtimeField, + }); - await indexPatternsService.updateSavedObject(indexPattern); + await indexPatternsService.updateSavedObject(indexPattern); - const fieldObject = indexPattern.fields.getByName(name); - if (!fieldObject) throw new Error(`Could not create a field [name = ${name}].`); + const fieldObject = indexPattern.fields.getByName(name); + if (!fieldObject) throw new Error(`Could not create a field [name = ${name}].`); - return res.ok({ - body: { - field: fieldObject.toSpec(), - index_pattern: indexPattern.toSpec(), - }, - }); - }) - ); -}; + const legacyResponse = { + body: { + field: fieldObject.toSpec(), + [serviceKey]: indexPattern.toSpec(), + }, + }; + + const response = { + body: { + fields: [fieldObject.toSpec()], + [serviceKey]: indexPattern.toSpec(), + }, + }; + + return res.ok(serviceKey === SERVICE_KEY_LEGACY ? legacyResponse : response); + }) + ); + }; + +export const registerUpdateRuntimeFieldRoute = updateRuntimeFieldRouteFactory( + SPECIFIC_RUNTIME_FIELD_PATH, + SERVICE_KEY +); + +export const registerUpdateRuntimeFieldRouteLegacy = updateRuntimeFieldRouteFactory( + SPECIFIC_RUNTIME_FIELD_PATH_LEGACY, + SERVICE_KEY_LEGACY +); diff --git a/src/plugins/data_views/server/routes/update_index_pattern.ts b/src/plugins/data_views/server/routes/update_index_pattern.ts index 25f45456b9d13..a9da81df7f911 100644 --- a/src/plugins/data_views/server/routes/update_index_pattern.ts +++ b/src/plugins/data_views/server/routes/update_index_pattern.ts @@ -7,6 +7,7 @@ */ import { schema } from '@kbn/config-schema'; +import { DataViewSpec } from 'src/plugins/data_views/common'; import { handleErrors } from './util/handle_errors'; import { fieldSpecSchema, @@ -14,7 +15,13 @@ import { serializedFieldFormatSchema, } from './util/schemas'; import { IRouter, StartServicesAccessor } from '../../../../core/server'; -import type { DataViewsServerPluginStart, DataViewsServerPluginStartDependencies } from '../types'; +import type { DataViewsServerPluginStartDependencies, DataViewsServerPluginStart } from '../types'; +import { + SPECIFIC_DATA_VIEW_PATH, + SPECIFIC_DATA_VIEW_PATH_LEGACY, + SERVICE_KEY, + SERVICE_KEY_LEGACY, +} from '../constants'; const indexPatternUpdateSchema = schema.object({ title: schema.maybe(schema.string()), @@ -34,50 +41,55 @@ const indexPatternUpdateSchema = schema.object({ runtimeFieldMap: schema.maybe(schema.recordOf(schema.string(), runtimeFieldSpecSchema)), }); -export const registerUpdateIndexPatternRoute = ( - router: IRouter, - getStartServices: StartServicesAccessor< - DataViewsServerPluginStartDependencies, - DataViewsServerPluginStart - > -) => { - router.post( - { - path: '/api/index_patterns/index_pattern/{id}', - validate: { - params: schema.object( - { - id: schema.string({ - minLength: 1, - maxLength: 1_000, - }), - }, - { unknowns: 'allow' } - ), - body: schema.object({ - refresh_fields: schema.maybe(schema.boolean({ defaultValue: false })), - index_pattern: indexPatternUpdateSchema, - }), +const updateDataViewRouteFactory = + (path: string, serviceKey: string) => + ( + router: IRouter, + getStartServices: StartServicesAccessor< + DataViewsServerPluginStartDependencies, + DataViewsServerPluginStart + > + ) => { + router.post( + { + path, + validate: { + params: schema.object( + { + id: schema.string({ + minLength: 1, + maxLength: 1_000, + }), + }, + { unknowns: 'allow' } + ), + body: schema.object({ + refresh_fields: schema.maybe(schema.boolean({ defaultValue: false })), + [serviceKey]: indexPatternUpdateSchema, + }), + }, }, - }, - router.handleLegacyErrors( - handleErrors(async (ctx, req, res) => { - const savedObjectsClient = ctx.core.savedObjects.client; - const elasticsearchClient = ctx.core.elasticsearch.client.asCurrentUser; - const [, , { indexPatternsServiceFactory }] = await getStartServices(); - const indexPatternsService = await indexPatternsServiceFactory( - savedObjectsClient, - elasticsearchClient, - req - ); - const id = req.params.id; - - const indexPattern = await indexPatternsService.get(id); - - const { - // eslint-disable-next-line @typescript-eslint/naming-convention - refresh_fields = true, - index_pattern: { + router.handleLegacyErrors( + handleErrors(async (ctx, req, res) => { + const savedObjectsClient = ctx.core.savedObjects.client; + const elasticsearchClient = ctx.core.elasticsearch.client.asCurrentUser; + const [, , { dataViewsServiceFactory }] = await getStartServices(); + const indexPatternsService = await dataViewsServiceFactory( + savedObjectsClient, + elasticsearchClient, + req + ); + const id = req.params.id; + + const indexPattern = await indexPatternsService.get(id); + + const { + // eslint-disable-next-line @typescript-eslint/naming-convention + refresh_fields = true, + } = req.body; + const indexPatternSpec = req.body[serviceKey] as DataViewSpec; + + const { title, timeFieldName, sourceFilters, @@ -86,78 +98,87 @@ export const registerUpdateIndexPatternRoute = ( typeMeta, fields, runtimeFieldMap, - }, - } = req.body; - - let changeCount = 0; - let doRefreshFields = false; - - if (title !== undefined && title !== indexPattern.title) { - changeCount++; - indexPattern.title = title; - } - - if (timeFieldName !== undefined && timeFieldName !== indexPattern.timeFieldName) { - changeCount++; - indexPattern.timeFieldName = timeFieldName; - } - - if (sourceFilters !== undefined) { - changeCount++; - indexPattern.sourceFilters = sourceFilters; - } - - if (fieldFormats !== undefined) { - changeCount++; - indexPattern.fieldFormatMap = fieldFormats; - } - - if (type !== undefined) { - changeCount++; - indexPattern.type = type; - } - - if (typeMeta !== undefined) { - changeCount++; - indexPattern.typeMeta = typeMeta; - } - - if (fields !== undefined) { - changeCount++; - doRefreshFields = true; - indexPattern.fields.replaceAll( - Object.values(fields || {}).map((field) => ({ - ...field, - aggregatable: true, - searchable: true, - })) - ); - } - - if (runtimeFieldMap !== undefined) { - changeCount++; - indexPattern.replaceAllRuntimeFields(runtimeFieldMap); - } - - if (changeCount < 1) { - throw new Error('Index pattern change set is empty.'); - } - - await indexPatternsService.updateSavedObject(indexPattern); - - if (doRefreshFields && refresh_fields) { - await indexPatternsService.refreshFields(indexPattern); - } - - return res.ok({ - headers: { - 'content-type': 'application/json', - }, - body: JSON.stringify({ - index_pattern: indexPattern.toSpec(), - }), - }); - }) - ) - ); -}; + } = indexPatternSpec; + + let changeCount = 0; + let doRefreshFields = false; + + if (title !== undefined && title !== indexPattern.title) { + changeCount++; + indexPattern.title = title; + } + + if (timeFieldName !== undefined && timeFieldName !== indexPattern.timeFieldName) { + changeCount++; + indexPattern.timeFieldName = timeFieldName; + } + + if (sourceFilters !== undefined) { + changeCount++; + indexPattern.sourceFilters = sourceFilters; + } + + if (fieldFormats !== undefined) { + changeCount++; + indexPattern.fieldFormatMap = fieldFormats; + } + + if (type !== undefined) { + changeCount++; + indexPattern.type = type; + } + + if (typeMeta !== undefined) { + changeCount++; + indexPattern.typeMeta = typeMeta; + } + + if (fields !== undefined) { + changeCount++; + doRefreshFields = true; + indexPattern.fields.replaceAll( + Object.values(fields || {}).map((field) => ({ + ...field, + aggregatable: true, + searchable: true, + })) + ); + } + + if (runtimeFieldMap !== undefined) { + changeCount++; + indexPattern.replaceAllRuntimeFields(runtimeFieldMap); + } + + if (changeCount < 1) { + throw new Error('Index pattern change set is empty.'); + } + + await indexPatternsService.updateSavedObject(indexPattern); + + if (doRefreshFields && refresh_fields) { + await indexPatternsService.refreshFields(indexPattern); + } + + return res.ok({ + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ + [serviceKey]: indexPattern.toSpec(), + }), + }); + }) + ) + ); + }; + +export const registerUpdateDataViewRoute = updateDataViewRouteFactory( + SPECIFIC_DATA_VIEW_PATH, + SERVICE_KEY +); + +export const registerUpdateDataViewRouteLegacy = updateDataViewRouteFactory( + SPECIFIC_DATA_VIEW_PATH_LEGACY, + SERVICE_KEY_LEGACY +); diff --git a/test/api_integration/apis/index_patterns/constants.ts b/test/api_integration/apis/index_patterns/constants.ts new file mode 100644 index 0000000000000..3de4f80dd657d --- /dev/null +++ b/test/api_integration/apis/index_patterns/constants.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { + DATA_VIEW_PATH_LEGACY, + SERVICE_KEY_LEGACY, + DATA_VIEW_PATH, + SERVICE_KEY, + SERVICE_PATH, + SERVICE_PATH_LEGACY, +} from '../../../../src/plugins/data_views/server'; + +const legacyConfig = { + name: 'legacy index pattern api', + path: DATA_VIEW_PATH_LEGACY, + basePath: SERVICE_PATH_LEGACY, + serviceKey: SERVICE_KEY_LEGACY, +}; + +const dataViewConfig = { + name: 'data view api', + path: DATA_VIEW_PATH, + basePath: SERVICE_PATH, + serviceKey: SERVICE_KEY, +}; + +export const configArray = [legacyConfig, dataViewConfig]; diff --git a/test/api_integration/apis/index_patterns/default_index_pattern/default_index_pattern.ts b/test/api_integration/apis/index_patterns/default_index_pattern/default_index_pattern.ts index b12600f5ce4f3..d34ad5ccd5f4d 100644 --- a/test/api_integration/apis/index_patterns/default_index_pattern/default_index_pattern.ts +++ b/test/api_integration/apis/index_patterns/default_index_pattern/default_index_pattern.ts @@ -8,42 +8,49 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../ftr_provider_context'; +import { configArray } from '../constants'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); describe('default index pattern api', () => { - const newId = () => `default-id-${Date.now()}-${Math.random()}`; - it('can set default index pattern', async () => { - const defaultId = newId(); - const response1 = await supertest.post('/api/index_patterns/default').send({ - index_pattern_id: defaultId, - force: true, + configArray.forEach((config) => { + describe(config.name, () => { + const newId = () => `default-id-${Date.now()}-${Math.random()}`; + it('can set default index pattern', async () => { + const defaultId = newId(); + const defaultPath = `${config.basePath}/default`; + const serviceKeyId = `${config.serviceKey}_id`; + const response1 = await supertest.post(defaultPath).send({ + [serviceKeyId]: defaultId, + force: true, + }); + expect(response1.status).to.be(200); + expect(response1.body.acknowledged).to.be(true); + + const response2 = await supertest.get(defaultPath); + expect(response2.status).to.be(200); + expect(response2.body[serviceKeyId]).to.be(defaultId); + + const response3 = await supertest.post(defaultPath).send({ + [serviceKeyId]: newId(), + // no force this time, so this new default shouldn't be set + }); + + expect(response3.status).to.be(200); + const response4 = await supertest.get(defaultPath); + expect(response4.body[serviceKeyId]).to.be(defaultId); // original default id is used + + const response5 = await supertest.post(defaultPath).send({ + [serviceKeyId]: null, + force: true, + }); + expect(response5.status).to.be(200); + + const response6 = await supertest.get(defaultPath); + expect(response6.body[serviceKeyId]).to.be(null); + }); }); - expect(response1.status).to.be(200); - expect(response1.body.acknowledged).to.be(true); - - const response2 = await supertest.get('/api/index_patterns/default'); - expect(response2.status).to.be(200); - expect(response2.body.index_pattern_id).to.be(defaultId); - - const response3 = await supertest.post('/api/index_patterns/default').send({ - index_pattern_id: newId(), - // no force this time, so this new default shouldn't be set - }); - - expect(response3.status).to.be(200); - const response4 = await supertest.get('/api/index_patterns/default'); - expect(response4.body.index_pattern_id).to.be(defaultId); // original default id is used - - const response5 = await supertest.post('/api/index_patterns/default').send({ - index_pattern_id: null, - force: true, - }); - expect(response5.status).to.be(200); - - const response6 = await supertest.get('/api/index_patterns/default'); - expect(response6.body.index_pattern_id).to.be(null); }); }); } diff --git a/test/api_integration/apis/index_patterns/fields_api/update_fields/errors.ts b/test/api_integration/apis/index_patterns/fields_api/update_fields/errors.ts index dfc3d90dcaeec..55ac2e165a9ac 100644 --- a/test/api_integration/apis/index_patterns/fields_api/update_fields/errors.ts +++ b/test/api_integration/apis/index_patterns/fields_api/update_fields/errors.ts @@ -8,63 +8,68 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { configArray } from '../../constants'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); describe('errors', () => { - it('returns 404 error on non-existing index_pattern', async () => { - const id = `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx-${Date.now()}`; - const response = await supertest.post(`/api/index_patterns/index_pattern/${id}/fields`).send({ - fields: { - foo: {}, - }, - }); + configArray.forEach((config) => { + describe(config.name, () => { + it('returns 404 error on non-existing index_pattern', async () => { + const id = `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx-${Date.now()}`; + const response = await supertest.post(`${config.path}/${id}/fields`).send({ + fields: { + foo: {}, + }, + }); - expect(response.status).to.be(404); - }); + expect(response.status).to.be(404); + }); - it('returns error when "fields" payload attribute is invalid', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - }, - }); - const response2 = await supertest - .post(`/api/index_patterns/index_pattern/${response1.body.index_pattern.id}/fields`) - .send({ - fields: 123, + it('returns error when "fields" payload attribute is invalid', async () => { + const title = `foo-${Date.now()}-${Math.random()}*`; + const response1 = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, + }, + }); + const response2 = await supertest + .post(`${config.path}/${response1.body[config.serviceKey].id}/fields`) + .send({ + fields: 123, + }); + + expect(response2.status).to.be(400); + expect(response2.body.statusCode).to.be(400); + expect(response2.body.message).to.be( + '[request body.fields]: expected value of type [object] but got [number]' + ); }); - expect(response2.status).to.be(400); - expect(response2.body.statusCode).to.be(400); - expect(response2.body.message).to.be( - '[request body.fields]: expected value of type [object] but got [number]' - ); - }); + it('returns error if not changes are specified', async () => { + const title = `foo-${Date.now()}-${Math.random()}*`; + const response1 = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, + }, + }); - it('returns error if not changes are specified', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - }, - }); + const response2 = await supertest + .post(`${config.path}/${response1.body[config.serviceKey].id}/fields`) + .send({ + fields: { + foo: {}, + bar: {}, + baz: {}, + }, + }); - const response2 = await supertest - .post(`/api/index_patterns/index_pattern/${response1.body.index_pattern.id}/fields`) - .send({ - fields: { - foo: {}, - bar: {}, - baz: {}, - }, + expect(response2.status).to.be(400); + expect(response2.body.statusCode).to.be(400); + expect(response2.body.message).to.be('Change set is empty.'); }); - - expect(response2.status).to.be(400); - expect(response2.body.statusCode).to.be(400); - expect(response2.body.message).to.be('Change set is empty.'); + }); }); }); } diff --git a/test/api_integration/apis/index_patterns/fields_api/update_fields/main.ts b/test/api_integration/apis/index_patterns/fields_api/update_fields/main.ts index 16861f3c28051..d48dd90396e16 100644 --- a/test/api_integration/apis/index_patterns/fields_api/update_fields/main.ts +++ b/test/api_integration/apis/index_patterns/fields_api/update_fields/main.ts @@ -8,6 +8,7 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { configArray } from '../../constants'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); @@ -17,474 +18,476 @@ export default function ({ getService }: FtrProviderContext) { const basicIndex = 'ba*ic_index'; let indexPattern: any; - before(async () => { - await esArchiver.load('test/api_integration/fixtures/es_archiver/index_patterns/basic_index'); - - indexPattern = ( - await supertest.post('/api/index_patterns/index_pattern').send({ - override: true, - index_pattern: { - title: basicIndex, - }, - }) - ).body.index_pattern; - }); - - after(async () => { - await esArchiver.unload( - 'test/api_integration/fixtures/es_archiver/index_patterns/basic_index' - ); - - if (indexPattern) { - await supertest.delete('/api/index_patterns/index_pattern/' + indexPattern.id); - } - }); - - it('can update multiple fields', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - }, - }); - - expect(response1.status).to.be(200); - expect(response1.body.index_pattern.fieldAttrs.foo).to.be(undefined); - expect(response1.body.index_pattern.fieldAttrs.bar).to.be(undefined); - - const response2 = await supertest - .post(`/api/index_patterns/index_pattern/${response1.body.index_pattern.id}/fields`) - .send({ - fields: { - foo: { - count: 123, - customLabel: 'test', - }, - bar: { - count: 456, - }, - }, + configArray.forEach((config) => { + describe(config.name, () => { + before(async () => { + await esArchiver.load( + 'test/api_integration/fixtures/es_archiver/index_patterns/basic_index' + ); + + indexPattern = ( + await supertest.post(config.path).send({ + override: true, + [config.serviceKey]: { + title: basicIndex, + }, + }) + ).body[config.serviceKey]; }); - expect(response2.status).to.be(200); - expect(response2.body.index_pattern.fieldAttrs.foo.count).to.be(123); - expect(response2.body.index_pattern.fieldAttrs.foo.customLabel).to.be('test'); - expect(response2.body.index_pattern.fieldAttrs.bar.count).to.be(456); + after(async () => { + await esArchiver.unload( + 'test/api_integration/fixtures/es_archiver/index_patterns/basic_index' + ); - const response3 = await supertest.get( - `/api/index_patterns/index_pattern/${response1.body.index_pattern.id}` - ); - - expect(response3.status).to.be(200); - expect(response3.body.index_pattern.fieldAttrs.foo.count).to.be(123); - expect(response3.body.index_pattern.fieldAttrs.foo.customLabel).to.be('test'); - expect(response3.body.index_pattern.fieldAttrs.bar.count).to.be(456); - }); - - describe('count', () => { - it('can set field "count" attribute on non-existing field', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - }, + if (indexPattern) { + await supertest.delete(`${config.path}/${indexPattern.id}`); + } }); - expect(response1.status).to.be(200); - expect(response1.body.index_pattern.fieldAttrs.foo).to.be(undefined); - - const response2 = await supertest - .post(`/api/index_patterns/index_pattern/${response1.body.index_pattern.id}/fields`) - .send({ - fields: { - foo: { - count: 123, - }, + it('can update multiple fields', async () => { + const title = `foo-${Date.now()}-${Math.random()}*`; + const response1 = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, }, }); - expect(response2.status).to.be(200); - expect(response2.body.index_pattern.fieldAttrs.foo.count).to.be(123); + expect(response1.status).to.be(200); + expect(response1.body[config.serviceKey].fieldAttrs.foo).to.be(undefined); + expect(response1.body[config.serviceKey].fieldAttrs.bar).to.be(undefined); + + const response2 = await supertest + .post(`${config.path}/${response1.body[config.serviceKey].id}/fields`) + .send({ + fields: { + foo: { + count: 123, + customLabel: 'test', + }, + bar: { + count: 456, + }, + }, + }); - const response3 = await supertest.get( - `/api/index_patterns/index_pattern/${response1.body.index_pattern.id}` - ); + expect(response2.status).to.be(200); + expect(response2.body[config.serviceKey].fieldAttrs.foo.count).to.be(123); + expect(response2.body[config.serviceKey].fieldAttrs.foo.customLabel).to.be('test'); + expect(response2.body[config.serviceKey].fieldAttrs.bar.count).to.be(456); - expect(response3.status).to.be(200); - expect(response3.body.index_pattern.fieldAttrs.foo.count).to.be(123); - }); + const response3 = await supertest.get( + `${config.path}/${response1.body[config.serviceKey].id}` + ); - it('can update "count" attribute in index_pattern attribute map', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - fieldAttrs: { - foo: { - count: 1, - }, - }, - }, + expect(response3.status).to.be(200); + expect(response3.body[config.serviceKey].fieldAttrs.foo.count).to.be(123); + expect(response3.body[config.serviceKey].fieldAttrs.foo.customLabel).to.be('test'); + expect(response3.body[config.serviceKey].fieldAttrs.bar.count).to.be(456); }); - expect(response1.status).to.be(200); - expect(response1.body.index_pattern.fieldAttrs.foo.count).to.be(1); - - const response2 = await supertest - .post(`/api/index_patterns/index_pattern/${response1.body.index_pattern.id}/fields`) - .send({ - fields: { - foo: { - count: 2, + describe('count', () => { + it('can set field "count" attribute on non-existing field', async () => { + const title = `foo-${Date.now()}-${Math.random()}*`; + const response1 = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, }, - }, - }); - - expect(response2.status).to.be(200); - expect(response2.body.index_pattern.fieldAttrs.foo.count).to.be(2); - - const response3 = await supertest.get( - `/api/index_patterns/index_pattern/${response1.body.index_pattern.id}` - ); - - expect(response3.status).to.be(200); - expect(response3.body.index_pattern.fieldAttrs.foo.count).to.be(2); - }); + }); + + expect(response1.status).to.be(200); + expect(response1.body[config.serviceKey].fieldAttrs.foo).to.be(undefined); + + const response2 = await supertest + .post(`${config.path}/${response1.body[config.serviceKey].id}/fields`) + .send({ + fields: { + foo: { + count: 123, + }, + }, + }); - it('can delete "count" attribute from index_pattern attribute map', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - fieldAttrs: { - foo: { - count: 1, - }, - }, - }, - }); + expect(response2.status).to.be(200); + expect(response2.body[config.serviceKey].fieldAttrs.foo.count).to.be(123); - expect(response1.status).to.be(200); - expect(response1.body.index_pattern.fieldAttrs.foo.count).to.be(1); + const response3 = await supertest.get( + `${config.path}/${response1.body[config.serviceKey].id}` + ); - const response2 = await supertest - .post(`/api/index_patterns/index_pattern/${response1.body.index_pattern.id}/fields`) - .send({ - fields: { - foo: { - count: null, - }, - }, + expect(response3.status).to.be(200); + expect(response3.body[config.serviceKey].fieldAttrs.foo.count).to.be(123); }); - expect(response2.status).to.be(200); - expect(response2.body.index_pattern.fieldAttrs.foo.count).to.be(undefined); - - const response3 = await supertest.get( - `/api/index_patterns/index_pattern/${response1.body.index_pattern.id}` - ); - - expect(response3.status).to.be(200); - expect(response3.body.index_pattern.fieldAttrs.foo.count).to.be(undefined); - }); - }); + it('can update "count" attribute in index_pattern attribute map', async () => { + const title = `foo-${Date.now()}-${Math.random()}*`; + const response1 = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, + fieldAttrs: { + foo: { + count: 1, + }, + }, + }, + }); + + expect(response1.status).to.be(200); + expect(response1.body[config.serviceKey].fieldAttrs.foo.count).to.be(1); + + const response2 = await supertest + .post(`${config.path}/${response1.body[config.serviceKey].id}/fields`) + .send({ + fields: { + foo: { + count: 2, + }, + }, + }); - describe('customLabel', () => { - it('can set field "customLabel" attribute on non-existing field', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - }, - }); + expect(response2.status).to.be(200); + expect(response2.body[config.serviceKey].fieldAttrs.foo.count).to.be(2); - expect(response1.status).to.be(200); - expect(response1.body.index_pattern.fieldAttrs.foo).to.be(undefined); + const response3 = await supertest.get( + `${config.path}/${response1.body[config.serviceKey].id}` + ); - const response2 = await supertest - .post(`/api/index_patterns/index_pattern/${response1.body.index_pattern.id}/fields`) - .send({ - fields: { - foo: { - customLabel: 'foo', - }, - }, + expect(response3.status).to.be(200); + expect(response3.body[config.serviceKey].fieldAttrs.foo.count).to.be(2); }); - expect(response2.status).to.be(200); - expect(response2.body.index_pattern.fieldAttrs.foo.customLabel).to.be('foo'); + it('can delete "count" attribute from index_pattern attribute map', async () => { + const title = `foo-${Date.now()}-${Math.random()}*`; + const response1 = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, + fieldAttrs: { + foo: { + count: 1, + }, + }, + }, + }); + + expect(response1.status).to.be(200); + expect(response1.body[config.serviceKey].fieldAttrs.foo.count).to.be(1); + + const response2 = await supertest + .post(`${config.path}/${response1.body[config.serviceKey].id}/fields`) + .send({ + fields: { + foo: { + count: null, + }, + }, + }); - const response3 = await supertest.get( - `/api/index_patterns/index_pattern/${response1.body.index_pattern.id}` - ); + expect(response2.status).to.be(200); + expect(response2.body[config.serviceKey].fieldAttrs.foo.count).to.be(undefined); - expect(response3.status).to.be(200); - expect(response3.body.index_pattern.fieldAttrs.foo.customLabel).to.be('foo'); - }); + const response3 = await supertest.get( + `${config.path}/${response1.body[config.serviceKey].id}` + ); - it('can update "customLabel" attribute in index_pattern attribute map', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - fieldAttrs: { - foo: { - customLabel: 'foo', - }, - }, - }, + expect(response3.status).to.be(200); + expect(response3.body[config.serviceKey].fieldAttrs.foo.count).to.be(undefined); + }); }); - expect(response1.status).to.be(200); - expect(response1.body.index_pattern.fieldAttrs.foo.customLabel).to.be('foo'); - - const response2 = await supertest - .post(`/api/index_patterns/index_pattern/${response1.body.index_pattern.id}/fields`) - .send({ - fields: { - foo: { - customLabel: 'bar', + describe('customLabel', () => { + it('can set field "customLabel" attribute on non-existing field', async () => { + const title = `foo-${Date.now()}-${Math.random()}*`; + const response1 = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, }, - }, - }); + }); + + expect(response1.status).to.be(200); + expect(response1.body[config.serviceKey].fieldAttrs.foo).to.be(undefined); + + const response2 = await supertest + .post(`${config.path}/${response1.body[config.serviceKey].id}/fields`) + .send({ + fields: { + foo: { + customLabel: 'foo', + }, + }, + }); - expect(response2.status).to.be(200); - expect(response2.body.index_pattern.fieldAttrs.foo.customLabel).to.be('bar'); + expect(response2.status).to.be(200); + expect(response2.body[config.serviceKey].fieldAttrs.foo.customLabel).to.be('foo'); - const response3 = await supertest.get( - `/api/index_patterns/index_pattern/${response1.body.index_pattern.id}` - ); + const response3 = await supertest.get( + `${config.path}/${response1.body[config.serviceKey].id}` + ); - expect(response3.status).to.be(200); - expect(response3.body.index_pattern.fieldAttrs.foo.customLabel).to.be('bar'); - }); + expect(response3.status).to.be(200); + expect(response3.body[config.serviceKey].fieldAttrs.foo.customLabel).to.be('foo'); + }); - it('can delete "customLabel" attribute from index_pattern attribute map', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - fieldAttrs: { - foo: { - customLabel: 'foo', + it('can update "customLabel" attribute in index_pattern attribute map', async () => { + const title = `foo-${Date.now()}-${Math.random()}*`; + const response1 = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, + fieldAttrs: { + foo: { + customLabel: 'foo', + }, + }, }, - }, - }, - }); + }); + + expect(response1.status).to.be(200); + expect(response1.body[config.serviceKey].fieldAttrs.foo.customLabel).to.be('foo'); + + const response2 = await supertest + .post(`${config.path}/${response1.body[config.serviceKey].id}/fields`) + .send({ + fields: { + foo: { + customLabel: 'bar', + }, + }, + }); - expect(response1.status).to.be(200); - expect(response1.body.index_pattern.fieldAttrs.foo.customLabel).to.be('foo'); + expect(response2.status).to.be(200); + expect(response2.body[config.serviceKey].fieldAttrs.foo.customLabel).to.be('bar'); - const response2 = await supertest - .post(`/api/index_patterns/index_pattern/${response1.body.index_pattern.id}/fields`) - .send({ - fields: { - foo: { - customLabel: null, - }, - }, + const response3 = await supertest.get( + `${config.path}/${response1.body[config.serviceKey].id}` + ); + + expect(response3.status).to.be(200); + expect(response3.body[config.serviceKey].fieldAttrs.foo.customLabel).to.be('bar'); }); - expect(response2.status).to.be(200); - expect(response2.body.index_pattern.fieldAttrs.foo.customLabel).to.be(undefined); + it('can delete "customLabel" attribute from index_pattern attribute map', async () => { + const title = `foo-${Date.now()}-${Math.random()}*`; + const response1 = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, + fieldAttrs: { + foo: { + customLabel: 'foo', + }, + }, + }, + }); + + expect(response1.status).to.be(200); + expect(response1.body[config.serviceKey].fieldAttrs.foo.customLabel).to.be('foo'); + + const response2 = await supertest + .post(`${config.path}/${response1.body[config.serviceKey].id}/fields`) + .send({ + fields: { + foo: { + customLabel: null, + }, + }, + }); - const response3 = await supertest.get( - `/api/index_patterns/index_pattern/${response1.body.index_pattern.id}` - ); + expect(response2.status).to.be(200); + expect(response2.body[config.serviceKey].fieldAttrs.foo.customLabel).to.be(undefined); - expect(response3.status).to.be(200); - expect(response3.body.index_pattern.fieldAttrs.foo.customLabel).to.be(undefined); - }); + const response3 = await supertest.get( + `${config.path}/${response1.body[config.serviceKey].id}` + ); - it('can set field "customLabel" attribute on an existing field', async () => { - await supertest.post(`/api/index_patterns/index_pattern/${indexPattern.id}/fields`).send({ - fields: { - foo: { - customLabel: 'baz', - }, - }, - }); + expect(response3.status).to.be(200); + expect(response3.body[config.serviceKey].fieldAttrs.foo.customLabel).to.be(undefined); + }); - const response1 = await supertest.get( - `/api/index_patterns/index_pattern/${indexPattern.id}` - ); + it('can set field "customLabel" attribute on an existing field', async () => { + await supertest.post(`${config.path}/${indexPattern.id}/fields`).send({ + fields: { + foo: { + customLabel: 'baz', + }, + }, + }); - expect(response1.status).to.be(200); - expect(response1.body.index_pattern.fields.foo.customLabel).to.be('baz'); - }); - }); + const response1 = await supertest.get(`${config.path}/${indexPattern.id}`); - describe('format', () => { - it('can set field "format" attribute on non-existing field', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - }, + expect(response1.status).to.be(200); + expect(response1.body[config.serviceKey].fields.foo.customLabel).to.be('baz'); + }); }); - expect(response1.status).to.be(200); - expect(response1.body.index_pattern.fieldFormats.foo).to.be(undefined); - - const response2 = await supertest - .post(`/api/index_patterns/index_pattern/${response1.body.index_pattern.id}/fields`) - .send({ - fields: { - foo: { - format: { - id: 'bar', - params: { baz: 'qux' }, - }, + describe('format', () => { + it('can set field "format" attribute on non-existing field', async () => { + const title = `foo-${Date.now()}-${Math.random()}*`; + const response1 = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, }, - }, + }); + + expect(response1.status).to.be(200); + expect(response1.body[config.serviceKey].fieldFormats.foo).to.be(undefined); + + const response2 = await supertest + .post(`${config.path}/${response1.body[config.serviceKey].id}/fields`) + .send({ + fields: { + foo: { + format: { + id: 'bar', + params: { baz: 'qux' }, + }, + }, + }, + }); + + expect(response2.status).to.be(200); + expect(response2.body[config.serviceKey].fieldFormats.foo).to.eql({ + id: 'bar', + params: { baz: 'qux' }, + }); + + const response3 = await supertest.get( + `${config.path}/${response1.body[config.serviceKey].id}` + ); + + expect(response3.status).to.be(200); + expect(response3.body[config.serviceKey].fieldFormats.foo).to.eql({ + id: 'bar', + params: { baz: 'qux' }, + }); }); - expect(response2.status).to.be(200); - expect(response2.body.index_pattern.fieldFormats.foo).to.eql({ - id: 'bar', - params: { baz: 'qux' }, - }); - - const response3 = await supertest.get( - `/api/index_patterns/index_pattern/${response1.body.index_pattern.id}` - ); - - expect(response3.status).to.be(200); - expect(response3.body.index_pattern.fieldFormats.foo).to.eql({ - id: 'bar', - params: { baz: 'qux' }, - }); - }); - - it('can update "format" attribute in index_pattern format map', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - fieldFormats: { - foo: { - id: 'bar', - params: { - baz: 'qux', + it('can update "format" attribute in index_pattern format map', async () => { + const title = `foo-${Date.now()}-${Math.random()}*`; + const response1 = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, + fieldFormats: { + foo: { + id: 'bar', + params: { + baz: 'qux', + }, + }, }, }, - }, - }, - }); + }); - expect(response1.status).to.be(200); - expect(response1.body.index_pattern.fieldFormats.foo).to.eql({ - id: 'bar', - params: { - baz: 'qux', - }, - }); - - const response2 = await supertest - .post(`/api/index_patterns/index_pattern/${response1.body.index_pattern.id}/fields`) - .send({ - fields: { - foo: { - format: { - id: 'bar-2', - params: { baz: 'qux-2' }, - }, + expect(response1.status).to.be(200); + expect(response1.body[config.serviceKey].fieldFormats.foo).to.eql({ + id: 'bar', + params: { + baz: 'qux', }, - }, + }); + + const response2 = await supertest + .post(`${config.path}/${response1.body[config.serviceKey].id}/fields`) + .send({ + fields: { + foo: { + format: { + id: 'bar-2', + params: { baz: 'qux-2' }, + }, + }, + }, + }); + + expect(response2.status).to.be(200); + expect(response2.body[config.serviceKey].fieldFormats.foo).to.eql({ + id: 'bar-2', + params: { baz: 'qux-2' }, + }); + + const response3 = await supertest.get( + `${config.path}/${response1.body[config.serviceKey].id}` + ); + + expect(response3.status).to.be(200); + expect(response3.body[config.serviceKey].fieldFormats.foo).to.eql({ + id: 'bar-2', + params: { baz: 'qux-2' }, + }); }); - expect(response2.status).to.be(200); - expect(response2.body.index_pattern.fieldFormats.foo).to.eql({ - id: 'bar-2', - params: { baz: 'qux-2' }, - }); + it('can remove "format" attribute from index_pattern format map', async () => { + const response2 = await supertest + .post(`${config.path}/${indexPattern.id}/fields`) + .send({ + fields: { + foo: { + format: null, + }, + }, + }); - const response3 = await supertest.get( - `/api/index_patterns/index_pattern/${response1.body.index_pattern.id}` - ); + expect(response2.status).to.be(200); + expect(response2.body[config.serviceKey].fieldFormats.foo).to.be(undefined); - expect(response3.status).to.be(200); - expect(response3.body.index_pattern.fieldFormats.foo).to.eql({ - id: 'bar-2', - params: { baz: 'qux-2' }, - }); - }); + const response3 = await supertest.get(`${config.path}/${indexPattern.id}`); - it('can remove "format" attribute from index_pattern format map', async () => { - const response2 = await supertest - .post(`/api/index_patterns/index_pattern/${indexPattern.id}/fields`) - .send({ - fields: { - foo: { - format: null, - }, - }, + expect(response3.status).to.be(200); + expect(response3.body[config.serviceKey].fieldFormats.foo).to.be(undefined); }); - expect(response2.status).to.be(200); - expect(response2.body.index_pattern.fieldFormats.foo).to.be(undefined); - - const response3 = await supertest.get( - `/api/index_patterns/index_pattern/${indexPattern.id}` - ); - - expect(response3.status).to.be(200); - expect(response3.body.index_pattern.fieldFormats.foo).to.be(undefined); - }); - - it('can set field "format" on an existing field', async () => { - const title = indexPattern.title; - await supertest.delete(`/api/index_patterns/index_pattern/${indexPattern.id}`); - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - fields: { - foo: { - name: 'foo', - type: 'string', - scripted: true, - format: { - id: 'string', + it('can set field "format" on an existing field', async () => { + const title = indexPattern.title; + await supertest.delete(`${config.path}/${indexPattern.id}`); + const response1 = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, + fields: { + foo: { + name: 'foo', + type: 'string', + scripted: true, + format: { + id: 'string', + }, + }, }, }, - }, - }, - }); - - expect(response1.status).to.be(200); - expect(response1.body.index_pattern.fieldFormats.foo).to.be(undefined); - expect(response1.body.index_pattern.fields.foo.format).to.eql({ - id: 'string', - }); - - const response2 = await supertest - .post(`/api/index_patterns/index_pattern/${response1.body.index_pattern.id}/fields`) - .send({ - fields: { - foo: { - format: { id: 'number' }, - }, - }, + }); + + expect(response1.status).to.be(200); + expect(response1.body[config.serviceKey].fieldFormats.foo).to.be(undefined); + expect(response1.body[config.serviceKey].fields.foo.format).to.eql({ + id: 'string', + }); + + const response2 = await supertest + .post(`${config.path}/${response1.body[config.serviceKey].id}/fields`) + .send({ + fields: { + foo: { + format: { id: 'number' }, + }, + }, + }); + + expect(response2.status).to.be(200); + expect(response2.body[config.serviceKey].fieldFormats.foo).to.eql({ + id: 'number', + }); + expect(response2.body[config.serviceKey].fields.foo.format).to.eql({ + id: 'number', + }); + + const response3 = await supertest.get( + `${config.path}/${response1.body[config.serviceKey].id}` + ); + + expect(response3.status).to.be(200); + expect(response3.body[config.serviceKey].fieldFormats.foo).to.eql({ + id: 'number', + }); + expect(response3.body[config.serviceKey].fields.foo.format).to.eql({ + id: 'number', + }); }); - - expect(response2.status).to.be(200); - expect(response2.body.index_pattern.fieldFormats.foo).to.eql({ - id: 'number', - }); - expect(response2.body.index_pattern.fields.foo.format).to.eql({ - id: 'number', - }); - - const response3 = await supertest.get( - `/api/index_patterns/index_pattern/${response1.body.index_pattern.id}` - ); - - expect(response3.status).to.be(200); - expect(response3.body.index_pattern.fieldFormats.foo).to.eql({ - id: 'number', - }); - expect(response3.body.index_pattern.fields.foo.format).to.eql({ - id: 'number', }); }); }); diff --git a/test/api_integration/apis/index_patterns/has_user_index_pattern/has_user_index_pattern.ts b/test/api_integration/apis/index_patterns/has_user_index_pattern/has_user_index_pattern.ts index 8fc4e860e5d9c..75ac7086bec53 100644 --- a/test/api_integration/apis/index_patterns/has_user_index_pattern/has_user_index_pattern.ts +++ b/test/api_integration/apis/index_patterns/has_user_index_pattern/has_user_index_pattern.ts @@ -8,6 +8,7 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../ftr_provider_context'; +import { configArray } from '../constants'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); @@ -15,125 +16,133 @@ export default function ({ getService }: FtrProviderContext) { const es = getService('es'); describe('has user index pattern API', () => { - beforeEach(async () => { - await esArchiver.emptyKibanaIndex(); - if (await es.indices.exists({ index: 'metrics-test' })) { - await es.indices.delete({ index: 'metrics-test' }); - } - - if (await es.indices.exists({ index: 'logs-test' })) { - await es.indices.delete({ index: 'logs-test' }); - } - }); - - it('should return false if no index patterns', async () => { - const response = await supertest.get('/api/index_patterns/has_user_index_pattern'); - expect(response.status).to.be(200); - expect(response.body.result).to.be(false); - }); - - it('should return true if has index pattern with user data', async () => { - await esArchiver.load('test/api_integration/fixtures/es_archiver/index_patterns/basic_index'); - await supertest.post('/api/index_patterns/index_pattern').send({ - override: true, - index_pattern: { - title: 'basic_index', - }, - }); - - const response = await supertest.get('/api/index_patterns/has_user_index_pattern'); - expect(response.status).to.be(200); - expect(response.body.result).to.be(true); - - await esArchiver.unload( - 'test/api_integration/fixtures/es_archiver/index_patterns/basic_index' - ); - }); - - it('should return true if has user index pattern without data', async () => { - await supertest.post('/api/index_patterns/index_pattern').send({ - override: true, - index_pattern: { - title: 'basic_index', - allowNoIndex: true, - }, - }); - - const response = await supertest.get('/api/index_patterns/has_user_index_pattern'); - expect(response.status).to.be(200); - expect(response.body.result).to.be(true); - }); - - it('should return false if only metric-* index pattern without data', async () => { - await supertest.post('/api/index_patterns/index_pattern').send({ - override: true, - index_pattern: { - title: 'metrics-*', - allowNoIndex: true, - }, + configArray.forEach((config) => { + describe(config.name, () => { + beforeEach(async () => { + await esArchiver.emptyKibanaIndex(); + if (await es.indices.exists({ index: 'metrics-test' })) { + await es.indices.delete({ index: 'metrics-test' }); + } + + if (await es.indices.exists({ index: 'logs-test' })) { + await es.indices.delete({ index: 'logs-test' }); + } + }); + + const servicePath = `${config.basePath}/has_user_${config.serviceKey}`; + + it('should return false if no index patterns', async () => { + const response = await supertest.get(servicePath); + expect(response.status).to.be(200); + expect(response.body.result).to.be(false); + }); + + it('should return true if has index pattern with user data', async () => { + await esArchiver.load( + 'test/api_integration/fixtures/es_archiver/index_patterns/basic_index' + ); + await supertest.post(config.path).send({ + override: true, + [config.serviceKey]: { + title: 'basic_index', + }, + }); + + const response = await supertest.get(servicePath); + expect(response.status).to.be(200); + expect(response.body.result).to.be(true); + + await esArchiver.unload( + 'test/api_integration/fixtures/es_archiver/index_patterns/basic_index' + ); + }); + + it('should return true if has user index pattern without data', async () => { + await supertest.post(config.path).send({ + override: true, + [config.serviceKey]: { + title: 'basic_index', + allowNoIndex: true, + }, + }); + + const response = await supertest.get(servicePath); + expect(response.status).to.be(200); + expect(response.body.result).to.be(true); + }); + + it('should return false if only metric-* index pattern without data', async () => { + await supertest.post(config.path).send({ + override: true, + [config.serviceKey]: { + title: 'metrics-*', + allowNoIndex: true, + }, + }); + + const response = await supertest.get(servicePath); + expect(response.status).to.be(200); + expect(response.body.result).to.be(false); + }); + + it('should return true if metric-* index pattern with user data', async () => { + await es.index({ + index: 'metrics-test', + body: { + foo: 'bar', + }, + }); + + await supertest.post(config.path).send({ + override: true, + [config.serviceKey]: { + title: 'metrics-*', + }, + }); + + const response = await supertest.get(servicePath); + expect(response.status).to.be(200); + expect(response.body.result).to.be(true); + }); + + it('should return false if only logs-* index pattern without data', async () => { + await supertest.post(config.path).send({ + override: true, + [config.serviceKey]: { + title: 'logs-*', + }, + }); + + const response = await supertest.get(servicePath); + expect(response.status).to.be(200); + expect(response.body.result).to.be(false); + }); + + it('should return true if logs-* index pattern with user data', async () => { + await es.index({ + index: 'logs-test', + body: { + foo: 'bar', + }, + }); + + await supertest.post(config.path).send({ + override: true, + [config.serviceKey]: { + title: 'logs-*', + }, + }); + + const response = await supertest.get(servicePath); + expect(response.status).to.be(200); + expect(response.body.result).to.be(true); + }); + + // TODO: should setup fleet first similar to x-pack/test/fleet_functional/apps/home/welcome.ts + // but it is skipped due to flakiness https://github.com/elastic/kibana/issues/109017 + it('should return false if logs-* with .ds-logs-elastic_agent only'); + it('should return false if metrics-* with .ds-metrics-elastic_agent only'); }); - - const response = await supertest.get('/api/index_patterns/has_user_index_pattern'); - expect(response.status).to.be(200); - expect(response.body.result).to.be(false); }); - - it('should return true if metric-* index pattern with user data', async () => { - await es.index({ - index: 'metrics-test', - body: { - foo: 'bar', - }, - }); - - await supertest.post('/api/index_patterns/index_pattern').send({ - override: true, - index_pattern: { - title: 'metrics-*', - }, - }); - - const response = await supertest.get('/api/index_patterns/has_user_index_pattern'); - expect(response.status).to.be(200); - expect(response.body.result).to.be(true); - }); - - it('should return false if only logs-* index pattern without data', async () => { - await supertest.post('/api/index_patterns/index_pattern').send({ - override: true, - index_pattern: { - title: 'logs-*', - }, - }); - - const response = await supertest.get('/api/index_patterns/has_user_index_pattern'); - expect(response.status).to.be(200); - expect(response.body.result).to.be(false); - }); - - it('should return true if logs-* index pattern with user data', async () => { - await es.index({ - index: 'logs-test', - body: { - foo: 'bar', - }, - }); - - await supertest.post('/api/index_patterns/index_pattern').send({ - override: true, - index_pattern: { - title: 'logs-*', - }, - }); - - const response = await supertest.get('/api/index_patterns/has_user_index_pattern'); - expect(response.status).to.be(200); - expect(response.body.result).to.be(true); - }); - - // TODO: should setup fleet first similar to x-pack/test/fleet_functional/apps/home/welcome.ts - // but it is skipped due to flakiness https://github.com/elastic/kibana/issues/109017 - it('should return false if logs-* with .ds-logs-elastic_agent only'); - it('should return false if metrics-* with .ds-metrics-elastic_agent only'); }); } diff --git a/test/api_integration/apis/index_patterns/index_pattern_crud/create_index_pattern/main.ts b/test/api_integration/apis/index_patterns/index_pattern_crud/create_index_pattern/main.ts index 500a642f60850..43c9696fd11f8 100644 --- a/test/api_integration/apis/index_patterns/index_pattern_crud/create_index_pattern/main.ts +++ b/test/api_integration/apis/index_patterns/index_pattern_crud/create_index_pattern/main.ts @@ -8,276 +8,286 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { configArray } from '../../constants'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const esArchiver = getService('esArchiver'); - describe('main', () => { - it('can create an index_pattern with just a title', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; - const response = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - }, - }); - - expect(response.status).to.be(200); - }); - - it('returns back the created index_pattern object', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; - const response = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - }, - }); - - expect(typeof response.body.index_pattern).to.be('object'); - expect(response.body.index_pattern.title).to.be(title); - expect(typeof response.body.index_pattern.id).to.be('string'); - expect(response.body.index_pattern.id.length > 0).to.be(true); - }); - - it('can specify primitive optional attributes when creating an index pattern', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; - const id = `test-id-${Date.now()}-${Math.random()}*`; - const response = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - id, - type: 'test-type', - timeFieldName: 'test-timeFieldName', - }, - }); - - expect(response.status).to.be(200); - expect(response.body.index_pattern.title).to.be(title); - expect(response.body.index_pattern.id).to.be(id); - expect(response.body.index_pattern.type).to.be('test-type'); - expect(response.body.index_pattern.timeFieldName).to.be('test-timeFieldName'); - }); - - it('can specify optional sourceFilters attribute when creating an index pattern', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; - const response = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - sourceFilters: [ - { - value: 'foo', + configArray.forEach((config) => { + describe(config.name, () => { + it('can create an index_pattern with just a title', async () => { + const title = `foo-${Date.now()}-${Math.random()}*`; + const response = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, }, - ], - }, - }); + }); - expect(response.status).to.be(200); - expect(response.body.index_pattern.title).to.be(title); - expect(response.body.index_pattern.sourceFilters[0].value).to.be('foo'); - }); + expect(response.status).to.be(200); + }); - describe('creating fields', () => { - before(async () => { - await esArchiver.load( - 'test/api_integration/fixtures/es_archiver/index_patterns/basic_index' - ); - }); + it('returns back the created index_pattern object', async () => { + const title = `foo-${Date.now()}-${Math.random()}*`; + const response = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, + }, + }); - after(async () => { - await esArchiver.unload( - 'test/api_integration/fixtures/es_archiver/index_patterns/basic_index' - ); - }); + expect(typeof response.body[config.serviceKey]).to.be('object'); + expect(response.body[config.serviceKey].title).to.be(title); + expect(typeof response.body[config.serviceKey].id).to.be('string'); + expect(response.body[config.serviceKey].id.length > 0).to.be(true); + }); - it('can specify optional fields attribute when creating an index pattern', async () => { - const title = `basic_index*`; - const response = await supertest.post('/api/index_patterns/index_pattern').send({ - override: true, - index_pattern: { - title, - fields: { - foo: { - name: 'foo', - type: 'string', - scripted: true, - script: "doc['field_name'].value", - }, + it('can specify primitive optional attributes when creating an index pattern', async () => { + const title = `foo-${Date.now()}-${Math.random()}*`; + const id = `test-id-${Date.now()}-${Math.random()}*`; + const response = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, + id, + type: 'test-type', + timeFieldName: 'test-timeFieldName', }, - }, + }); + + expect(response.status).to.be(200); + expect(response.body[config.serviceKey].title).to.be(title); + expect(response.body[config.serviceKey].id).to.be(id); + expect(response.body[config.serviceKey].type).to.be('test-type'); + expect(response.body[config.serviceKey].timeFieldName).to.be('test-timeFieldName'); }); - expect(response.status).to.be(200); - expect(response.body.index_pattern.title).to.be(title); - expect(response.body.index_pattern.fields.foo.name).to.be('foo'); - expect(response.body.index_pattern.fields.foo.type).to.be('string'); - expect(response.body.index_pattern.fields.foo.scripted).to.be(true); - expect(response.body.index_pattern.fields.foo.script).to.be("doc['field_name'].value"); + it('can specify optional sourceFilters attribute when creating an index pattern', async () => { + const title = `foo-${Date.now()}-${Math.random()}*`; + const response = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, + sourceFilters: [ + { + value: 'foo', + }, + ], + }, + }); - expect(response.body.index_pattern.fields.bar.name).to.be('bar'); // created from es index - expect(response.body.index_pattern.fields.bar.type).to.be('boolean'); - }); + expect(response.status).to.be(200); + expect(response.body[config.serviceKey].title).to.be(title); + expect(response.body[config.serviceKey].sourceFilters[0].value).to.be('foo'); + }); - it('can add scripted fields, other fields created from es index', async () => { - const title = `basic_index*`; - const response = await supertest.post('/api/index_patterns/index_pattern').send({ - override: true, - index_pattern: { - title, - fields: { - foo: { - name: 'foo', - type: 'string', + describe('creating fields', () => { + before(async () => { + await esArchiver.load( + 'test/api_integration/fixtures/es_archiver/index_patterns/basic_index' + ); + }); + + after(async () => { + await esArchiver.unload( + 'test/api_integration/fixtures/es_archiver/index_patterns/basic_index' + ); + }); + + it('can specify optional fields attribute when creating an index pattern', async () => { + const title = `basic_index*`; + const response = await supertest.post(config.path).send({ + override: true, + [config.serviceKey]: { + title, + fields: { + foo: { + name: 'foo', + type: 'string', + scripted: true, + script: "doc['field_name'].value", + }, + }, }, - fake: { - name: 'fake', - type: 'string', + }); + + expect(response.status).to.be(200); + expect(response.body[config.serviceKey].title).to.be(title); + expect(response.body[config.serviceKey].fields.foo.name).to.be('foo'); + expect(response.body[config.serviceKey].fields.foo.type).to.be('string'); + expect(response.body[config.serviceKey].fields.foo.scripted).to.be(true); + expect(response.body[config.serviceKey].fields.foo.script).to.be( + "doc['field_name'].value" + ); + + expect(response.body[config.serviceKey].fields.bar.name).to.be('bar'); // created from es index + expect(response.body[config.serviceKey].fields.bar.type).to.be('boolean'); + }); + + it('can add scripted fields, other fields created from es index', async () => { + const title = `basic_index*`; + const response = await supertest.post(config.path).send({ + override: true, + [config.serviceKey]: { + title, + fields: { + foo: { + name: 'foo', + type: 'string', + }, + fake: { + name: 'fake', + type: 'string', + }, + bar: { + name: 'bar', + type: 'number', + count: 123, + script: '', + esTypes: ['test-type'], + scripted: true, + }, + }, }, - bar: { - name: 'bar', - type: 'number', - count: 123, - script: '', - esTypes: ['test-type'], - scripted: true, + }); + + expect(response.status).to.be(200); + expect(response.body[config.serviceKey].title).to.be(title); + + expect(response.body[config.serviceKey].fields.foo.name).to.be('foo'); + expect(response.body[config.serviceKey].fields.foo.type).to.be('number'); // picked up from index + + expect(response.body[config.serviceKey].fields.fake).to.be(undefined); // not in index, so not created + + expect(response.body[config.serviceKey].fields.bar.name).to.be('bar'); + expect(response.body[config.serviceKey].fields.bar.type).to.be('number'); + expect(response.body[config.serviceKey].fields.bar.count).to.be(123); + expect(response.body[config.serviceKey].fields.bar.script).to.be(''); + expect(response.body[config.serviceKey].fields.bar.esTypes[0]).to.be('test-type'); + expect(response.body[config.serviceKey].fields.bar.scripted).to.be(true); + }); + + it('can add runtime fields', async () => { + const title = `basic_index*`; + const response = await supertest.post(config.path).send({ + override: true, + [config.serviceKey]: { + title, + runtimeFieldMap: { + runtimeFoo: { + type: 'keyword', + script: { + source: 'emit(doc["foo"].value)', + }, + }, + }, }, - }, - }, + }); + + expect(response.status).to.be(200); + expect(response.body[config.serviceKey].title).to.be(title); + + expect(response.body[config.serviceKey].runtimeFieldMap.runtimeFoo.type).to.be( + 'keyword' + ); + expect(response.body[config.serviceKey].runtimeFieldMap.runtimeFoo.script.source).to.be( + 'emit(doc["foo"].value)' + ); + }); }); - expect(response.status).to.be(200); - expect(response.body.index_pattern.title).to.be(title); - - expect(response.body.index_pattern.fields.foo.name).to.be('foo'); - expect(response.body.index_pattern.fields.foo.type).to.be('number'); // picked up from index - - expect(response.body.index_pattern.fields.fake).to.be(undefined); // not in index, so not created + it('can specify optional typeMeta attribute when creating an index pattern', async () => { + const title = `foo-${Date.now()}-${Math.random()}*`; + const response = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, + typeMeta: {}, + }, + }); - expect(response.body.index_pattern.fields.bar.name).to.be('bar'); - expect(response.body.index_pattern.fields.bar.type).to.be('number'); - expect(response.body.index_pattern.fields.bar.count).to.be(123); - expect(response.body.index_pattern.fields.bar.script).to.be(''); - expect(response.body.index_pattern.fields.bar.esTypes[0]).to.be('test-type'); - expect(response.body.index_pattern.fields.bar.scripted).to.be(true); - }); + expect(response.status).to.be(200); + }); - it('can add runtime fields', async () => { - const title = `basic_index*`; - const response = await supertest.post('/api/index_patterns/index_pattern').send({ - override: true, - index_pattern: { - title, - runtimeFieldMap: { - runtimeFoo: { - type: 'keyword', - script: { - source: 'emit(doc["foo"].value)', + it('can specify optional fieldFormats attribute when creating an index pattern', async () => { + const title = `foo-${Date.now()}-${Math.random()}*`; + const response = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, + fieldFormats: { + foo: { + id: 'test-id', + params: {}, }, }, }, - }, - }); - - expect(response.status).to.be(200); - expect(response.body.index_pattern.title).to.be(title); - - expect(response.body.index_pattern.runtimeFieldMap.runtimeFoo.type).to.be('keyword'); - expect(response.body.index_pattern.runtimeFieldMap.runtimeFoo.script.source).to.be( - 'emit(doc["foo"].value)' - ); - }); - }); - - it('can specify optional typeMeta attribute when creating an index pattern', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; - const response = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - typeMeta: {}, - }, - }); - - expect(response.status).to.be(200); - }); - - it('can specify optional fieldFormats attribute when creating an index pattern', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; - const response = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - fieldFormats: { - foo: { - id: 'test-id', - params: {}, - }, - }, - }, - }); + }); - expect(response.status).to.be(200); - expect(response.body.index_pattern.fieldFormats.foo.id).to.be('test-id'); - expect(response.body.index_pattern.fieldFormats.foo.params).to.eql({}); - }); + expect(response.status).to.be(200); + expect(response.body[config.serviceKey].fieldFormats.foo.id).to.be('test-id'); + expect(response.body[config.serviceKey].fieldFormats.foo.params).to.eql({}); + }); - it('can specify optional fieldFormats attribute when creating an index pattern', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; - const response = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - fieldAttrs: { - foo: { - count: 123, - customLabel: 'test', + it('can specify optional fieldFormats attribute when creating an index pattern', async () => { + const title = `foo-${Date.now()}-${Math.random()}*`; + const response = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, + fieldAttrs: { + foo: { + count: 123, + customLabel: 'test', + }, + }, }, - }, - }, - }); + }); - expect(response.status).to.be(200); - expect(response.body.index_pattern.fieldAttrs.foo.count).to.be(123); - expect(response.body.index_pattern.fieldAttrs.foo.customLabel).to.be('test'); - }); - - describe('when creating index pattern with existing title', () => { - it('returns error, by default', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - }, + expect(response.status).to.be(200); + expect(response.body[config.serviceKey].fieldAttrs.foo.count).to.be(123); + expect(response.body[config.serviceKey].fieldAttrs.foo.customLabel).to.be('test'); }); - const response2 = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - }, - }); - - expect(response1.status).to.be(200); - expect(response2.status).to.be(400); - }); - it('succeeds, override flag is set', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - timeFieldName: 'foo', - }, - }); - const response2 = await supertest.post('/api/index_patterns/index_pattern').send({ - override: true, - index_pattern: { - title, - timeFieldName: 'bar', - }, - }); + describe('when creating index pattern with existing title', () => { + it('returns error, by default', async () => { + const title = `foo-${Date.now()}-${Math.random()}*`; + const response1 = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, + }, + }); + const response2 = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, + }, + }); + + expect(response1.status).to.be(200); + expect(response2.status).to.be(400); + }); + + it('succeeds, override flag is set', async () => { + const title = `foo-${Date.now()}-${Math.random()}*`; + const response1 = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, + timeFieldName: 'foo', + }, + }); + const response2 = await supertest.post(config.path).send({ + override: true, + [config.serviceKey]: { + title, + timeFieldName: 'bar', + }, + }); - expect(response1.status).to.be(200); - expect(response2.status).to.be(200); + expect(response1.status).to.be(200); + expect(response2.status).to.be(200); - expect(response1.body.index_pattern.timeFieldName).to.be('foo'); - expect(response2.body.index_pattern.timeFieldName).to.be('bar'); + expect(response1.body[config.serviceKey].timeFieldName).to.be('foo'); + expect(response2.body[config.serviceKey].timeFieldName).to.be('bar'); - expect(response1.body.index_pattern.id).to.be(response1.body.index_pattern.id); + expect(response1.body[config.serviceKey].id).to.be( + response1.body[config.serviceKey].id + ); + }); + }); }); }); }); diff --git a/test/api_integration/apis/index_patterns/index_pattern_crud/create_index_pattern/validation.ts b/test/api_integration/apis/index_patterns/index_pattern_crud/create_index_pattern/validation.ts index 598001644eedb..536e366655c24 100644 --- a/test/api_integration/apis/index_patterns/index_pattern_crud/create_index_pattern/validation.ts +++ b/test/api_integration/apis/index_patterns/index_pattern_crud/create_index_pattern/validation.ts @@ -8,81 +8,86 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { configArray } from '../../constants'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); describe('validation', () => { - it('returns error when index_pattern object is not provided', async () => { - const response = await supertest.post('/api/index_patterns/index_pattern'); + configArray.forEach((config) => { + describe(config.name, () => { + it('returns error when index_pattern object is not provided', async () => { + const response = await supertest.post(config.path); - expect(response.status).to.be(400); - expect(response.body.statusCode).to.be(400); - expect(response.body.message).to.be( - '[request body]: expected a plain object value, but found [null] instead.' - ); - }); + expect(response.status).to.be(400); + expect(response.body.statusCode).to.be(400); + expect(response.body.message).to.be( + '[request body]: expected a plain object value, but found [null] instead.' + ); + }); - it('returns error on empty index_pattern object', async () => { - const response = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: {}, - }); + it('returns error on empty index_pattern object', async () => { + const response = await supertest.post(config.path).send({ + [config.serviceKey]: {}, + }); - expect(response.status).to.be(400); - expect(response.body.statusCode).to.be(400); - expect(response.body.message).to.be( - '[request body.index_pattern.title]: expected value of type [string] but got [undefined]' - ); - }); + expect(response.status).to.be(400); + expect(response.body.statusCode).to.be(400); + expect(response.body.message).to.be( + `[request body.${config.serviceKey}.title]: expected value of type [string] but got [undefined]` + ); + }); - it('returns error when "override" parameter is not a boolean', async () => { - const response = await supertest.post('/api/index_patterns/index_pattern').send({ - override: 123, - index_pattern: { - title: 'foo', - }, - }); + it('returns error when "override" parameter is not a boolean', async () => { + const response = await supertest.post(config.path).send({ + override: 123, + [config.serviceKey]: { + title: 'foo', + }, + }); - expect(response.status).to.be(400); - expect(response.body.statusCode).to.be(400); - expect(response.body.message).to.be( - '[request body.override]: expected value of type [boolean] but got [number]' - ); - }); + expect(response.status).to.be(400); + expect(response.body.statusCode).to.be(400); + expect(response.body.message).to.be( + '[request body.override]: expected value of type [boolean] but got [number]' + ); + }); - it('returns error when "refresh_fields" parameter is not a boolean', async () => { - const response = await supertest.post('/api/index_patterns/index_pattern').send({ - refresh_fields: 123, - index_pattern: { - title: 'foo', - }, - }); + it('returns error when "refresh_fields" parameter is not a boolean', async () => { + const response = await supertest.post(config.path).send({ + refresh_fields: 123, + [config.serviceKey]: { + title: 'foo', + }, + }); - expect(response.status).to.be(400); - expect(response.body.statusCode).to.be(400); - expect(response.body.message).to.be( - '[request body.refresh_fields]: expected value of type [boolean] but got [number]' - ); - }); + expect(response.status).to.be(400); + expect(response.body.statusCode).to.be(400); + expect(response.body.message).to.be( + '[request body.refresh_fields]: expected value of type [boolean] but got [number]' + ); + }); - it('returns an error when unknown runtime field type', async () => { - const title = `basic_index*`; - const response = await supertest.post('/api/index_patterns/index_pattern').send({ - override: true, - index_pattern: { - title, - runtimeFieldMap: { - runtimeFoo: { - type: 'wrong-type', - script: { - source: 'emit(doc["foo"].value)', + it('returns an error when unknown runtime field type', async () => { + const title = `basic_index*`; + const response = await supertest.post(config.path).send({ + override: true, + [config.serviceKey]: { + title, + runtimeFieldMap: { + runtimeFoo: { + type: 'wrong-type', + script: { + source: 'emit(doc["foo"].value)', + }, + }, }, }, - }, - }, - }); + }); - expect(response.status).to.be(400); + expect(response.status).to.be(400); + }); + }); }); }); } diff --git a/test/api_integration/apis/index_patterns/index_pattern_crud/delete_index_pattern/errors.ts b/test/api_integration/apis/index_patterns/index_pattern_crud/delete_index_pattern/errors.ts index c746f0ce59359..5a7a12fc80201 100644 --- a/test/api_integration/apis/index_patterns/index_pattern_crud/delete_index_pattern/errors.ts +++ b/test/api_integration/apis/index_patterns/index_pattern_crud/delete_index_pattern/errors.ts @@ -8,26 +8,31 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { configArray } from '../../constants'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); describe('errors', () => { - it('returns 404 error on non-existing index_pattern', async () => { - const id = `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx-${Date.now()}`; - const response = await supertest.delete(`/api/index_patterns/index_pattern/${id}`); + configArray.forEach((config) => { + describe(config.name, () => { + it('returns 404 error on non-existing index_pattern', async () => { + const id = `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx-${Date.now()}`; + const response = await supertest.delete(`${config.path}/${id}`); - expect(response.status).to.be(404); - }); + expect(response.status).to.be(404); + }); - it('returns error when ID is too long', async () => { - const id = `xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx`; - const response = await supertest.delete(`/api/index_patterns/index_pattern/${id}`); + it('returns error when ID is too long', async () => { + const id = `xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx`; + const response = await supertest.delete(`${config.path}/${id}`); - expect(response.status).to.be(400); - expect(response.body.message).to.be( - '[request params.id]: value has length [1759] but it must have a maximum length of [1000].' - ); + expect(response.status).to.be(400); + expect(response.body.message).to.be( + '[request params.id]: value has length [1759] but it must have a maximum length of [1000].' + ); + }); + }); }); }); } diff --git a/test/api_integration/apis/index_patterns/index_pattern_crud/delete_index_pattern/main.ts b/test/api_integration/apis/index_patterns/index_pattern_crud/delete_index_pattern/main.ts index c47f9269e4b15..fa82d745937bf 100644 --- a/test/api_integration/apis/index_patterns/index_pattern_crud/delete_index_pattern/main.ts +++ b/test/api_integration/apis/index_patterns/index_pattern_crud/delete_index_pattern/main.ts @@ -8,50 +8,55 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { configArray } from '../../constants'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); describe('main', () => { - it('deletes an index_pattern', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - }, + configArray.forEach((config) => { + describe(config.name, () => { + it('deletes an index_pattern', async () => { + const title = `foo-${Date.now()}-${Math.random()}*`; + const response1 = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, + }, + }); + const response2 = await supertest.get( + `${config.path}/${response1.body[config.serviceKey].id}` + ); + + expect(response2.status).to.be(200); + + const response3 = await supertest.delete( + `${config.path}/${response1.body[config.serviceKey].id}` + ); + + expect(response3.status).to.be(200); + + const response4 = await supertest.get( + `${config.path}/${response1.body[config.serviceKey].id}` + ); + + expect(response4.status).to.be(404); + }); + + it('returns nothing', async () => { + const title = `foo-${Date.now()}-${Math.random()}*`; + const response1 = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, + }, + }); + await supertest.get(`${config.path}/${response1.body[config.serviceKey].id}`); + const response2 = await supertest.delete( + `${config.path}/${response1.body[config.serviceKey].id}` + ); + + expect(!!response2.body).to.be(false); + }); }); - const response2 = await supertest.get( - '/api/index_patterns/index_pattern/' + response1.body.index_pattern.id - ); - - expect(response2.status).to.be(200); - - const response3 = await supertest.delete( - '/api/index_patterns/index_pattern/' + response1.body.index_pattern.id - ); - - expect(response3.status).to.be(200); - - const response4 = await supertest.get( - '/api/index_patterns/index_pattern/' + response1.body.index_pattern.id - ); - - expect(response4.status).to.be(404); - }); - - it('returns nothing', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - }, - }); - await supertest.get('/api/index_patterns/index_pattern/' + response1.body.index_pattern.id); - const response2 = await supertest.delete( - '/api/index_patterns/index_pattern/' + response1.body.index_pattern.id - ); - - expect(!!response2.body).to.be(false); }); }); } diff --git a/test/api_integration/apis/index_patterns/index_pattern_crud/get_index_pattern/errors.ts b/test/api_integration/apis/index_patterns/index_pattern_crud/get_index_pattern/errors.ts index 4c6cd783ca6f0..70c39288f0461 100644 --- a/test/api_integration/apis/index_patterns/index_pattern_crud/get_index_pattern/errors.ts +++ b/test/api_integration/apis/index_patterns/index_pattern_crud/get_index_pattern/errors.ts @@ -8,26 +8,31 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { configArray } from '../../constants'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); describe('errors', () => { - it('returns 404 error on non-existing index_pattern', async () => { - const id = `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx-${Date.now()}`; - const response = await supertest.get(`/api/index_patterns/index_pattern/${id}`); + configArray.forEach((config) => { + describe(config.name, () => { + it('returns 404 error on non-existing index_pattern', async () => { + const id = `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx-${Date.now()}`; + const response = await supertest.get(`${config.path}/${id}`); - expect(response.status).to.be(404); - }); + expect(response.status).to.be(404); + }); - it('returns error when ID is too long', async () => { - const id = `xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx`; - const response = await supertest.get(`/api/index_patterns/index_pattern/${id}`); + it('returns error when ID is too long', async () => { + const id = `xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx`; + const response = await supertest.get(`${config.path}/${id}`); - expect(response.status).to.be(400); - expect(response.body.message).to.be( - '[request params.id]: value has length [1759] but it must have a maximum length of [1000].' - ); + expect(response.status).to.be(400); + expect(response.body.message).to.be( + '[request params.id]: value has length [1759] but it must have a maximum length of [1000].' + ); + }); + }); }); }); } diff --git a/test/api_integration/apis/index_patterns/index_pattern_crud/get_index_pattern/main.ts b/test/api_integration/apis/index_patterns/index_pattern_crud/get_index_pattern/main.ts index 9d4ee4b740703..92d4f613d1247 100644 --- a/test/api_integration/apis/index_patterns/index_pattern_crud/get_index_pattern/main.ts +++ b/test/api_integration/apis/index_patterns/index_pattern_crud/get_index_pattern/main.ts @@ -8,23 +8,28 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { configArray } from '../../constants'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); describe('main', () => { - it('can retrieve an index_pattern', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - }, - }); - const response2 = await supertest.get( - '/api/index_patterns/index_pattern/' + response1.body.index_pattern.id - ); + configArray.forEach((config) => { + describe(config.name, () => { + it('can retrieve an index_pattern', async () => { + const title = `foo-${Date.now()}-${Math.random()}*`; + const response1 = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, + }, + }); + const response2 = await supertest.get( + `${config.path}/${response1.body[config.serviceKey].id}` + ); - expect(response2.body.index_pattern.title).to.be(title); + expect(response2.body[config.serviceKey].title).to.be(title); + }); + }); }); }); } diff --git a/test/api_integration/apis/index_patterns/index_pattern_crud/update_index_pattern/errors.ts b/test/api_integration/apis/index_patterns/index_pattern_crud/update_index_pattern/errors.ts index d8cfe902a5d10..54f61cba1cfbe 100644 --- a/test/api_integration/apis/index_patterns/index_pattern_crud/update_index_pattern/errors.ts +++ b/test/api_integration/apis/index_patterns/index_pattern_crud/update_index_pattern/errors.ts @@ -8,65 +8,68 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { configArray } from '../../constants'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); describe('errors', () => { - it('returns error when index_pattern object is not provided', async () => { - const response = await supertest.post('/api/index_patterns/index_pattern/foo'); + configArray.forEach((config) => { + describe(config.name, () => { + it('returns error when index_pattern object is not provided', async () => { + const response = await supertest.post(`${config.path}/foo`); - expect(response.status).to.be(400); - expect(response.body.statusCode).to.be(400); - expect(response.body.message).to.be( - '[request body]: expected a plain object value, but found [null] instead.' - ); - }); + expect(response.status).to.be(400); + expect(response.body.statusCode).to.be(400); + expect(response.body.message).to.be( + '[request body]: expected a plain object value, but found [null] instead.' + ); + }); + + it('returns error on non-existing index_pattern', async () => { + const response = await supertest.post(`${config.path}/non-existing-index-pattern`).send({ + [config.serviceKey]: {}, + }); - it('returns error on non-existing index_pattern', async () => { - const response = await supertest - .post('/api/index_patterns/index_pattern/non-existing-index-pattern') - .send({ - index_pattern: {}, + expect(response.status).to.be(404); + expect(response.body.statusCode).to.be(404); + expect(response.body.message).to.be( + 'Saved object [index-pattern/non-existing-index-pattern] not found' + ); }); - expect(response.status).to.be(404); - expect(response.body.statusCode).to.be(404); - expect(response.body.message).to.be( - 'Saved object [index-pattern/non-existing-index-pattern] not found' - ); - }); + it('returns error when "refresh_fields" parameter is not a boolean', async () => { + const response = await supertest.post(`${config.path}/foo`).send({ + refresh_fields: 123, + [config.serviceKey]: { + title: 'foo', + }, + }); - it('returns error when "refresh_fields" parameter is not a boolean', async () => { - const response = await supertest.post('/api/index_patterns/index_pattern/foo`').send({ - refresh_fields: 123, - index_pattern: { - title: 'foo', - }, - }); + expect(response.status).to.be(400); + expect(response.body.statusCode).to.be(400); + expect(response.body.message).to.be( + '[request body.refresh_fields]: expected value of type [boolean] but got [number]' + ); + }); - expect(response.status).to.be(400); - expect(response.body.statusCode).to.be(400); - expect(response.body.message).to.be( - '[request body.refresh_fields]: expected value of type [boolean] but got [number]' - ); - }); + it('returns error when update patch is empty', async () => { + const title1 = `foo-${Date.now()}-${Math.random()}*`; + const response = await supertest.post(config.path).send({ + [config.serviceKey]: { + title: title1, + }, + }); + const id = response.body[config.serviceKey].id; + const response2 = await supertest.post(`${config.path}/${id}`).send({ + [config.serviceKey]: {}, + }); - it('returns error when update patch is empty', async () => { - const title1 = `foo-${Date.now()}-${Math.random()}*`; - const response = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title: title1, - }, - }); - const id = response.body.index_pattern.id; - const response2 = await supertest.post('/api/index_patterns/index_pattern/' + id).send({ - index_pattern: {}, + expect(response2.status).to.be(400); + expect(response2.body.statusCode).to.be(400); + expect(response2.body.message).to.be('Index pattern change set is empty.'); + }); }); - - expect(response2.status).to.be(400); - expect(response2.body.statusCode).to.be(400); - expect(response2.body.message).to.be('Index pattern change set is empty.'); }); }); } diff --git a/test/api_integration/apis/index_patterns/index_pattern_crud/update_index_pattern/main.ts b/test/api_integration/apis/index_patterns/index_pattern_crud/update_index_pattern/main.ts index 94ca63956e1f2..7548d09580f45 100644 --- a/test/api_integration/apis/index_patterns/index_pattern_crud/update_index_pattern/main.ts +++ b/test/api_integration/apis/index_patterns/index_pattern_crud/update_index_pattern/main.ts @@ -8,301 +8,312 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { configArray } from '../../constants'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); describe('main', () => { - it('can update index_pattern title', async () => { - const title1 = `foo-${Date.now()}-${Math.random()}*`; - const title2 = `bar-${Date.now()}-${Math.random()}*`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title: title1, - }, - }); + configArray.forEach((config) => { + describe(config.name, () => { + it('can update index_pattern title', async () => { + const title1 = `foo-${Date.now()}-${Math.random()}*`; + const title2 = `bar-${Date.now()}-${Math.random()}*`; + const response1 = await supertest.post(config.path).send({ + [config.serviceKey]: { + title: title1, + }, + }); - expect(response1.body.index_pattern.title).to.be(title1); + expect(response1.body[config.serviceKey].title).to.be(title1); - const id = response1.body.index_pattern.id; - const response2 = await supertest.post('/api/index_patterns/index_pattern/' + id).send({ - index_pattern: { - title: title2, - }, - }); + const id = response1.body[config.serviceKey].id; + const response2 = await supertest.post(`${config.path}/${id}`).send({ + [config.serviceKey]: { + title: title2, + }, + }); - expect(response2.body.index_pattern.title).to.be(title2); + expect(response2.body[config.serviceKey].title).to.be(title2); - const response3 = await supertest.get('/api/index_patterns/index_pattern/' + id); + const response3 = await supertest.get(`${config.path}/${id}`); - expect(response3.body.index_pattern.title).to.be(title2); - }); + expect(response3.body[config.serviceKey].title).to.be(title2); + }); - it('can update index_pattern timeFieldName', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - timeFieldName: 'timeFieldName1', - }, - }); + it('can update index_pattern timeFieldName', async () => { + const title = `foo-${Date.now()}-${Math.random()}*`; + const response1 = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, + timeFieldName: 'timeFieldName1', + }, + }); - expect(response1.body.index_pattern.timeFieldName).to.be('timeFieldName1'); + expect(response1.body[config.serviceKey].timeFieldName).to.be('timeFieldName1'); - const id = response1.body.index_pattern.id; - const response2 = await supertest.post('/api/index_patterns/index_pattern/' + id).send({ - index_pattern: { - timeFieldName: 'timeFieldName2', - }, - }); + const id = response1.body[config.serviceKey].id; + const response2 = await supertest.post(`${config.path}/${id}`).send({ + [config.serviceKey]: { + timeFieldName: 'timeFieldName2', + }, + }); - expect(response2.body.index_pattern.timeFieldName).to.be('timeFieldName2'); + expect(response2.body[config.serviceKey].timeFieldName).to.be('timeFieldName2'); - const response3 = await supertest.get('/api/index_patterns/index_pattern/' + id); + const response3 = await supertest.get(`${config.path}/${id}`); - expect(response3.body.index_pattern.timeFieldName).to.be('timeFieldName2'); - }); + expect(response3.body[config.serviceKey].timeFieldName).to.be('timeFieldName2'); + }); + + it('can update index_pattern sourceFilters', async () => { + const title = `foo-${Date.now()}-${Math.random()}*`; + const response1 = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, + sourceFilters: [ + { + value: 'foo', + }, + ], + }, + }); - it('can update index_pattern sourceFilters', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - sourceFilters: [ + expect(response1.body[config.serviceKey].sourceFilters).to.eql([ { value: 'foo', }, - ], - }, - }); - - expect(response1.body.index_pattern.sourceFilters).to.eql([ - { - value: 'foo', - }, - ]); + ]); + + const id = response1.body[config.serviceKey].id; + const response2 = await supertest.post(`${config.path}/${id}`).send({ + [config.serviceKey]: { + sourceFilters: [ + { + value: 'bar', + }, + { + value: 'baz', + }, + ], + }, + }); - const id = response1.body.index_pattern.id; - const response2 = await supertest.post('/api/index_patterns/index_pattern/' + id).send({ - index_pattern: { - sourceFilters: [ + expect(response2.body[config.serviceKey].sourceFilters).to.eql([ { value: 'bar', }, { value: 'baz', }, - ], - }, - }); + ]); - expect(response2.body.index_pattern.sourceFilters).to.eql([ - { - value: 'bar', - }, - { - value: 'baz', - }, - ]); - - const response3 = await supertest.get('/api/index_patterns/index_pattern/' + id); - - expect(response3.body.index_pattern.sourceFilters).to.eql([ - { - value: 'bar', - }, - { - value: 'baz', - }, - ]); - }); + const response3 = await supertest.get(`${config.path}/${id}`); - it('can update index_pattern fieldFormats', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - fieldFormats: { + expect(response3.body[config.serviceKey].sourceFilters).to.eql([ + { + value: 'bar', + }, + { + value: 'baz', + }, + ]); + }); + + it('can update index_pattern fieldFormats', async () => { + const title = `foo-${Date.now()}-${Math.random()}*`; + const response1 = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, + fieldFormats: { + foo: { + id: 'foo', + params: { + bar: 'baz', + }, + }, + }, + }, + }); + + expect(response1.body[config.serviceKey].fieldFormats).to.eql({ foo: { id: 'foo', params: { bar: 'baz', }, }, - }, - }, - }); - - expect(response1.body.index_pattern.fieldFormats).to.eql({ - foo: { - id: 'foo', - params: { - bar: 'baz', - }, - }, - }); + }); + + const id = response1.body[config.serviceKey].id; + const response2 = await supertest.post(`${config.path}/${id}`).send({ + [config.serviceKey]: { + fieldFormats: { + a: { + id: 'a', + params: { + b: 'v', + }, + }, + }, + }, + }); - const id = response1.body.index_pattern.id; - const response2 = await supertest.post('/api/index_patterns/index_pattern/' + id).send({ - index_pattern: { - fieldFormats: { + expect(response2.body[config.serviceKey].fieldFormats).to.eql({ a: { id: 'a', params: { b: 'v', }, }, - }, - }, - }); - - expect(response2.body.index_pattern.fieldFormats).to.eql({ - a: { - id: 'a', - params: { - b: 'v', - }, - }, - }); - - const response3 = await supertest.get('/api/index_patterns/index_pattern/' + id); - - expect(response3.body.index_pattern.fieldFormats).to.eql({ - a: { - id: 'a', - params: { - b: 'v', - }, - }, - }); - }); + }); - it('can update index_pattern type', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - type: 'foo', - }, - }); - - expect(response1.body.index_pattern.type).to.be('foo'); - - const id = response1.body.index_pattern.id; - const response2 = await supertest.post('/api/index_patterns/index_pattern/' + id).send({ - index_pattern: { - type: 'bar', - }, - }); + const response3 = await supertest.get(`${config.path}/${id}`); - expect(response2.body.index_pattern.type).to.be('bar'); + expect(response3.body[config.serviceKey].fieldFormats).to.eql({ + a: { + id: 'a', + params: { + b: 'v', + }, + }, + }); + }); + + it('can update index_pattern type', async () => { + const title = `foo-${Date.now()}-${Math.random()}*`; + const response1 = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, + type: 'foo', + }, + }); - const response3 = await supertest.get('/api/index_patterns/index_pattern/' + id); + expect(response1.body[config.serviceKey].type).to.be('foo'); - expect(response3.body.index_pattern.type).to.be('bar'); - }); - - it('can update index_pattern typeMeta', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - typeMeta: { foo: 'bar' }, - }, - }); + const id = response1.body[config.serviceKey].id; + const response2 = await supertest.post(`${config.path}/${id}`).send({ + [config.serviceKey]: { + type: 'bar', + }, + }); - expect(response1.body.index_pattern.typeMeta).to.eql({ foo: 'bar' }); + expect(response2.body[config.serviceKey].type).to.be('bar'); - const id = response1.body.index_pattern.id; - const response2 = await supertest.post('/api/index_patterns/index_pattern/' + id).send({ - index_pattern: { - typeMeta: { foo: 'baz' }, - }, - }); + const response3 = await supertest.get(`${config.path}/${id}`); - expect(response2.body.index_pattern.typeMeta).to.eql({ foo: 'baz' }); + expect(response3.body[config.serviceKey].type).to.be('bar'); + }); - const response3 = await supertest.get('/api/index_patterns/index_pattern/' + id); + it('can update index_pattern typeMeta', async () => { + const title = `foo-${Date.now()}-${Math.random()}*`; + const response1 = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, + typeMeta: { foo: 'bar' }, + }, + }); - expect(response3.body.index_pattern.typeMeta).to.eql({ foo: 'baz' }); - }); + expect(response1.body[config.serviceKey].typeMeta).to.eql({ foo: 'bar' }); - it('can update multiple index pattern fields at once', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - timeFieldName: 'timeFieldName1', - typeMeta: { foo: 'bar' }, - }, - }); + const id = response1.body[config.serviceKey].id; + const response2 = await supertest.post(`${config.path}/${id}`).send({ + [config.serviceKey]: { + typeMeta: { foo: 'baz' }, + }, + }); - expect(response1.body.index_pattern.timeFieldName).to.be('timeFieldName1'); - expect(response1.body.index_pattern.typeMeta.foo).to.be('bar'); + expect(response2.body[config.serviceKey].typeMeta).to.eql({ foo: 'baz' }); - const id = response1.body.index_pattern.id; - const response2 = await supertest.post('/api/index_patterns/index_pattern/' + id).send({ - index_pattern: { - timeFieldName: 'timeFieldName2', - typeMeta: { baz: 'qux' }, - }, - }); + const response3 = await supertest.get(`${config.path}/${id}`); - expect(response2.body.index_pattern.timeFieldName).to.be('timeFieldName2'); - expect(response2.body.index_pattern.typeMeta.baz).to.be('qux'); + expect(response3.body[config.serviceKey].typeMeta).to.eql({ foo: 'baz' }); + }); - const response3 = await supertest.get('/api/index_patterns/index_pattern/' + id); + it('can update multiple index pattern fields at once', async () => { + const title = `foo-${Date.now()}-${Math.random()}*`; + const response1 = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, + timeFieldName: 'timeFieldName1', + typeMeta: { foo: 'bar' }, + }, + }); - expect(response3.body.index_pattern.timeFieldName).to.be('timeFieldName2'); - expect(response3.body.index_pattern.typeMeta.baz).to.be('qux'); - }); + expect(response1.body[config.serviceKey].timeFieldName).to.be('timeFieldName1'); + expect(response1.body[config.serviceKey].typeMeta.foo).to.be('bar'); - it('can update runtime fields', async () => { - const title = `basic_index*`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - override: true, - index_pattern: { - title, - runtimeFieldMap: { - runtimeFoo: { - type: 'keyword', - script: { - source: 'emit(doc["foo"].value)', + const id = response1.body[config.serviceKey].id; + const response2 = await supertest.post(`${config.path}/${id}`).send({ + [config.serviceKey]: { + timeFieldName: 'timeFieldName2', + typeMeta: { baz: 'qux' }, + }, + }); + + expect(response2.body[config.serviceKey].timeFieldName).to.be('timeFieldName2'); + expect(response2.body[config.serviceKey].typeMeta.baz).to.be('qux'); + + const response3 = await supertest.get(`${config.path}/${id}`); + + expect(response3.body[config.serviceKey].timeFieldName).to.be('timeFieldName2'); + expect(response3.body[config.serviceKey].typeMeta.baz).to.be('qux'); + }); + + it('can update runtime fields', async () => { + const title = `basic_index*`; + const response1 = await supertest.post(config.path).send({ + override: true, + [config.serviceKey]: { + title, + runtimeFieldMap: { + runtimeFoo: { + type: 'keyword', + script: { + source: 'emit(doc["foo"].value)', + }, + }, }, }, - }, - }, - }); - - expect(response1.status).to.be(200); - expect(response1.body.index_pattern.title).to.be(title); - - expect(response1.body.index_pattern.runtimeFieldMap.runtimeFoo.type).to.be('keyword'); - expect(response1.body.index_pattern.runtimeFieldMap.runtimeFoo.script.source).to.be( - 'emit(doc["foo"].value)' - ); - - const id = response1.body.index_pattern.id; - const response2 = await supertest.post('/api/index_patterns/index_pattern/' + id).send({ - index_pattern: { - runtimeFieldMap: { - runtimeBar: { - type: 'keyword', - script: { - source: 'emit(doc["foo"].value)', + }); + + expect(response1.status).to.be(200); + expect(response1.body[config.serviceKey].title).to.be(title); + + expect(response1.body[config.serviceKey].runtimeFieldMap.runtimeFoo.type).to.be( + 'keyword' + ); + expect(response1.body[config.serviceKey].runtimeFieldMap.runtimeFoo.script.source).to.be( + 'emit(doc["foo"].value)' + ); + + const id = response1.body[config.serviceKey].id; + const response2 = await supertest.post(`${config.path}/${id}`).send({ + [config.serviceKey]: { + runtimeFieldMap: { + runtimeBar: { + type: 'keyword', + script: { + source: 'emit(doc["foo"].value)', + }, + }, }, }, - }, - }, - }); + }); - expect(response2.body.index_pattern.runtimeFieldMap.runtimeBar.type).to.be('keyword'); - expect(response2.body.index_pattern.runtimeFieldMap.runtimeFoo).to.be(undefined); + expect(response2.body[config.serviceKey].runtimeFieldMap.runtimeBar.type).to.be( + 'keyword' + ); + expect(response2.body[config.serviceKey].runtimeFieldMap.runtimeFoo).to.be(undefined); - const response3 = await supertest.get('/api/index_patterns/index_pattern/' + id); + const response3 = await supertest.get(`${config.path}/${id}`); - expect(response3.body.index_pattern.runtimeFieldMap.runtimeBar.type).to.be('keyword'); - expect(response3.body.index_pattern.runtimeFieldMap.runtimeFoo).to.be(undefined); + expect(response3.body[config.serviceKey].runtimeFieldMap.runtimeBar.type).to.be( + 'keyword' + ); + expect(response3.body[config.serviceKey].runtimeFieldMap.runtimeFoo).to.be(undefined); + }); + }); }); }); } diff --git a/test/api_integration/apis/index_patterns/runtime_fields_crud/create_runtime_field/errors.ts b/test/api_integration/apis/index_patterns/runtime_fields_crud/create_runtime_field/errors.ts index 8ce9e3b36b5c8..3d53588e2fb37 100644 --- a/test/api_integration/apis/index_patterns/runtime_fields_crud/create_runtime_field/errors.ts +++ b/test/api_integration/apis/index_patterns/runtime_fields_crud/create_runtime_field/errors.ts @@ -8,28 +8,31 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { configArray } from '../../constants'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); describe('errors', () => { - it('returns an error field object is not provided', async () => { - const title = `foo-${Date.now()}-${Math.random()}*`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title, - }, - }); - const id = response1.body.index_pattern.id; - const response2 = await supertest - .post(`/api/index_patterns/index_pattern/${id}/runtime_field`) - .send({}); + configArray.forEach((config) => { + describe(config.name, () => { + it('returns an error field object is not provided', async () => { + const title = `foo-${Date.now()}-${Math.random()}*`; + const response1 = await supertest.post(config.path).send({ + [config.serviceKey]: { + title, + }, + }); + const id = response1.body[config.serviceKey].id; + const response2 = await supertest.post(`${config.path}/${id}/runtime_field`).send({}); - expect(response2.status).to.be(400); - expect(response2.body.statusCode).to.be(400); - expect(response2.body.message).to.be( - '[request body.name]: expected value of type [string] but got [undefined]' - ); + expect(response2.status).to.be(400); + expect(response2.body.statusCode).to.be(400); + expect(response2.body.message).to.be( + '[request body.name]: expected value of type [string] but got [undefined]' + ); + }); + }); }); }); } diff --git a/test/api_integration/apis/index_patterns/runtime_fields_crud/create_runtime_field/main.ts b/test/api_integration/apis/index_patterns/runtime_fields_crud/create_runtime_field/main.ts index e262b9d838e97..f9dd5bd7105b4 100644 --- a/test/api_integration/apis/index_patterns/runtime_fields_crud/create_runtime_field/main.ts +++ b/test/api_integration/apis/index_patterns/runtime_fields_crud/create_runtime_field/main.ts @@ -8,6 +8,7 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { configArray } from '../../constants'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); @@ -24,72 +25,73 @@ export default function ({ getService }: FtrProviderContext) { ); }); - it('can create a new runtime field', async () => { - const title = `basic_index*`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - override: true, - index_pattern: { - title, - }, - }); - const id = response1.body.index_pattern.id; - const response2 = await supertest - .post(`/api/index_patterns/index_pattern/${id}/runtime_field`) - .send({ - name: 'runtimeBar', - runtimeField: { - type: 'long', - script: { - source: "emit(doc['field_name'].value)", + configArray.forEach((config) => { + describe(config.name, () => { + it('can create a new runtime field', async () => { + const title = `basic_index*`; + const response1 = await supertest.post(config.path).send({ + override: true, + [config.serviceKey]: { + title, }, - }, - }); + }); + const id = response1.body[config.serviceKey].id; + const response2 = await supertest.post(`${config.path}/${id}/runtime_field`).send({ + name: 'runtimeBar', + runtimeField: { + type: 'long', + script: { + source: "emit(doc['field_name'].value)", + }, + }, + }); - expect(response2.status).to.be(200); - expect(response2.body.field.name).to.be('runtimeBar'); - expect(response2.body.field.runtimeField.type).to.be('long'); - expect(response2.body.field.runtimeField.script.source).to.be( - "emit(doc['field_name'].value)" - ); - expect(response2.body.field.scripted).to.be(false); - }); + expect(response2.status).to.be(200); + const field = + config.serviceKey === 'index_pattern' ? response2.body.field : response2.body.fields[0]; - it('newly created runtime field is available in the index_pattern object', async () => { - const title = `basic_index`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - override: true, - index_pattern: { - title, - }, - }); + expect(field.name).to.be('runtimeBar'); + expect(field.runtimeField.type).to.be('long'); + expect(field.runtimeField.script.source).to.be("emit(doc['field_name'].value)"); + expect(field.scripted).to.be(false); + }); - await supertest - .post(`/api/index_patterns/index_pattern/${response1.body.index_pattern.id}/runtime_field`) - .send({ - name: 'runtimeBar', - runtimeField: { - type: 'long', - script: { - source: "emit(doc['field_name'].value)", + it('newly created runtime field is available in the index_pattern object', async () => { + const title = `basic_index`; + const response1 = await supertest.post(config.path).send({ + override: true, + [config.serviceKey]: { + title, }, - }, - }); + }); - const response2 = await supertest.get( - '/api/index_patterns/index_pattern/' + response1.body.index_pattern.id - ); + await supertest + .post(`${config.path}/${response1.body[config.serviceKey].id}/runtime_field`) + .send({ + name: 'runtimeBar', + runtimeField: { + type: 'long', + script: { + source: "emit(doc['field_name'].value)", + }, + }, + }); - expect(response2.status).to.be(200); + const response2 = await supertest.get( + `${config.path}/${response1.body[config.serviceKey].id}` + ); - const field = response2.body.index_pattern.fields.runtimeBar; + expect(response2.status).to.be(200); - expect(field.name).to.be('runtimeBar'); - expect(field.runtimeField.type).to.be('long'); - expect(field.runtimeField.script.source).to.be("emit(doc['field_name'].value)"); - expect(field.scripted).to.be(false); - await supertest.delete( - '/api/index_patterns/index_pattern/' + response1.body.index_pattern.id - ); + const field = response2.body[config.serviceKey].fields.runtimeBar; + + expect(field.name).to.be('runtimeBar'); + expect(field.runtimeField.type).to.be('long'); + expect(field.runtimeField.script.source).to.be("emit(doc['field_name'].value)"); + expect(field.scripted).to.be(false); + await supertest.delete(`${config.path}/${response1.body[config.serviceKey].id}`); + }); + }); }); }); } diff --git a/test/api_integration/apis/index_patterns/runtime_fields_crud/delete_runtime_field/errors.ts b/test/api_integration/apis/index_patterns/runtime_fields_crud/delete_runtime_field/errors.ts index b41a630889ff8..54c982ec7f325 100644 --- a/test/api_integration/apis/index_patterns/runtime_fields_crud/delete_runtime_field/errors.ts +++ b/test/api_integration/apis/index_patterns/runtime_fields_crud/delete_runtime_field/errors.ts @@ -8,6 +8,7 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { configArray } from '../../constants'; export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); @@ -17,65 +18,67 @@ export default function ({ getService }: FtrProviderContext) { const basicIndex = 'b*sic_index'; let indexPattern: any; - before(async () => { - await esArchiver.load('test/api_integration/fixtures/es_archiver/index_patterns/basic_index'); + configArray.forEach((config) => { + describe(config.name, () => { + before(async () => { + await esArchiver.load( + 'test/api_integration/fixtures/es_archiver/index_patterns/basic_index' + ); - indexPattern = ( - await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title: basicIndex, - }, - }) - ).body.index_pattern; - }); + indexPattern = ( + await supertest.post(config.path).send({ + [config.serviceKey]: { + title: basicIndex, + }, + }) + ).body[config.serviceKey]; + }); - after(async () => { - await esArchiver.unload( - 'test/api_integration/fixtures/es_archiver/index_patterns/basic_index' - ); + after(async () => { + await esArchiver.unload( + 'test/api_integration/fixtures/es_archiver/index_patterns/basic_index' + ); - if (indexPattern) { - await supertest.delete('/api/index_patterns/index_pattern/' + indexPattern.id); - } - }); + if (indexPattern) { + await supertest.delete(`${config.path}/${indexPattern.id}`); + } + }); - it('returns 404 error on non-existing index_pattern', async () => { - const id = `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx-${Date.now()}`; - const response = await supertest.delete( - `/api/index_patterns/index_pattern/${id}/runtime_field/foo` - ); + it('returns 404 error on non-existing index_pattern', async () => { + const id = `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx-${Date.now()}`; + const response = await supertest.delete(`${config.path}/${id}/runtime_field/foo`); - expect(response.status).to.be(404); - }); + expect(response.status).to.be(404); + }); - it('returns 404 error on non-existing runtime field', async () => { - const response1 = await supertest.delete( - `/api/index_patterns/index_pattern/${indexPattern.id}/runtime_field/test` - ); + it('returns 404 error on non-existing runtime field', async () => { + const response1 = await supertest.delete( + `${config.path}/${indexPattern.id}/runtime_field/test` + ); - expect(response1.status).to.be(404); - }); + expect(response1.status).to.be(404); + }); - it('returns error when attempting to delete a field which is not a runtime field', async () => { - const response2 = await supertest.delete( - `/api/index_patterns/index_pattern/${indexPattern.id}/runtime_field/foo` - ); + it('returns error when attempting to delete a field which is not a runtime field', async () => { + const response2 = await supertest.delete( + `${config.path}/${indexPattern.id}/runtime_field/foo` + ); - expect(response2.status).to.be(400); - expect(response2.body.statusCode).to.be(400); - expect(response2.body.message).to.be('Only runtime fields can be deleted.'); - }); + expect(response2.status).to.be(400); + expect(response2.body.statusCode).to.be(400); + expect(response2.body.message).to.be('Only runtime fields can be deleted.'); + }); - it('returns error when ID is too long', async () => { - const id = `xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx`; - const response = await supertest.delete( - `/api/index_patterns/index_pattern/${id}/runtime_field/foo` - ); + it('returns error when ID is too long', async () => { + const id = `xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx`; + const response = await supertest.delete(`${config.path}/${id}/runtime_field/foo`); - expect(response.status).to.be(400); - expect(response.body.message).to.be( - '[request params.id]: value has length [1759] but it must have a maximum length of [1000].' - ); + expect(response.status).to.be(400); + expect(response.body.message).to.be( + '[request params.id]: value has length [1759] but it must have a maximum length of [1000].' + ); + }); + }); }); }); } diff --git a/test/api_integration/apis/index_patterns/runtime_fields_crud/delete_runtime_field/main.ts b/test/api_integration/apis/index_patterns/runtime_fields_crud/delete_runtime_field/main.ts index 3c74aa336e440..7a66e6a613db5 100644 --- a/test/api_integration/apis/index_patterns/runtime_fields_crud/delete_runtime_field/main.ts +++ b/test/api_integration/apis/index_patterns/runtime_fields_crud/delete_runtime_field/main.ts @@ -8,6 +8,7 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { configArray } from '../../constants'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); @@ -24,43 +25,45 @@ export default function ({ getService }: FtrProviderContext) { ); }); - it('can delete a runtime field', async () => { - const title = `basic_index*`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - override: true, - index_pattern: { - title, - runtimeFieldMap: { - runtimeBar: { - type: 'long', - script: { - source: "emit(doc['field_name'].value)", + configArray.forEach((config) => { + describe(config.name, () => { + it('can delete a runtime field', async () => { + const title = `basic_index*`; + const response1 = await supertest.post(config.path).send({ + override: true, + [config.serviceKey]: { + title, + runtimeFieldMap: { + runtimeBar: { + type: 'long', + script: { + source: "emit(doc['field_name'].value)", + }, + }, }, }, - }, - }, - }); + }); - const response2 = await supertest.get( - '/api/index_patterns/index_pattern/' + response1.body.index_pattern.id - ); + const response2 = await supertest.get( + `${config.path}/${response1.body[config.serviceKey].id}` + ); - expect(typeof response2.body.index_pattern.fields.runtimeBar).to.be('object'); + expect(typeof response2.body[config.serviceKey].fields.runtimeBar).to.be('object'); - const response3 = await supertest.delete( - `/api/index_patterns/index_pattern/${response1.body.index_pattern.id}/runtime_field/runtimeBar` - ); + const response3 = await supertest.delete( + `${config.path}/${response1.body[config.serviceKey].id}/runtime_field/runtimeBar` + ); - expect(response3.status).to.be(200); + expect(response3.status).to.be(200); - const response4 = await supertest.get( - '/api/index_patterns/index_pattern/' + response1.body.index_pattern.id - ); + const response4 = await supertest.get( + `${config.path}/${response1.body[config.serviceKey].id}` + ); - expect(typeof response4.body.index_pattern.fields.runtimeBar).to.be('undefined'); - await supertest.delete( - '/api/index_patterns/index_pattern/' + response1.body.index_pattern.id - ); + expect(typeof response4.body[config.serviceKey].fields.runtimeBar).to.be('undefined'); + await supertest.delete(`${config.path}/${response1.body[config.serviceKey].id}`); + }); + }); }); }); } diff --git a/test/api_integration/apis/index_patterns/runtime_fields_crud/get_runtime_field/errors.ts b/test/api_integration/apis/index_patterns/runtime_fields_crud/get_runtime_field/errors.ts index 3608089e4641a..b6bebb224b33f 100644 --- a/test/api_integration/apis/index_patterns/runtime_fields_crud/get_runtime_field/errors.ts +++ b/test/api_integration/apis/index_patterns/runtime_fields_crud/get_runtime_field/errors.ts @@ -8,6 +8,7 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { configArray } from '../../constants'; export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); @@ -17,65 +18,67 @@ export default function ({ getService }: FtrProviderContext) { const basicIndex = '*asic_index'; let indexPattern: any; - before(async () => { - await esArchiver.load('test/api_integration/fixtures/es_archiver/index_patterns/basic_index'); + configArray.forEach((config) => { + describe(config.name, () => { + before(async () => { + await esArchiver.load( + 'test/api_integration/fixtures/es_archiver/index_patterns/basic_index' + ); - indexPattern = ( - await supertest.post('/api/index_patterns/index_pattern').send({ - index_pattern: { - title: basicIndex, - }, - }) - ).body.index_pattern; - }); + indexPattern = ( + await supertest.post(config.path).send({ + [config.serviceKey]: { + title: basicIndex, + }, + }) + ).body[config.serviceKey]; + }); - after(async () => { - await esArchiver.unload( - 'test/api_integration/fixtures/es_archiver/index_patterns/basic_index' - ); + after(async () => { + await esArchiver.unload( + 'test/api_integration/fixtures/es_archiver/index_patterns/basic_index' + ); - if (indexPattern) { - await supertest.delete('/api/index_patterns/index_pattern/' + indexPattern.id); - } - }); + if (indexPattern) { + await supertest.delete(`${config.path}/${indexPattern.id}`); + } + }); - it('returns 404 error on non-existing index_pattern', async () => { - const id = `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx-${Date.now()}`; - const response = await supertest.get( - `/api/index_patterns/index_pattern/${id}/runtime_field/foo` - ); + it('returns 404 error on non-existing index_pattern', async () => { + const id = `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx-${Date.now()}`; + const response = await supertest.get(`${config.path}/${id}/runtime_field/foo`); - expect(response.status).to.be(404); - }); + expect(response.status).to.be(404); + }); - it('returns 404 error on non-existing runtime field', async () => { - const response1 = await supertest.get( - `/api/index_patterns/index_pattern/${indexPattern.id}/runtime_field/sf` - ); + it('returns 404 error on non-existing runtime field', async () => { + const response1 = await supertest.get( + `${config.path}/${indexPattern.id}/runtime_field/sf` + ); - expect(response1.status).to.be(404); - }); + expect(response1.status).to.be(404); + }); - it('returns error when ID is too long', async () => { - const id = `xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx`; - const response = await supertest.get( - `/api/index_patterns/index_pattern/${id}/runtime_field/foo` - ); + it('returns error when ID is too long', async () => { + const id = `xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx`; + const response = await supertest.get(`${config.path}/${id}/runtime_field/foo`); - expect(response.status).to.be(400); - expect(response.body.message).to.be( - '[request params.id]: value has length [1759] but it must have a maximum length of [1000].' - ); - }); + expect(response.status).to.be(400); + expect(response.body.message).to.be( + '[request params.id]: value has length [1759] but it must have a maximum length of [1000].' + ); + }); - it('returns error when attempting to fetch a field which is not a runtime field', async () => { - const response2 = await supertest.get( - `/api/index_patterns/index_pattern/${indexPattern.id}/runtime_field/foo` - ); + it('returns error when attempting to fetch a field which is not a runtime field', async () => { + const response2 = await supertest.get( + `${config.path}/${indexPattern.id}/runtime_field/foo` + ); - expect(response2.status).to.be(400); - expect(response2.body.statusCode).to.be(400); - expect(response2.body.message).to.be('Only runtime fields can be retrieved.'); + expect(response2.status).to.be(400); + expect(response2.body.statusCode).to.be(400); + expect(response2.body.message).to.be('Only runtime fields can be retrieved.'); + }); + }); }); }); } diff --git a/test/api_integration/apis/index_patterns/runtime_fields_crud/get_runtime_field/main.ts b/test/api_integration/apis/index_patterns/runtime_fields_crud/get_runtime_field/main.ts index fa0283d69d8e3..8733b679c2302 100644 --- a/test/api_integration/apis/index_patterns/runtime_fields_crud/get_runtime_field/main.ts +++ b/test/api_integration/apis/index_patterns/runtime_fields_crud/get_runtime_field/main.ts @@ -8,6 +8,7 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { configArray } from '../../constants'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); @@ -24,48 +25,49 @@ export default function ({ getService }: FtrProviderContext) { ); }); - it('can fetch a runtime field', async () => { - const title = `basic_index*`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - override: true, - index_pattern: { - title, - runtimeFieldMap: { - runtimeFoo: { - type: 'keyword', - script: { - source: "emit(doc['field_name'].value)", + configArray.forEach((config) => { + describe(config.name, () => { + it('can fetch a runtime field', async () => { + const title = `basic_index*`; + const response1 = await supertest.post(config.path).send({ + override: true, + [config.serviceKey]: { + title, + runtimeFieldMap: { + runtimeFoo: { + type: 'keyword', + script: { + source: "emit(doc['field_name'].value)", + }, + }, + runtimeBar: { + type: 'keyword', + script: { + source: "emit(doc['field_name'].value)", + }, + }, }, }, - runtimeBar: { - type: 'keyword', - script: { - source: "emit(doc['field_name'].value)", - }, - }, - }, - }, - }); + }); - expect(response1.status).to.be(200); + expect(response1.status).to.be(200); - const response2 = await supertest.get( - '/api/index_patterns/index_pattern/' + - response1.body.index_pattern.id + - '/runtime_field/runtimeFoo' - ); + const response2 = await supertest.get( + `${config.path}/${response1.body[config.serviceKey].id}/runtime_field/runtimeFoo` + ); - expect(response2.status).to.be(200); - expect(typeof response2.body.field).to.be('object'); - expect(response2.body.field.name).to.be('runtimeFoo'); - expect(response2.body.field.type).to.be('string'); - expect(response2.body.field.scripted).to.be(false); - expect(response2.body.field.runtimeField.script.source).to.be( - "emit(doc['field_name'].value)" - ); - await supertest.delete( - '/api/index_patterns/index_pattern/' + response1.body.index_pattern.id - ); + const field = + config.serviceKey === 'index_pattern' ? response2.body.field : response2.body.fields[0]; + + expect(response2.status).to.be(200); + expect(typeof field).to.be('object'); + expect(field.name).to.be('runtimeFoo'); + expect(field.type).to.be('string'); + expect(field.scripted).to.be(false); + expect(field.runtimeField.script.source).to.be("emit(doc['field_name'].value)"); + await supertest.delete(`${config.path}/${response1.body[config.serviceKey].id}`); + }); + }); }); }); } diff --git a/test/api_integration/apis/index_patterns/runtime_fields_crud/put_runtime_field/errors.ts b/test/api_integration/apis/index_patterns/runtime_fields_crud/put_runtime_field/errors.ts index 9faca08238033..c43fe55369992 100644 --- a/test/api_integration/apis/index_patterns/runtime_fields_crud/put_runtime_field/errors.ts +++ b/test/api_integration/apis/index_patterns/runtime_fields_crud/put_runtime_field/errors.ts @@ -8,6 +8,7 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { configArray } from '../../constants'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); @@ -24,46 +25,48 @@ export default function ({ getService }: FtrProviderContext) { ); }); - it('returns 404 error on non-existing index_pattern', async () => { - const id = `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx-${Date.now()}`; - const response = await supertest - .put(`/api/index_patterns/index_pattern/${id}/runtime_field`) - .send({ - name: 'runtimeBar', - runtimeField: { - type: 'long', - script: { - source: "emit(doc['field_name'].value)", + configArray.forEach((config) => { + describe(config.name, () => { + it('returns 404 error on non-existing index_pattern', async () => { + const id = `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx-${Date.now()}`; + const response = await supertest.put(`${config.path}/${id}/runtime_field`).send({ + name: 'runtimeBar', + runtimeField: { + type: 'long', + script: { + source: "emit(doc['field_name'].value)", + }, }, - }, + }); + + expect(response.status).to.be(404); }); - expect(response.status).to.be(404); - }); + it('returns error on non-runtime field update attempt', async () => { + const title = `basic_index`; + const response1 = await supertest.post(config.path).send({ + override: true, + [config.serviceKey]: { + title, + }, + }); - it('returns error on non-runtime field update attempt', async () => { - const title = `basic_index`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - override: true, - index_pattern: { - title, - }, - }); + const response2 = await supertest + .put(`${config.path}/${response1.body[config.serviceKey].id}/runtime_field`) + .send({ + name: 'bar', + runtimeField: { + type: 'long', + script: { + source: "emit(doc['field_name'].value)", + }, + }, + }); - const response2 = await supertest - .put(`/api/index_patterns/index_pattern/${response1.body.index_pattern.id}/runtime_field`) - .send({ - name: 'bar', - runtimeField: { - type: 'long', - script: { - source: "emit(doc['field_name'].value)", - }, - }, + expect(response2.status).to.be(400); + expect(response2.body.message).to.be('Only runtime fields can be updated'); }); - - expect(response2.status).to.be(400); - expect(response2.body.message).to.be('Only runtime fields can be updated'); + }); }); }); } diff --git a/test/api_integration/apis/index_patterns/runtime_fields_crud/put_runtime_field/main.ts b/test/api_integration/apis/index_patterns/runtime_fields_crud/put_runtime_field/main.ts index 92d8c6fd6d3c2..4cbef92c49a31 100644 --- a/test/api_integration/apis/index_patterns/runtime_fields_crud/put_runtime_field/main.ts +++ b/test/api_integration/apis/index_patterns/runtime_fields_crud/put_runtime_field/main.ts @@ -8,6 +8,7 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { configArray } from '../../constants'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); @@ -24,99 +25,106 @@ export default function ({ getService }: FtrProviderContext) { ); }); - it('can overwrite an existing field', async () => { - const title = `basic_index`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - override: true, - index_pattern: { - title, - runtimeFieldMap: { - runtimeFoo: { - type: 'keyword', - script: { - source: "doc['field_name'].value", + configArray.forEach((config) => { + describe(config.name, () => { + it('can overwrite an existing field', async () => { + const title = `basic_index`; + const response1 = await supertest.post(config.path).send({ + override: true, + [config.serviceKey]: { + title, + runtimeFieldMap: { + runtimeFoo: { + type: 'keyword', + script: { + source: "doc['field_name'].value", + }, + }, + runtimeBar: { + type: 'keyword', + script: { + source: "doc['field_name'].value", + }, + }, }, }, - runtimeBar: { - type: 'keyword', - script: { - source: "doc['field_name'].value", + }); + + const response2 = await supertest + .put(`${config.path}/${response1.body[config.serviceKey].id}/runtime_field`) + .send({ + name: 'runtimeFoo', + runtimeField: { + type: 'long', + script: { + source: "doc['field_name'].value", + }, }, - }, - }, - }, - }); + }); - const response2 = await supertest - .put(`/api/index_patterns/index_pattern/${response1.body.index_pattern.id}/runtime_field`) - .send({ - name: 'runtimeFoo', - runtimeField: { - type: 'long', - script: { - source: "doc['field_name'].value", - }, - }, - }); + expect(response2.status).to.be(200); - expect(response2.status).to.be(200); + const response3 = await supertest.get( + `${config.path}/${response1.body[config.serviceKey].id}/runtime_field/runtimeFoo` + ); - const response3 = await supertest.get( - '/api/index_patterns/index_pattern/' + - response1.body.index_pattern.id + - '/runtime_field/runtimeFoo' - ); + const field3 = + config.serviceKey === 'index_pattern' ? response3.body.field : response3.body.fields[0]; - expect(response3.status).to.be(200); - expect(response3.body.field.type).to.be('number'); + expect(response3.status).to.be(200); + expect(field3.type).to.be('number'); - const response4 = await supertest.get( - '/api/index_patterns/index_pattern/' + - response1.body.index_pattern.id + - '/runtime_field/runtimeBar' - ); + const response4 = await supertest.get( + `${config.path}/${response1.body[config.serviceKey].id}/runtime_field/runtimeBar` + ); - expect(response4.status).to.be(200); - expect(response4.body.field.type).to.be('string'); - }); + const field4 = + config.serviceKey === 'index_pattern' ? response4.body.field : response4.body.fields[0]; + + expect(response4.status).to.be(200); + expect(field4.type).to.be('string'); + }); - it('can add a new runtime field', async () => { - const title = `basic_index`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - override: true, - index_pattern: { - title, - runtimeFieldMap: { - runtimeFoo: { - type: 'keyword', - script: { - source: "doc['field_name'].value", + it('can add a new runtime field', async () => { + const title = `basic_index`; + const response1 = await supertest.post(config.path).send({ + override: true, + [config.serviceKey]: { + title, + runtimeFieldMap: { + runtimeFoo: { + type: 'keyword', + script: { + source: "doc['field_name'].value", + }, + }, }, }, - }, - }, - }); + }); - await supertest - .put(`/api/index_patterns/index_pattern/${response1.body.index_pattern.id}/runtime_field`) - .send({ - name: 'runtimeBar', - runtimeField: { - type: 'long', - script: { - source: "doc['field_name'].value", - }, - }, - }); + await supertest + .put(`${config.path}/${response1.body[config.serviceKey].id}/runtime_field`) + .send({ + name: 'runtimeBar', + runtimeField: { + type: 'long', + script: { + source: "doc['field_name'].value", + }, + }, + }); - const response2 = await supertest.get( - '/api/index_patterns/index_pattern/' + - response1.body.index_pattern.id + - '/runtime_field/runtimeBar' - ); + const response2 = await supertest.get( + `${config.path}/${response1.body[config.serviceKey].id}/runtime_field/runtimeBar` + ); + + const field = + config.serviceKey === 'index_pattern' ? response2.body.field : response2.body.fields[0]; - expect(response2.status).to.be(200); - expect(typeof response2.body.field.runtimeField).to.be('object'); + expect(response2.status).to.be(200); + expect(typeof field.runtimeField).to.be('object'); + }); + }); }); }); } diff --git a/test/api_integration/apis/index_patterns/runtime_fields_crud/update_runtime_field/errors.ts b/test/api_integration/apis/index_patterns/runtime_fields_crud/update_runtime_field/errors.ts index 3980821c0fd09..09e781d70bb8d 100644 --- a/test/api_integration/apis/index_patterns/runtime_fields_crud/update_runtime_field/errors.ts +++ b/test/api_integration/apis/index_patterns/runtime_fields_crud/update_runtime_field/errors.ts @@ -8,44 +8,45 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { configArray } from '../../constants'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); describe('errors', () => { - it('returns 404 error on non-existing index_pattern', async () => { - const id = `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx-${Date.now()}`; - const response = await supertest - .post(`/api/index_patterns/index_pattern/${id}/runtime_field/foo`) - .send({ - runtimeField: { - script: { - source: "doc['something_new'].value", + configArray.forEach((config) => { + describe(config.name, () => { + it('returns 404 error on non-existing index_pattern', async () => { + const id = `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx-${Date.now()}`; + const response = await supertest.post(`${config.path}/${id}/runtime_field/foo`).send({ + runtimeField: { + script: { + source: "doc['something_new'].value", + }, }, - }, - }); + }); - expect(response.status).to.be(404); - }); + expect(response.status).to.be(404); + }); - it('returns error when field name is specified', async () => { - const id = `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx-${Date.now()}`; - const response = await supertest - .post(`/api/index_patterns/index_pattern/${id}/runtime_field/foo`) - .send({ - name: 'foo', - runtimeField: { - script: { - source: "doc['something_new'].value", + it('returns error when field name is specified', async () => { + const id = `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx-${Date.now()}`; + const response = await supertest.post(`${config.path}/${id}/runtime_field/foo`).send({ + name: 'foo', + runtimeField: { + script: { + source: "doc['something_new'].value", + }, }, - }, - }); + }); - expect(response.status).to.be(400); - expect(response.body.statusCode).to.be(400); - expect(response.body.message).to.be( - "[request body.name]: a value wasn't expected to be present" - ); + expect(response.status).to.be(400); + expect(response.body.statusCode).to.be(400); + expect(response.body.message).to.be( + "[request body.name]: a value wasn't expected to be present" + ); + }); + }); }); }); } diff --git a/test/api_integration/apis/index_patterns/runtime_fields_crud/update_runtime_field/main.ts b/test/api_integration/apis/index_patterns/runtime_fields_crud/update_runtime_field/main.ts index 6b924570a0e45..d1f569472d104 100644 --- a/test/api_integration/apis/index_patterns/runtime_fields_crud/update_runtime_field/main.ts +++ b/test/api_integration/apis/index_patterns/runtime_fields_crud/update_runtime_field/main.ts @@ -8,6 +8,7 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../../ftr_provider_context'; +import { configArray } from '../../constants'; export default function ({ getService }: FtrProviderContext) { const supertest = getService('supertest'); @@ -24,53 +25,56 @@ export default function ({ getService }: FtrProviderContext) { ); }); - it('can update an existing field', async () => { - const title = `basic_index`; - const response1 = await supertest.post('/api/index_patterns/index_pattern').send({ - override: true, - index_pattern: { - title, - runtimeFieldMap: { - runtimeFoo: { - type: 'keyword', - script: { - source: "doc['field_name'].value", + configArray.forEach((config) => { + describe(config.name, () => { + it('can update an existing field', async () => { + const title = `basic_index`; + const response1 = await supertest.post(config.path).send({ + override: true, + [config.serviceKey]: { + title, + runtimeFieldMap: { + runtimeFoo: { + type: 'keyword', + script: { + source: "doc['field_name'].value", + }, + }, + runtimeBar: { + type: 'keyword', + script: { + source: "doc['field_name'].value", + }, + }, }, }, - runtimeBar: { - type: 'keyword', - script: { - source: "doc['field_name'].value", + }); + + const response2 = await supertest + .post(`${config.path}/${response1.body[config.serviceKey].id}/runtime_field/runtimeFoo`) + .send({ + runtimeField: { + script: { + source: "doc['something_new'].value", + }, }, - }, - }, - }, - }); + }); - const response2 = await supertest - .post( - `/api/index_patterns/index_pattern/${response1.body.index_pattern.id}/runtime_field/runtimeFoo` - ) - .send({ - runtimeField: { - script: { - source: "doc['something_new'].value", - }, - }, - }); + expect(response2.status).to.be(200); - expect(response2.status).to.be(200); + const response3 = await supertest.get( + `${config.path}/${response1.body[config.serviceKey].id}/runtime_field/runtimeFoo` + ); - const response3 = await supertest.get( - '/api/index_patterns/index_pattern/' + - response1.body.index_pattern.id + - '/runtime_field/runtimeFoo' - ); + const field = + config.serviceKey === 'index_pattern' ? response3.body.field : response3.body.fields[0]; - expect(response3.status).to.be(200); - expect(response3.body.field.type).to.be('string'); - expect(response3.body.field.runtimeField.type).to.be('keyword'); - expect(response3.body.field.runtimeField.script.source).to.be("doc['something_new'].value"); + expect(response3.status).to.be(200); + expect(field.type).to.be('string'); + expect(field.runtimeField.type).to.be('keyword'); + expect(field.runtimeField.script.source).to.be("doc['something_new'].value"); + }); + }); }); }); } From 2d1755439de02483671f75f3b2ccb38c802ab782 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?= Date: Thu, 6 Jan 2022 14:37:43 +0100 Subject: [PATCH 4/6] [Telemetry] Use server's `lastReported` on the browser (#121656) --- .../__snapshots__/home.test.tsx.snap | 4 ++ .../common/is_report_interval_expired.test.ts | 67 ++++++++++++++++++ .../common/is_report_interval_expired.ts | 19 +++++ .../public/services/telemetry_sender.test.ts | 70 +++++++++++-------- .../public/services/telemetry_sender.ts | 61 ++++++++++------ .../public/services/telemetry_service.ts | 11 +++ src/plugins/telemetry/server/fetcher.ts | 15 ++-- src/plugins/telemetry/server/plugin.ts | 39 +++++++---- src/plugins/telemetry/server/routes/index.ts | 13 ++-- .../server/routes/telemetry_last_reported.ts | 55 +++++++++++++++ ...telemetry_management_section.test.tsx.snap | 2 + test/api_integration/apis/telemetry/index.js | 1 + .../apis/telemetry/telemetry_last_reported.ts | 60 ++++++++++++++++ 13 files changed, 343 insertions(+), 74 deletions(-) create mode 100644 src/plugins/telemetry/common/is_report_interval_expired.test.ts create mode 100644 src/plugins/telemetry/common/is_report_interval_expired.ts create mode 100644 src/plugins/telemetry/server/routes/telemetry_last_reported.ts create mode 100644 test/api_integration/apis/telemetry/telemetry_last_reported.ts diff --git a/src/plugins/home/public/application/components/__snapshots__/home.test.tsx.snap b/src/plugins/home/public/application/components/__snapshots__/home.test.tsx.snap index f38bdb9ac53f0..373fc8ea59b6f 100644 --- a/src/plugins/home/public/application/components/__snapshots__/home.test.tsx.snap +++ b/src/plugins/home/public/application/components/__snapshots__/home.test.tsx.snap @@ -442,6 +442,7 @@ exports[`home isNewKibanaInstance should set isNewKibanaInstance to true when th "userCanChangeSettings": true, }, "fetchExample": [Function], + "fetchLastReported": [Function], "fetchTelemetry": [Function], "getCanChangeOptInStatus": [Function], "getIsOptedIn": [Function], @@ -492,6 +493,7 @@ exports[`home isNewKibanaInstance should set isNewKibanaInstance to true when th "reportOptInStatusChange": true, "setOptIn": [Function], "setUserHasSeenNotice": [Function], + "updateLastReported": [Function], "updatedConfig": undefined, }, }, @@ -509,6 +511,7 @@ exports[`home isNewKibanaInstance should set isNewKibanaInstance to true when th "userCanChangeSettings": true, }, "fetchExample": [Function], + "fetchLastReported": [Function], "fetchTelemetry": [Function], "getCanChangeOptInStatus": [Function], "getIsOptedIn": [Function], @@ -559,6 +562,7 @@ exports[`home isNewKibanaInstance should set isNewKibanaInstance to true when th "reportOptInStatusChange": true, "setOptIn": [Function], "setUserHasSeenNotice": [Function], + "updateLastReported": [Function], "updatedConfig": undefined, }, } diff --git a/src/plugins/telemetry/common/is_report_interval_expired.test.ts b/src/plugins/telemetry/common/is_report_interval_expired.test.ts new file mode 100644 index 0000000000000..68c252a959c0e --- /dev/null +++ b/src/plugins/telemetry/common/is_report_interval_expired.test.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { REPORT_INTERVAL_MS } from './constants'; +import { isReportIntervalExpired } from './is_report_interval_expired'; + +describe('isReportIntervalExpired', () => { + test('true when undefined', () => { + expect(isReportIntervalExpired(undefined)).toBe(true); + expect(isReportIntervalExpired(void 0)).toBe(true); + }); + + describe('true when NaN', () => { + test('NaN', () => { + expect(isReportIntervalExpired(NaN)).toBe(true); + }); + + test('parseInt(undefined)', () => { + expect(isReportIntervalExpired(parseInt(undefined as unknown as string, 10))).toBe(true); + }); + + test('parseInt(null)', () => { + expect(isReportIntervalExpired(parseInt(null as unknown as string, 10))).toBe(true); + }); + + test('parseInt("")', () => { + expect(isReportIntervalExpired(parseInt('', 10))).toBe(true); + }); + + test('empty string', () => { + expect(isReportIntervalExpired('' as unknown as number)).toBe(true); + }); + + test('malformed string', () => { + expect(isReportIntervalExpired(`random_malformed_string` as unknown as number)).toBe(true); + }); + + test('other object', () => { + expect(isReportIntervalExpired({} as unknown as number)).toBe(true); + }); + }); + + test('true when 0', () => { + expect(isReportIntervalExpired(0)).toBe(true); + }); + + test('true when actually expired', () => { + expect(isReportIntervalExpired(Date.now() - REPORT_INTERVAL_MS - 1000)).toBe(true); + }); + + test('false when close but not yet', () => { + expect(isReportIntervalExpired(Date.now() - REPORT_INTERVAL_MS + 1000)).toBe(false); + }); + + test('false when date in the future', () => { + expect(isReportIntervalExpired(Date.now() + 1000)).toBe(false); + }); + + test('false when date is now', () => { + expect(isReportIntervalExpired(Date.now())).toBe(false); + }); +}); diff --git a/src/plugins/telemetry/common/is_report_interval_expired.ts b/src/plugins/telemetry/common/is_report_interval_expired.ts new file mode 100644 index 0000000000000..d91916c12c449 --- /dev/null +++ b/src/plugins/telemetry/common/is_report_interval_expired.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { REPORT_INTERVAL_MS } from './constants'; + +/** + * The report is considered expired if: + * - `lastReportAt` does not exist, is NaN or `REPORT_INTERVAL_MS` have passed ever since. + * @param lastReportAt + * @returns `true` if the report interval is considered expired + */ +export function isReportIntervalExpired(lastReportAt: number | undefined) { + return !lastReportAt || isNaN(lastReportAt) || Date.now() - lastReportAt > REPORT_INTERVAL_MS; +} diff --git a/src/plugins/telemetry/public/services/telemetry_sender.test.ts b/src/plugins/telemetry/public/services/telemetry_sender.test.ts index d4678ce0ea23a..03b7fdb6f556d 100644 --- a/src/plugins/telemetry/public/services/telemetry_sender.test.ts +++ b/src/plugins/telemetry/public/services/telemetry_sender.test.ts @@ -42,87 +42,98 @@ describe('TelemetrySender', () => { }); it('uses lastReport if set', () => { - const lastReport = `${Date.now()}`; - mockLocalStorage.getItem.mockReturnValueOnce(JSON.stringify({ lastReport })); + const lastReport = Date.now(); + mockLocalStorage.getItem.mockReturnValueOnce(JSON.stringify({ lastReport: `${lastReport}` })); const telemetryService = mockTelemetryService(); const telemetrySender = new TelemetrySender(telemetryService); expect(telemetrySender['lastReported']).toBe(lastReport); }); }); - describe('saveToBrowser', () => { - it('uses lastReport', () => { - const lastReport = `${Date.now()}`; + describe('updateLastReported', () => { + it('stores the new lastReported value in the storage', () => { + const lastReport = Date.now(); const telemetryService = mockTelemetryService(); const telemetrySender = new TelemetrySender(telemetryService); - telemetrySender['lastReported'] = lastReport; - telemetrySender['saveToBrowser'](); + telemetrySender['updateLastReported'](lastReport); expect(mockLocalStorage.setItem).toHaveBeenCalledTimes(1); expect(mockLocalStorage.setItem).toHaveBeenCalledWith( LOCALSTORAGE_KEY, - JSON.stringify({ lastReport }) + JSON.stringify({ lastReport: `${lastReport}` }) ); }); }); describe('shouldSendReport', () => { - it('returns false whenever optIn is false', () => { + it('returns false whenever optIn is false', async () => { const telemetryService = mockTelemetryService(); telemetryService.getIsOptedIn = jest.fn().mockReturnValue(false); const telemetrySender = new TelemetrySender(telemetryService); - const shouldSendReport = telemetrySender['shouldSendReport'](); + const shouldSendReport = await telemetrySender['shouldSendReport'](); expect(telemetryService.getIsOptedIn).toBeCalledTimes(1); expect(shouldSendReport).toBe(false); }); - it('returns true if lastReported is undefined', () => { + it('returns true if lastReported is undefined (both local and global)', async () => { const telemetryService = mockTelemetryService(); telemetryService.getIsOptedIn = jest.fn().mockReturnValue(true); + telemetryService.fetchLastReported = jest.fn().mockResolvedValue(undefined); const telemetrySender = new TelemetrySender(telemetryService); - const shouldSendReport = telemetrySender['shouldSendReport'](); + const shouldSendReport = await telemetrySender['shouldSendReport'](); expect(telemetrySender['lastReported']).toBeUndefined(); expect(shouldSendReport).toBe(true); + expect(telemetryService.fetchLastReported).toHaveBeenCalledTimes(1); }); - it('returns true if lastReported passed REPORT_INTERVAL_MS', () => { + it('returns true if lastReported passed REPORT_INTERVAL_MS', async () => { const lastReported = Date.now() - (REPORT_INTERVAL_MS + 1000); const telemetryService = mockTelemetryService(); telemetryService.getIsOptedIn = jest.fn().mockReturnValue(true); const telemetrySender = new TelemetrySender(telemetryService); - telemetrySender['lastReported'] = `${lastReported}`; - const shouldSendReport = telemetrySender['shouldSendReport'](); + telemetrySender['lastReported'] = lastReported; + const shouldSendReport = await telemetrySender['shouldSendReport'](); expect(shouldSendReport).toBe(true); }); - it('returns false if lastReported is within REPORT_INTERVAL_MS', () => { + it('returns false if local lastReported is within REPORT_INTERVAL_MS', async () => { const lastReported = Date.now() + 1000; const telemetryService = mockTelemetryService(); telemetryService.getIsOptedIn = jest.fn().mockReturnValue(true); const telemetrySender = new TelemetrySender(telemetryService); - telemetrySender['lastReported'] = `${lastReported}`; - const shouldSendReport = telemetrySender['shouldSendReport'](); + telemetrySender['lastReported'] = lastReported; + const shouldSendReport = await telemetrySender['shouldSendReport'](); expect(shouldSendReport).toBe(false); }); - it('returns true if lastReported is malformed', () => { + it('returns false if local lastReported is expired but the remote is within REPORT_INTERVAL_MS', async () => { const telemetryService = mockTelemetryService(); telemetryService.getIsOptedIn = jest.fn().mockReturnValue(true); + telemetryService.fetchLastReported = jest.fn().mockResolvedValue(Date.now() + 1000); const telemetrySender = new TelemetrySender(telemetryService); - telemetrySender['lastReported'] = `random_malformed_string`; - const shouldSendReport = telemetrySender['shouldSendReport'](); + telemetrySender['lastReported'] = Date.now() - (REPORT_INTERVAL_MS + 1000); + const shouldSendReport = await telemetrySender['shouldSendReport'](); + expect(shouldSendReport).toBe(false); + }); + + it('returns true if lastReported is malformed', async () => { + const telemetryService = mockTelemetryService(); + telemetryService.getIsOptedIn = jest.fn().mockReturnValue(true); + const telemetrySender = new TelemetrySender(telemetryService); + telemetrySender['lastReported'] = `random_malformed_string` as unknown as number; + const shouldSendReport = await telemetrySender['shouldSendReport'](); expect(shouldSendReport).toBe(true); }); - it('returns false if we are in screenshot mode', () => { + it('returns false if we are in screenshot mode', async () => { const telemetryService = mockTelemetryService({ isScreenshotMode: true }); telemetryService.getIsOptedIn = jest.fn().mockReturnValue(false); const telemetrySender = new TelemetrySender(telemetryService); - const shouldSendReport = telemetrySender['shouldSendReport'](); + const shouldSendReport = await telemetrySender['shouldSendReport'](); expect(telemetryService.getIsOptedIn).toBeCalledTimes(0); expect(shouldSendReport).toBe(false); @@ -165,13 +176,14 @@ describe('TelemetrySender', () => { const telemetrySender = new TelemetrySender(telemetryService); telemetrySender['shouldSendReport'] = jest.fn().mockReturnValue(true); telemetrySender['sendUsageData'] = jest.fn().mockReturnValue(true); - telemetrySender['saveToBrowser'] = jest.fn(); - telemetrySender['lastReported'] = `${lastReported}`; + telemetrySender['updateLastReported'] = jest.fn().mockImplementation((value) => { + expect(value).not.toBe(lastReported); + }); + telemetrySender['lastReported'] = lastReported; await telemetrySender['sendIfDue'](); - expect(telemetrySender['lastReported']).not.toBe(lastReported); - expect(telemetrySender['saveToBrowser']).toBeCalledTimes(1); + expect(telemetrySender['updateLastReported']).toBeCalledTimes(1); expect(telemetrySender['retryCount']).toEqual(0); expect(telemetrySender['sendUsageData']).toHaveBeenCalledTimes(1); }); @@ -181,7 +193,7 @@ describe('TelemetrySender', () => { const telemetrySender = new TelemetrySender(telemetryService); telemetrySender['shouldSendReport'] = jest.fn().mockReturnValue(true); telemetrySender['sendUsageData'] = jest.fn(); - telemetrySender['saveToBrowser'] = jest.fn(); + telemetrySender['updateLastReported'] = jest.fn(); telemetrySender['retryCount'] = 9; await telemetrySender['sendIfDue'](); @@ -272,7 +284,7 @@ describe('TelemetrySender', () => { telemetryService.getTelemetryUrl = jest.fn().mockReturnValue(mockTelemetryUrl); telemetryService.fetchTelemetry = jest.fn().mockReturnValue(mockTelemetryPayload); telemetrySender['shouldSendReport'] = jest.fn().mockReturnValue(true); - telemetrySender['saveToBrowser'] = jest.fn(); + telemetrySender['updateLastReported'] = jest.fn(); await telemetrySender['sendUsageData'](); diff --git a/src/plugins/telemetry/public/services/telemetry_sender.ts b/src/plugins/telemetry/public/services/telemetry_sender.ts index fb87b0b23ad56..d0eb9142e724a 100644 --- a/src/plugins/telemetry/public/services/telemetry_sender.ts +++ b/src/plugins/telemetry/public/services/telemetry_sender.ts @@ -6,18 +6,15 @@ * Side Public License, v 1. */ -import { - REPORT_INTERVAL_MS, - LOCALSTORAGE_KEY, - PAYLOAD_CONTENT_ENCODING, -} from '../../common/constants'; +import { LOCALSTORAGE_KEY, PAYLOAD_CONTENT_ENCODING } from '../../common/constants'; import { TelemetryService } from './telemetry_service'; import { Storage } from '../../../kibana_utils/public'; import type { EncryptedTelemetryPayload } from '../../common/types'; +import { isReportIntervalExpired } from '../../common/is_report_interval_expired'; export class TelemetrySender { private readonly telemetryService: TelemetryService; - private lastReported?: string; + private lastReported?: number; private readonly storage: Storage; private intervalId: number = 0; // setInterval returns a positive integer, 0 means no interval is set private retryCount: number = 0; @@ -32,38 +29,56 @@ export class TelemetrySender { const attributes = this.storage.get(LOCALSTORAGE_KEY); if (attributes) { - this.lastReported = attributes.lastReport; + this.lastReported = parseInt(attributes.lastReport, 10); } } - private saveToBrowser = () => { + private updateLastReported = (lastReported: number) => { + this.lastReported = lastReported; // we are the only code that manipulates this key, so it's safe to blindly overwrite the whole object - this.storage.set(LOCALSTORAGE_KEY, { lastReport: this.lastReported }); + this.storage.set(LOCALSTORAGE_KEY, { lastReport: `${this.lastReported}` }); }; - private shouldSendReport = (): boolean => { + /** + * Using the local and SO's `lastReported` values, it decides whether the last report should be considered as expired + * @returns `true` if a new report should be generated. `false` otherwise. + */ + private isReportDue = async (): Promise => { + // Try to decide with the local `lastReported` to avoid querying the server + if (!isReportIntervalExpired(this.lastReported)) { + // If it is not expired locally, there's no need to send it again yet. + return false; + } + + // Double-check with the server's value + const globalLastReported = await this.telemetryService.fetchLastReported(); + + if (globalLastReported) { + // Update the local value to avoid repetitions of this request (it was already expired, so it doesn't really matter if the server's value is older) + this.updateLastReported(globalLastReported); + } + + return isReportIntervalExpired(globalLastReported); + }; + + /** + * Using configuration and the lastReported dates, it decides whether a new telemetry report should be sent. + * @returns `true` if a new report should be sent. `false` otherwise. + */ + private shouldSendReport = async (): Promise => { if (this.telemetryService.canSendTelemetry()) { - if (!this.lastReported) { - return true; - } - // returns NaN for any malformed or unset (null/undefined) value - const lastReported = parseInt(this.lastReported, 10); - // If it's been a day since we last sent telemetry - if (isNaN(lastReported) || Date.now() - lastReported > REPORT_INTERVAL_MS) { - return true; - } + return await this.isReportDue(); } return false; }; private sendIfDue = async (): Promise => { - if (!this.shouldSendReport()) { + if (!(await this.shouldSendReport())) { return; } // optimistically update the report date and reset the retry counter for a new time report interval window - this.lastReported = `${Date.now()}`; - this.saveToBrowser(); + this.updateLastReported(Date.now()); this.retryCount = 0; await this.sendUsageData(); }; @@ -89,6 +104,8 @@ export class TelemetrySender { }) ) ); + + await this.telemetryService.updateLastReported().catch(() => {}); // Let's catch the error. Worst-case scenario another Telemetry report will be generated somewhere else. } catch (err) { // ignore err and try again but after a longer wait period. this.retryCount = this.retryCount + 1; diff --git a/src/plugins/telemetry/public/services/telemetry_service.ts b/src/plugins/telemetry/public/services/telemetry_service.ts index d8732b3d4bba9..55dc623a8ccf8 100644 --- a/src/plugins/telemetry/public/services/telemetry_service.ts +++ b/src/plugins/telemetry/public/services/telemetry_service.ts @@ -138,6 +138,17 @@ export class TelemetryService { return !this.isScreenshotMode && this.getIsOptedIn(); }; + public fetchLastReported = async (): Promise => { + const response = await this.http.get<{ lastReported?: number }>( + '/api/telemetry/v2/last_reported' + ); + return response?.lastReported; + }; + + public updateLastReported = async (): Promise => { + return this.http.put('/api/telemetry/v2/last_reported'); + }; + /** Fetches an unencrypted telemetry payload so we can show it to the user **/ public fetchExample = async (): Promise => { return await this.fetchTelemetry({ unencrypted: true, refreshCache: true }); diff --git a/src/plugins/telemetry/server/fetcher.ts b/src/plugins/telemetry/server/fetcher.ts index a232ad4050793..db890d2ea12ec 100644 --- a/src/plugins/telemetry/server/fetcher.ts +++ b/src/plugins/telemetry/server/fetcher.ts @@ -24,9 +24,10 @@ import { getTelemetryFailureDetails, } from '../common/telemetry_config'; import { getTelemetrySavedObject, updateTelemetrySavedObject } from './telemetry_repository'; -import { REPORT_INTERVAL_MS, PAYLOAD_CONTENT_ENCODING } from '../common/constants'; +import { PAYLOAD_CONTENT_ENCODING } from '../common/constants'; import type { EncryptedTelemetryPayload } from '../common/types'; import { TelemetryConfigType } from './config'; +import { isReportIntervalExpired } from '../common/is_report_interval_expired'; export interface FetcherTaskDepsStart { telemetryCollectionManager: TelemetryCollectionManagerPluginStart; @@ -39,6 +40,7 @@ interface TelemetryConfig { failureCount: number; failureVersion: string | undefined; currentVersion: string; + lastReported: number | undefined; } export class FetcherTask { @@ -59,10 +61,7 @@ export class FetcherTask { this.logger = initializerContext.logger.get('fetcher'); } - public start( - { savedObjects, elasticsearch }: CoreStart, - { telemetryCollectionManager }: FetcherTaskDepsStart - ) { + public start({ savedObjects }: CoreStart, { telemetryCollectionManager }: FetcherTaskDepsStart) { this.internalRepository = new SavedObjectsClient(savedObjects.createInternalRepository()); this.telemetryCollectionManager = telemetryCollectionManager; @@ -148,6 +147,7 @@ export class FetcherTask { failureCount, failureVersion, currentVersion: currentKibanaVersion, + lastReported: telemetrySavedObject ? telemetrySavedObject.lastReported : void 0, }; } @@ -178,13 +178,16 @@ export class FetcherTask { failureCount, failureVersion, currentVersion, + lastReported, }: TelemetryConfig) { if (failureCount > 2 && failureVersion === currentVersion) { return false; } if (telemetryOptIn && telemetrySendUsageFrom === 'server') { - if (!this.lastReported || Date.now() - this.lastReported > REPORT_INTERVAL_MS) { + // Check both: in-memory and SO-driven value. + // This will avoid the server retrying over and over when it has issues with storing the state in the SO. + if (isReportIntervalExpired(this.lastReported) && isReportIntervalExpired(lastReported)) { return true; } } diff --git a/src/plugins/telemetry/server/plugin.ts b/src/plugins/telemetry/server/plugin.ts index aa22410358f72..cfd91af73d747 100644 --- a/src/plugins/telemetry/server/plugin.ts +++ b/src/plugins/telemetry/server/plugin.ts @@ -7,22 +7,23 @@ */ import { URL } from 'url'; -import { Observable } from 'rxjs'; -import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; -import { +import type { Observable } from 'rxjs'; +import { ReplaySubject } from 'rxjs'; +import { take } from 'rxjs/operators'; +import type { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import type { TelemetryCollectionManagerPluginSetup, TelemetryCollectionManagerPluginStart, } from 'src/plugins/telemetry_collection_manager/server'; -import { take } from 'rxjs/operators'; -import { +import type { CoreSetup, PluginInitializerContext, ISavedObjectsRepository, CoreStart, - SavedObjectsClient, Plugin, Logger, -} from '../../../core/server'; +} from 'src/core/server'; +import { SavedObjectsClient } from '../../../core/server'; import { registerRoutes } from './routes'; import { registerCollection } from './telemetry_collection'; import { @@ -77,7 +78,17 @@ export class TelemetryPlugin implements Plugin(1); constructor(initializerContext: PluginInitializerContext) { this.logger = initializerContext.logger.get(); @@ -107,6 +118,7 @@ export class TelemetryPlugin implements Plugin savedObjects.registerType(opts)); @@ -128,13 +140,16 @@ export class TelemetryPlugin implements Plugin { - const internalRepository = new SavedObjectsClient(savedObjectsInternalRepository); - const telemetrySavedObject = await getTelemetrySavedObject(internalRepository); + const internalRepositoryClient = await this.savedObjectsInternalClient$ + .pipe(take(1)) + .toPromise(); + const telemetrySavedObject = await getTelemetrySavedObject(internalRepositoryClient); const config = await this.config$.pipe(take(1)).toPromise(); const allowChangingOptInStatus = config.allowChangingOptInStatus; @@ -197,7 +212,7 @@ export class TelemetryPlugin implements Plugin this.savedObjectsClient; + const getSavedObjectsClient = () => this.savedObjectsInternalRepository; registerTelemetryPluginUsageCollector(usageCollection, { currentKibanaVersion: this.currentKibanaVersion, diff --git a/src/plugins/telemetry/server/routes/index.ts b/src/plugins/telemetry/server/routes/index.ts index 5d073a7146c92..995bcd491634f 100644 --- a/src/plugins/telemetry/server/routes/index.ts +++ b/src/plugins/telemetry/server/routes/index.ts @@ -6,14 +6,15 @@ * Side Public License, v 1. */ -import { Observable } from 'rxjs'; -import { IRouter, Logger } from 'kibana/server'; -import { TelemetryCollectionManagerPluginSetup } from 'src/plugins/telemetry_collection_manager/server'; +import type { Observable } from 'rxjs'; +import type { IRouter, Logger, SavedObjectsClient } from 'kibana/server'; +import type { TelemetryCollectionManagerPluginSetup } from 'src/plugins/telemetry_collection_manager/server'; import { registerTelemetryOptInRoutes } from './telemetry_opt_in'; import { registerTelemetryUsageStatsRoutes } from './telemetry_usage_stats'; import { registerTelemetryOptInStatsRoutes } from './telemetry_opt_in_stats'; import { registerTelemetryUserHasSeenNotice } from './telemetry_user_has_seen_notice'; -import { TelemetryConfigType } from '../config'; +import type { TelemetryConfigType } from '../config'; +import { registerTelemetryLastReported } from './telemetry_last_reported'; interface RegisterRoutesParams { isDev: boolean; @@ -22,12 +23,14 @@ interface RegisterRoutesParams { currentKibanaVersion: string; router: IRouter; telemetryCollectionManager: TelemetryCollectionManagerPluginSetup; + savedObjectsInternalClient$: Observable; } export function registerRoutes(options: RegisterRoutesParams) { - const { isDev, telemetryCollectionManager, router } = options; + const { isDev, telemetryCollectionManager, router, savedObjectsInternalClient$ } = options; registerTelemetryOptInRoutes(options); registerTelemetryUsageStatsRoutes(router, telemetryCollectionManager, isDev); registerTelemetryOptInStatsRoutes(router, telemetryCollectionManager); registerTelemetryUserHasSeenNotice(router); + registerTelemetryLastReported(router, savedObjectsInternalClient$); } diff --git a/src/plugins/telemetry/server/routes/telemetry_last_reported.ts b/src/plugins/telemetry/server/routes/telemetry_last_reported.ts new file mode 100644 index 0000000000000..2086327476ba2 --- /dev/null +++ b/src/plugins/telemetry/server/routes/telemetry_last_reported.ts @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { IRouter, SavedObjectsClient } from 'kibana/server'; +import type { Observable } from 'rxjs'; +import { take } from 'rxjs/operators'; +import { getTelemetrySavedObject, updateTelemetrySavedObject } from '../telemetry_repository'; + +export function registerTelemetryLastReported( + router: IRouter, + savedObjectsInternalClient$: Observable +) { + // GET to retrieve + router.get( + { + path: '/api/telemetry/v2/last_reported', + validate: false, + }, + async (context, req, res) => { + const savedObjectsInternalClient = await savedObjectsInternalClient$ + .pipe(take(1)) + .toPromise(); + const telemetrySavedObject = await getTelemetrySavedObject(savedObjectsInternalClient); + + return res.ok({ + body: { + lastReported: telemetrySavedObject && telemetrySavedObject?.lastReported, + }, + }); + } + ); + + // PUT to update + router.put( + { + path: '/api/telemetry/v2/last_reported', + validate: false, + }, + async (context, req, res) => { + const savedObjectsInternalClient = await savedObjectsInternalClient$ + .pipe(take(1)) + .toPromise(); + await updateTelemetrySavedObject(savedObjectsInternalClient, { + lastReported: Date.now(), + }); + + return res.ok(); + } + ); +} diff --git a/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap b/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap index 72947b1514911..0edad23d3312b 100644 --- a/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap +++ b/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap @@ -258,6 +258,7 @@ exports[`TelemetryManagementSectionComponent renders null because allowChangingO "sendUsageTo": "staging", }, "fetchExample": [Function], + "fetchLastReported": [Function], "fetchTelemetry": [Function], "getCanChangeOptInStatus": [Function], "getIsOptedIn": [Function], @@ -308,6 +309,7 @@ exports[`TelemetryManagementSectionComponent renders null because allowChangingO "reportOptInStatusChange": false, "setOptIn": [Function], "setUserHasSeenNotice": [Function], + "updateLastReported": [Function], "updatedConfig": undefined, } } diff --git a/test/api_integration/apis/telemetry/index.js b/test/api_integration/apis/telemetry/index.js index 5394b54062d89..94ada69b93322 100644 --- a/test/api_integration/apis/telemetry/index.js +++ b/test/api_integration/apis/telemetry/index.js @@ -9,6 +9,7 @@ export default function ({ loadTestFile }) { describe('Telemetry', () => { loadTestFile(require.resolve('./opt_in')); + loadTestFile(require.resolve('./telemetry_last_reported')); loadTestFile(require.resolve('./telemetry_optin_notice_seen')); }); } diff --git a/test/api_integration/apis/telemetry/telemetry_last_reported.ts b/test/api_integration/apis/telemetry/telemetry_last_reported.ts new file mode 100644 index 0000000000000..d190726e6db0b --- /dev/null +++ b/test/api_integration/apis/telemetry/telemetry_last_reported.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function optInTest({ getService }: FtrProviderContext) { + const client = getService('es'); + const supertest = getService('supertest'); + + describe('/api/telemetry/v2/last_reported API Telemetry lastReported', () => { + before(async () => { + await client.delete( + { + index: '.kibana', + id: 'telemetry:telemetry', + }, + { ignore: [404] } + ); + }); + + it('GET should return undefined when there is no stored telemetry.lastReported value', async () => { + await supertest + .get('/api/telemetry/v2/last_reported') + .set('kbn-xsrf', 'xxx') + .expect(200, { lastReported: undefined }); + }); + + it('PUT should update telemetry.lastReported to now', async () => { + await supertest.put('/api/telemetry/v2/last_reported').set('kbn-xsrf', 'xxx').expect(200); + + const { _source } = await client.get<{ telemetry: { lastReported: number } }>({ + index: '.kibana', + id: 'telemetry:telemetry', + }); + + expect(_source?.telemetry.lastReported).to.be.a('number'); + }); + + it('GET should return the previously stored lastReported value', async () => { + const { _source } = await client.get<{ telemetry: { lastReported: number } }>({ + index: '.kibana', + id: 'telemetry:telemetry', + }); + + expect(_source?.telemetry.lastReported).to.be.a('number'); + const lastReported = _source?.telemetry.lastReported; + + await supertest + .get('/api/telemetry/v2/last_reported') + .set('kbn-xsrf', 'xxx') + .expect(200, { lastReported }); + }); + }); +} From 9f469d0b92f6dee816654d40900e2e463e8e28ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20S=C3=A1nchez?= Date: Thu, 6 Jan 2022 15:55:38 +0100 Subject: [PATCH 5/6] [Security Solution][Endpoint] Allow user see exception full details filtering event filters list by item_id (#122241) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../pages/event_filters/constants.ts | 1 + .../list/policy_event_filters_list.test.tsx | 12 +++++++++++ .../list/policy_event_filters_list.tsx | 20 ++++++++++++++++++- 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/constants.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/constants.ts index 6fb6b8081596e..df77915e5de59 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/constants.ts +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/constants.ts @@ -28,6 +28,7 @@ export const SEARCHABLE_FIELDS: Readonly = [ `entries.value`, `entries.entries.value`, `comments.comment`, + `item_id`, ]; export { ENDPOINT_EVENT_FILTERS_LIST_ID, EXCEPTION_LIST_URL, EXCEPTION_LIST_ITEM_URL }; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/list/policy_event_filters_list.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/list/policy_event_filters_list.test.tsx index b85c77b4c8edf..1479eca21c1c8 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/list/policy_event_filters_list.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/list/policy_event_filters_list.test.tsx @@ -121,4 +121,16 @@ describe('Policy details event filters list', () => { ) ); }); + + it('should enable the "view full details" action', async () => { + mockedApi.responseProvider.eventFiltersList.mockReturnValue( + getFoundExceptionListItemSchemaMock() + ); + await render(); + // click the actions button + userEvent.click( + renderResult.getByTestId('eventFilters-collapsed-list-card-header-actions-button') + ); + expect(renderResult.queryByTestId('view-full-details-action')).toBeTruthy(); + }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/list/policy_event_filters_list.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/list/policy_event_filters_list.tsx index 04e24fa3f48b4..b837a99370218 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/list/policy_event_filters_list.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/event_filters/list/policy_event_filters_list.tsx @@ -9,6 +9,8 @@ import React, { useCallback, useMemo, useState } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiSpacer, EuiText, Pagination } from '@elastic/eui'; import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import { useAppUrl } from '../../../../../../common/lib/kibana'; +import { APP_UI_ID } from '../../../../../../../common/constants'; import { useSearchAssignedEventFilters } from '../hooks'; import { SearchExceptions } from '../../../../../components/search_exceptions'; import { useEndpointPoliciesToArtifactPolicies } from '../../../../../components/artifact_entry_card/hooks/use_endpoint_policies_to_artifact_policies'; @@ -27,11 +29,13 @@ import { } from '../../policy_hooks'; import { getCurrentArtifactsLocation } from '../../../store/policy_details/selectors'; import { ImmutableObject, PolicyData } from '../../../../../../../common/endpoint/types'; +import { getEventFiltersListPath } from '../../../../../common/routing'; interface PolicyEventFiltersListProps { policy: ImmutableObject; } export const PolicyEventFiltersList = React.memo(({ policy }) => { + const { getAppUrl } = useAppUrl(); const policiesRequest = useGetEndpointSpecificPolicies(); const navigateCallback = usePolicyDetailsEventFiltersNavigateCallback(); const urlParams = usePolicyDetailsSelector(getCurrentArtifactsLocation); @@ -93,10 +97,24 @@ export const PolicyEventFiltersList = React.memo(({ const artifactCardPolicies = useEndpointPoliciesToArtifactPolicies(policiesRequest.data?.items); const provideCardProps: ArtifactCardGridProps['cardComponentProps'] = (artifact) => { + const viewUrlPath = getEventFiltersListPath({ + filter: (artifact as ExceptionListItemSchema).item_id, + }); + const fullDetailsAction = { + icon: 'controlsHorizontal', + children: i18n.translate( + 'xpack.securitySolution.endpoint.policy.eventFilters.list.fullDetailsAction', + { defaultMessage: 'View full details' } + ), + href: getAppUrl({ appId: APP_UI_ID, path: viewUrlPath }), + navigateAppId: APP_UI_ID, + navigateOptions: { path: viewUrlPath }, + 'data-test-subj': 'view-full-details-action', + }; const item = artifact as ExceptionListItemSchema; return { expanded: expandedItemsMap.get(item.id) || false, - actions: [], + actions: [fullDetailsAction], policies: artifactCardPolicies, }; }; From 7dfad91dc77f9ad30ecb5fc346a46ffa482aab4d Mon Sep 17 00:00:00 2001 From: Vitalii Dmyterko <92328789+vitaliidm@users.noreply.github.com> Date: Thu, 6 Jan 2022 15:21:10 +0000 Subject: [PATCH 6/6] [Security Solution][Detections] Adds Bulk edit API (#120472) [Security Solution][Detections] Adds Bulk edit API (#120472) --- .../security_solution/common/constants.ts | 27 ++ .../schemas/common/schemas.ts | 47 +++ .../perform_bulk_action_schema.mock.ts | 8 +- .../perform_bulk_action_schema.test.ts | 393 +++++++++++++++--- .../request/perform_bulk_action_schema.ts | 34 +- .../common/endpoint/constants.ts | 2 - .../rules/rules_table/rules_table_reducer.ts | 1 + .../detection_engine/rules/types.ts | 1 + .../endpoint/routes/limited_concurrency.ts | 73 ---- .../routes/__mocks__/request_responses.ts | 12 +- .../routes/rules/patch_rules_bulk_route.ts | 3 - .../routes/rules/patch_rules_route.ts | 3 - .../rules/perform_bulk_action_route.test.ts | 189 ++++++++- .../routes/rules/perform_bulk_action_route.ts | 219 +++++++++- .../routes/rules/update_rules_bulk_route.ts | 3 - .../routes/rules/update_rules_route.ts | 3 - .../routes/rules/utils/import_rules_utils.ts | 3 - .../rules/bulk_action_edit.test.ts | 138 ++++++ .../rules/bulk_action_edit.ts | 73 ++++ .../rules/patch_rules.mock.ts | 8 - .../lib/detection_engine/rules/patch_rules.ts | 3 - .../lib/detection_engine/rules/types.ts | 12 +- .../rules/update_prepacked_rules.ts | 31 +- .../detection_engine/rules/update_rules.ts | 2 - .../lib/detection_engine/rules/utils.ts | 2 +- .../security_solution/server/plugin.ts | 2 +- .../server/routes/limited_concurrency.ts | 114 +++++ .../server/utils/promise_pool.test.ts | 51 ++- .../server/utils/promise_pool.ts | 18 +- .../utils/route_limited_concurrency_tag.ts | 17 + .../tests/perform_bulk_action.ts | 226 ++++++++-- 31 files changed, 1458 insertions(+), 260 deletions(-) delete mode 100644 x-pack/plugins/security_solution/server/endpoint/routes/limited_concurrency.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/bulk_action_edit.test.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/bulk_action_edit.ts create mode 100644 x-pack/plugins/security_solution/server/routes/limited_concurrency.ts create mode 100644 x-pack/plugins/security_solution/server/utils/route_limited_concurrency_tag.ts diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 7bb433738b30a..a99a3f8ee2fe9 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -375,3 +375,30 @@ export const WARNING_TRANSFORM_STATES = new Set([ TRANSFORM_STATES.STOPPED, TRANSFORM_STATES.STOPPING, ]); + +/** + * How many rules to update at a time is set to 50 from errors coming from + * the slow environments such as cloud when the rule updates are > 100 we were + * seeing timeout issues. + * + * Since there is not timeout options at the alerting API level right now, we are + * at the mercy of the Elasticsearch server client/server default timeouts and what + * we are doing could be considered a workaround to not being able to increase the timeouts. + * + * However, other bad effects and saturation of connections beyond 50 makes this a "noisy neighbor" + * if we don't limit its number of connections as we increase the number of rules that can be + * installed at a time. + * + * Lastly, we saw weird issues where Chrome on upstream 408 timeouts will re-call the REST route + * which in turn could create additional connections we want to avoid. + * + * See file import_rules_route.ts for another area where 50 was chosen, therefore I chose + * 50 here to mimic it as well. If you see this re-opened or what similar to it, consider + * reducing the 50 above to a lower number. + * + * See the original ticket here: + * https://github.com/elastic/kibana/issues/94418 + */ +export const MAX_RULES_TO_UPDATE_IN_PARALLEL = 50; + +export const LIMITED_CONCURRENCY_ROUTE_TAG_PREFIX = `${APP_ID}:limitedConcurrency`; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts index 23c45c03b62a0..7e4a4fd1295bd 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts @@ -453,6 +453,53 @@ export enum BulkAction { 'export' = 'export', 'delete' = 'delete', 'duplicate' = 'duplicate', + 'edit' = 'edit', } export const bulkAction = enumeration('BulkAction', BulkAction); + +export enum BulkActionEditType { + 'add_tags' = 'add_tags', + 'delete_tags' = 'delete_tags', + 'set_tags' = 'set_tags', + 'add_index_patterns' = 'add_index_patterns', + 'delete_index_patterns' = 'delete_index_patterns', + 'set_index_patterns' = 'set_index_patterns', + 'set_timeline' = 'set_timeline', +} + +export const bulkActionEditType = enumeration('BulkActionEditType', BulkActionEditType); + +const bulkActionEditPayloadTags = t.type({ + type: t.union([ + t.literal(BulkActionEditType.add_tags), + t.literal(BulkActionEditType.delete_tags), + t.literal(BulkActionEditType.set_tags), + ]), + value: tags, +}); + +const bulkActionEditPayloadIndexPatterns = t.type({ + type: t.union([ + t.literal(BulkActionEditType.add_index_patterns), + t.literal(BulkActionEditType.delete_index_patterns), + t.literal(BulkActionEditType.set_index_patterns), + ]), + value: index, +}); + +const bulkActionEditPayloadTimeline = t.type({ + type: t.literal(BulkActionEditType.set_timeline), + value: t.type({ + timeline_id, + timeline_title, + }), +}); + +export const bulkActionEditPayload = t.union([ + bulkActionEditPayloadTags, + bulkActionEditPayloadIndexPatterns, + bulkActionEditPayloadTimeline, +]); + +export type BulkActionEditPayload = t.TypeOf; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/perform_bulk_action_schema.mock.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/perform_bulk_action_schema.mock.ts index cb78168fbec6e..b6c241dfd15d2 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/perform_bulk_action_schema.mock.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/perform_bulk_action_schema.mock.ts @@ -5,10 +5,16 @@ * 2.0. */ -import { BulkAction } from '../common/schemas'; +import { BulkAction, BulkActionEditType } from '../common/schemas'; import { PerformBulkActionSchema } from './perform_bulk_action_schema'; export const getPerformBulkActionSchemaMock = (): PerformBulkActionSchema => ({ query: '', action: BulkAction.disable, }); + +export const getPerformBulkActionEditSchemaMock = (): PerformBulkActionSchema => ({ + query: '', + action: BulkAction.edit, + [BulkAction.edit]: [{ type: BulkActionEditType.add_tags, value: ['tag1'] }], +}); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/perform_bulk_action_schema.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/perform_bulk_action_schema.test.ts index a9707b88f5240..855b7ea506d81 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/perform_bulk_action_schema.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/perform_bulk_action_schema.test.ts @@ -8,61 +8,358 @@ import { performBulkActionSchema, PerformBulkActionSchema } from './perform_bulk_action_schema'; import { exactCheck, foldLeftRight, getPaths } from '@kbn/securitysolution-io-ts-utils'; import { left } from 'fp-ts/lib/Either'; -import { BulkAction } from '../common/schemas'; +import { BulkAction, BulkActionEditType } from '../common/schemas'; + +const retrieveValidationMessage = (payload: unknown) => { + const decoded = performBulkActionSchema.decode(payload); + const checked = exactCheck(payload, decoded); + return foldLeftRight(checked); +}; describe('perform_bulk_action_schema', () => { - test('query and action is valid', () => { - const payload: PerformBulkActionSchema = { - query: 'name: test', - action: BulkAction.enable, - }; - - const decoded = performBulkActionSchema.decode(payload); - const checked = exactCheck(payload, decoded); - const message = foldLeftRight(checked); - expect(getPaths(left(message.errors))).toEqual([]); - expect(message.schema).toEqual(payload); + describe('cases common to every bulk action', () => { + // missing query means it will request for all rules + test('valid request: missing query', () => { + const payload: PerformBulkActionSchema = { + query: undefined, + action: BulkAction.enable, + }; + const message = retrieveValidationMessage(payload); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('invalid request: missing action', () => { + const payload: Omit = { + query: 'name: test', + }; + const message = retrieveValidationMessage(payload); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "action"', + 'Invalid value "undefined" supplied to "edit"', + ]); + expect(message.schema).toEqual({}); + }); + + test('invalid request: unknown action', () => { + const payload: Omit & { action: 'unknown' } = { + query: 'name: test', + action: 'unknown', + }; + const message = retrieveValidationMessage(payload); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "unknown" supplied to "action"', + 'Invalid value "undefined" supplied to "edit"', + ]); + expect(message.schema).toEqual({}); + }); + + test('invalid request: unknown property', () => { + const payload = { + query: 'name: test', + action: BulkAction.enable, + ids: ['id'], + }; + const message = retrieveValidationMessage(payload); + + expect(getPaths(left(message.errors))).toEqual(['invalid keys "ids,["id"]"']); + expect(message.schema).toEqual({}); + }); + }); + + describe('bulk enable', () => { + test('valid request', () => { + const payload: PerformBulkActionSchema = { + query: 'name: test', + action: BulkAction.enable, + }; + const message = retrieveValidationMessage(payload); + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + }); + + describe('bulk disable', () => { + test('valid request', () => { + const payload: PerformBulkActionSchema = { + query: 'name: test', + action: BulkAction.disable, + }; + const message = retrieveValidationMessage(payload); + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + }); + + describe('bulk export', () => { + test('valid request', () => { + const payload: PerformBulkActionSchema = { + query: 'name: test', + action: BulkAction.export, + }; + const message = retrieveValidationMessage(payload); + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); }); - test('missing query is valid', () => { - const payload: PerformBulkActionSchema = { - query: undefined, - action: BulkAction.enable, - }; - - const decoded = performBulkActionSchema.decode(payload); - const checked = exactCheck(payload, decoded); - const message = foldLeftRight(checked); - expect(getPaths(left(message.errors))).toEqual([]); - expect(message.schema).toEqual(payload); + describe('bulk delete', () => { + test('valid request', () => { + const payload: PerformBulkActionSchema = { + query: 'name: test', + action: BulkAction.delete, + }; + const message = retrieveValidationMessage(payload); + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); }); - test('missing action is invalid', () => { - const payload: Omit = { - query: 'name: test', - }; - - const decoded = performBulkActionSchema.decode(payload); - const checked = exactCheck(payload, decoded); - const message = foldLeftRight(checked); - expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "undefined" supplied to "action"', - ]); - expect(message.schema).toEqual({}); + describe('bulk duplicate', () => { + test('valid request', () => { + const payload: PerformBulkActionSchema = { + query: 'name: test', + action: BulkAction.duplicate, + }; + const message = retrieveValidationMessage(payload); + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); }); - test('unknown action is invalid', () => { - const payload: Omit & { action: 'unknown' } = { - query: 'name: test', - action: 'unknown', - }; - - const decoded = performBulkActionSchema.decode(payload); - const checked = exactCheck(payload, decoded); - const message = foldLeftRight(checked); - expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "unknown" supplied to "action"', - ]); - expect(message.schema).toEqual({}); + describe('bulk edit', () => { + describe('cases common to every type of editing', () => { + test('invalid request: missing edit payload', () => { + const payload = { + query: 'name: test', + action: BulkAction.edit, + }; + + const message = retrieveValidationMessage(payload); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "edit" supplied to "action"', + 'Invalid value "undefined" supplied to "edit"', + ]); + expect(message.schema).toEqual({}); + }); + + test('invalid request: specified edit payload for another action', () => { + const payload = { + query: 'name: test', + action: BulkAction.enable, + [BulkAction.edit]: [{ type: BulkActionEditType.set_tags, value: ['test-tag'] }], + }; + + const message = retrieveValidationMessage(payload); + + expect(getPaths(left(message.errors))).toEqual([ + 'invalid keys "edit,[{"type":"set_tags","value":["test-tag"]}]"', + ]); + expect(message.schema).toEqual({}); + }); + + test('invalid request: wrong type for edit payload', () => { + const payload = { + query: 'name: test', + action: BulkAction.edit, + [BulkAction.edit]: { type: BulkActionEditType.set_tags, value: ['test-tag'] }, + }; + + const message = retrieveValidationMessage(payload); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "edit" supplied to "action"', + 'Invalid value "{"type":"set_tags","value":["test-tag"]}" supplied to "edit"', + ]); + expect(message.schema).toEqual({}); + }); + }); + + describe('tags', () => { + test('invalid request: wrong tags type', () => { + const payload = { + query: 'name: test', + action: BulkAction.edit, + [BulkAction.edit]: [{ type: BulkActionEditType.set_tags, value: 'test-tag' }], + }; + + const message = retrieveValidationMessage(payload); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "edit" supplied to "action"', + 'Invalid value "test-tag" supplied to "edit,value"', + 'Invalid value "set_tags" supplied to "edit,type"', + ]); + expect(message.schema).toEqual({}); + }); + + test('valid request: add_tags edit action', () => { + const payload = { + query: 'name: test', + action: BulkAction.edit, + [BulkAction.edit]: [{ type: BulkActionEditType.add_tags, value: ['test-tag'] }], + }; + + const message = retrieveValidationMessage(payload); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('valid request: set_tags edit action', () => { + const payload = { + query: 'name: test', + action: BulkAction.edit, + [BulkAction.edit]: [{ type: BulkActionEditType.set_tags, value: ['test-tag'] }], + }; + + const message = retrieveValidationMessage(payload); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('valid request: delete_tags edit action', () => { + const payload = { + query: 'name: test', + action: BulkAction.edit, + [BulkAction.edit]: [{ type: BulkActionEditType.delete_tags, value: ['test-tag'] }], + }; + + const message = retrieveValidationMessage(payload); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + }); + + describe('index_patterns', () => { + test('invalid request: wrong index_patterns type', () => { + const payload = { + query: 'name: test', + action: BulkAction.edit, + [BulkAction.edit]: [{ type: BulkActionEditType.set_tags, value: 'logs-*' }], + }; + + const message = retrieveValidationMessage(payload); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "edit" supplied to "action"', + 'Invalid value "logs-*" supplied to "edit,value"', + 'Invalid value "set_tags" supplied to "edit,type"', + ]); + expect(message.schema).toEqual({}); + }); + + test('valid request: set_index_patterns edit action', () => { + const payload: PerformBulkActionSchema = { + query: 'name: test', + action: BulkAction.edit, + [BulkAction.edit]: [{ type: BulkActionEditType.set_index_patterns, value: ['logs-*'] }], + }; + + const message = retrieveValidationMessage(payload); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('valid request: add_index_patterns edit action', () => { + const payload: PerformBulkActionSchema = { + query: 'name: test', + action: BulkAction.edit, + [BulkAction.edit]: [{ type: BulkActionEditType.add_index_patterns, value: ['logs-*'] }], + }; + + const message = retrieveValidationMessage(payload); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('valid request: delete_index_patterns edit action', () => { + const payload: PerformBulkActionSchema = { + query: 'name: test', + action: BulkAction.edit, + [BulkAction.edit]: [ + { type: BulkActionEditType.delete_index_patterns, value: ['logs-*'] }, + ], + }; + + const message = retrieveValidationMessage(payload); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + }); + + describe('timeline', () => { + test('invalid request: wrong timeline payload type', () => { + const payload = { + query: 'name: test', + action: BulkAction.edit, + [BulkAction.edit]: [{ type: BulkActionEditType.set_timeline, value: [] }], + }; + + const message = retrieveValidationMessage(payload); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "edit" supplied to "action"', + 'Invalid value "set_timeline" supplied to "edit,type"', + 'Invalid value "[]" supplied to "edit,value"', + ]); + expect(message.schema).toEqual({}); + }); + + test('invalid request: missing timeline_id', () => { + const payload = { + query: 'name: test', + action: BulkAction.edit, + [BulkAction.edit]: [ + { + type: BulkActionEditType.set_timeline, + value: { + timeline_title: 'Test timeline title', + }, + }, + ], + }; + + const message = retrieveValidationMessage(payload); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "edit" supplied to "action"', + 'Invalid value "set_timeline" supplied to "edit,type"', + 'Invalid value "{"timeline_title":"Test timeline title"}" supplied to "edit,value"', + 'Invalid value "undefined" supplied to "edit,value,timeline_id"', + ]); + expect(message.schema).toEqual({}); + }); + + test('valid request: set_timeline edit action', () => { + const payload: PerformBulkActionSchema = { + query: 'name: test', + action: BulkAction.edit, + [BulkAction.edit]: [ + { + type: BulkActionEditType.set_timeline, + value: { + timeline_id: 'timelineid', + timeline_title: 'Test timeline title', + }, + }, + ], + }; + + const message = retrieveValidationMessage(payload); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + }); }); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/perform_bulk_action_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/perform_bulk_action_schema.ts index adb26f107c8cd..02de2f845b85d 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/perform_bulk_action_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/perform_bulk_action_schema.ts @@ -6,13 +6,33 @@ */ import * as t from 'io-ts'; -import { bulkAction, queryOrUndefined } from '../common/schemas'; +import { BulkAction, queryOrUndefined, bulkActionEditPayload } from '../common/schemas'; -export const performBulkActionSchema = t.exact( - t.type({ - query: queryOrUndefined, - action: bulkAction, - }) -); +export const performBulkActionSchema = t.intersection([ + t.exact( + t.type({ + query: queryOrUndefined, + }) + ), + t.union([ + t.exact( + t.type({ + action: t.union([ + t.literal(BulkAction.delete), + t.literal(BulkAction.disable), + t.literal(BulkAction.duplicate), + t.literal(BulkAction.enable), + t.literal(BulkAction.export), + ]), + }) + ), + t.exact( + t.type({ + action: t.literal(BulkAction.edit), + [BulkAction.edit]: t.array(bulkActionEditPayload), + }) + ), + ]), +]); export type PerformBulkActionSchema = t.TypeOf; diff --git a/x-pack/plugins/security_solution/common/endpoint/constants.ts b/x-pack/plugins/security_solution/common/endpoint/constants.ts index 2b5182578d4b2..c8af729ec3a68 100644 --- a/x-pack/plugins/security_solution/common/endpoint/constants.ts +++ b/x-pack/plugins/security_solution/common/endpoint/constants.ts @@ -38,8 +38,6 @@ export const METADATA_UNITED_INDEX = '.metrics-endpoint.metadata_united_default' export const policyIndexPattern = 'metrics-endpoint.policy-*'; export const telemetryIndexPattern = 'metrics-endpoint.telemetry-*'; -export const LIMITED_CONCURRENCY_ENDPOINT_ROUTE_TAG = 'endpoint:limited-concurrency'; -export const LIMITED_CONCURRENCY_ENDPOINT_COUNT = 100; export const BASE_ENDPOINT_ROUTE = '/api/endpoint'; export const HOST_METADATA_LIST_ROUTE = `${BASE_ENDPOINT_ROUTE}/metadata`; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/rules_table_reducer.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/rules_table_reducer.ts index 7d32785222fed..2cc022ca7412c 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/rules_table_reducer.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/rules_table/rules_table_reducer.ts @@ -14,6 +14,7 @@ export type LoadingRuleAction = | 'disable' | 'export' | 'delete' + | 'edit' | null; export interface RulesTableState { diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts index 1411ed25b6e89..2507d5a9596b6 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts @@ -235,6 +235,7 @@ export type BulkActionResponse = { [BulkAction.enable]: BulkActionResult; [BulkAction.duplicate]: BulkActionResult; [BulkAction.export]: Blob; + [BulkAction.edit]: BulkActionResult; }[Action]; export interface BasicFetchProps { diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/limited_concurrency.ts b/x-pack/plugins/security_solution/server/endpoint/routes/limited_concurrency.ts deleted file mode 100644 index 916d78107e20f..0000000000000 --- a/x-pack/plugins/security_solution/server/endpoint/routes/limited_concurrency.ts +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { - CoreSetup, - KibanaRequest, - LifecycleResponseFactory, - OnPreAuthToolkit, -} from 'kibana/server'; -import { - LIMITED_CONCURRENCY_ENDPOINT_ROUTE_TAG, - LIMITED_CONCURRENCY_ENDPOINT_COUNT, -} from '../../../common/endpoint/constants'; - -class MaxCounter { - constructor(private readonly max: number = 1) {} - private counter = 0; - valueOf() { - return this.counter; - } - increase() { - if (this.counter < this.max) { - this.counter += 1; - } - } - decrease() { - if (this.counter > 0) { - this.counter -= 1; - } - } - lessThanMax() { - return this.counter < this.max; - } -} - -function shouldHandleRequest(request: KibanaRequest) { - const tags = request.route.options.tags; - return tags.includes(LIMITED_CONCURRENCY_ENDPOINT_ROUTE_TAG); -} - -export function registerLimitedConcurrencyRoutes(core: CoreSetup) { - const counter = new MaxCounter(LIMITED_CONCURRENCY_ENDPOINT_COUNT); - core.http.registerOnPreAuth(function preAuthHandler( - request: KibanaRequest, - response: LifecycleResponseFactory, - toolkit: OnPreAuthToolkit - ) { - if (!shouldHandleRequest(request)) { - return toolkit.next(); - } - - if (!counter.lessThanMax()) { - return response.customError({ - body: 'Too Many Requests', - statusCode: 429, - }); - } - - counter.increase(); - - // requests.events.aborted$ has a bug (but has test which explicitly verifies) where it's fired even when the request completes - // https://github.com/elastic/kibana/pull/70495#issuecomment-656288766 - request.events.aborted$.toPromise().then(() => { - counter.decrease(); - }); - - return toolkit.next(); - }); -} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts index 073a0bcebdc7a..d186c88e8458e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts @@ -36,7 +36,10 @@ import { getSignalsMigrationStatusSchemaMock } from '../../../../../common/detec import { RuleParams } from '../../schemas/rule_schemas'; import { SanitizedAlert, ResolvedSanitizedRule } from '../../../../../../alerting/common'; import { getQueryRuleParams } from '../../schemas/rule_schemas.mock'; -import { getPerformBulkActionSchemaMock } from '../../../../../common/detection_engine/schemas/request/perform_bulk_action_schema.mock'; +import { + getPerformBulkActionSchemaMock, + getPerformBulkActionEditSchemaMock, +} from '../../../../../common/detection_engine/schemas/request/perform_bulk_action_schema.mock'; import { RuleExecutionStatus } from '../../../../../common/detection_engine/schemas/common/schemas'; import { GetCurrentStatusBulkResult } from '../../rule_execution_log/types'; // eslint-disable-next-line no-restricted-imports @@ -127,6 +130,13 @@ export const getBulkActionRequest = () => body: getPerformBulkActionSchemaMock(), }); +export const getBulkActionEditRequest = () => + requestMock.create({ + method: 'patch', + path: DETECTION_ENGINE_RULES_BULK_ACTION, + body: getPerformBulkActionEditSchemaMock(), + }); + export const getDeleteBulkRequest = () => requestMock.create({ method: 'delete', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts index 9e821c8f686f6..1a79d12ae1b18 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts @@ -144,7 +144,6 @@ export const patchRulesBulkRoute = ( const rule = await patchRules({ rule: migratedRule, rulesClient, - savedObjectsClient, author, buildingBlockType, description, @@ -157,8 +156,6 @@ export const patchRulesBulkRoute = ( license, outputIndex, savedId, - spaceId: context.securitySolution.getSpaceId(), - ruleStatusClient, timelineId, timelineTitle, meta, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_route.ts index da3e4ccc99b99..6d11fc5851625 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_route.ts @@ -144,7 +144,6 @@ export const patchRulesRoute = ( const rule = await patchRules({ rulesClient, - savedObjectsClient, author, buildingBlockType, description, @@ -157,8 +156,6 @@ export const patchRulesRoute = ( license, outputIndex, savedId, - spaceId: context.securitySolution.getSpaceId(), - ruleStatusClient, timelineId, timelineTitle, meta, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.test.ts index 3e85b4898d01c..c99760b72b56b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.test.ts @@ -11,6 +11,7 @@ import { buildMlAuthz } from '../../../machine_learning/authz'; import { getEmptyFindResult, getBulkActionRequest, + getBulkActionEditRequest, getFindResultWithSingleHit, getFindResultWithMultiHits, } from '../__mocks__/request_responses'; @@ -18,24 +19,28 @@ import { requestContextMock, serverMock, requestMock } from '../__mocks__'; import { performBulkActionRoute } from './perform_bulk_action_route'; import { getPerformBulkActionSchemaMock } from '../../../../../common/detection_engine/schemas/request/perform_bulk_action_schema.mock'; import { loggingSystemMock } from 'src/core/server/mocks'; +import { isElasticRule } from '../../../../usage/detections'; jest.mock('../../../machine_learning/authz', () => mockMlAuthzFactory.create()); +jest.mock('../../../../usage/detections', () => ({ isElasticRule: jest.fn() })); describe.each([ ['Legacy', false], ['RAC', true], ])('perform_bulk_action - %s', (_, isRuleRegistryEnabled) => { + const isElasticRuleMock = isElasticRule as jest.Mock; let server: ReturnType; let { clients, context } = requestContextMock.createTools(); let ml: ReturnType; let logger: ReturnType; + const mockRule = getFindResultWithSingleHit(isRuleRegistryEnabled).data[0]; beforeEach(() => { server = serverMock.create(); logger = loggingSystemMock.createLogger(); ({ clients, context } = requestContextMock.createTools()); ml = mlServicesMock.createSetupContract(); - + isElasticRuleMock.mockReturnValue(false); clients.rulesClient.find.mockResolvedValue(getFindResultWithSingleHit(isRuleRegistryEnabled)); performBulkActionRoute(server.router, ml, logger, isRuleRegistryEnabled); @@ -73,20 +78,78 @@ describe.each([ expect(response.status).toEqual(404); expect(response.body).toEqual({ message: 'Not Found', status_code: 404 }); }); + }); + + describe('rules execution failures', () => { + it('returns error if rule is immutable/elastic', async () => { + isElasticRuleMock.mockReturnValue(true); + clients.rulesClient.find.mockResolvedValue( + getFindResultWithMultiHits({ + data: [mockRule], + total: 1, + }) + ); - it('catches error if disable throws error', async () => { + const response = await server.inject(getBulkActionEditRequest(), context); + + expect(response.status).toEqual(500); + expect(response.body).toEqual({ + message: 'Bulk edit failed', + status_code: 500, + attributes: { + errors: [ + { + message: 'Elastic rule can`t be edited', + status_code: 403, + rules: [ + { + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + name: 'Detect Root/Admin Users', + }, + ], + }, + ], + rules: { + failed: 1, + succeeded: 0, + total: 1, + }, + }, + }); + }); + + it('returns error if disable rule throws error', async () => { clients.rulesClient.disable.mockImplementation(async () => { throw new Error('Test error'); }); const response = await server.inject(getBulkActionRequest(), context); expect(response.status).toEqual(500); expect(response.body).toEqual({ - message: 'Test error', + message: 'Bulk edit failed', status_code: 500, + attributes: { + errors: [ + { + message: 'Test error', + status_code: 500, + rules: [ + { + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + name: 'Detect Root/Admin Users', + }, + ], + }, + ], + rules: { + failed: 1, + succeeded: 0, + total: 1, + }, + }, }); }); - it('rejects patching a rule if mlAuthz fails', async () => { + it('returns error if machine learning rule validation fails', async () => { (buildMlAuthz as jest.Mock).mockReturnValueOnce({ validateRuleType: jest .fn() @@ -94,12 +157,105 @@ describe.each([ }); const response = await server.inject(getBulkActionRequest(), context); - expect(response.status).toEqual(403); + expect(response.status).toEqual(500); + expect(response.body).toEqual({ + attributes: { + errors: [ + { + message: 'mocked validation message', + status_code: 403, + rules: [ + { + id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + name: 'Detect Root/Admin Users', + }, + ], + }, + ], + rules: { + failed: 1, + succeeded: 0, + total: 1, + }, + }, + message: 'Bulk edit failed', + status_code: 500, + }); + }); + + it('returns partial failure error if couple of rule validations fail and the rest are successfull', async () => { + clients.rulesClient.find.mockResolvedValue( + getFindResultWithMultiHits({ + data: [ + { ...mockRule, id: 'failed-rule-id-1' }, + { ...mockRule, id: 'failed-rule-id-2' }, + { ...mockRule, id: 'failed-rule-id-3' }, + mockRule, + mockRule, + ], + total: 5, + }) + ); + + (buildMlAuthz as jest.Mock).mockReturnValueOnce({ + validateRuleType: jest + .fn() + .mockImplementationOnce(() => ({ valid: false, message: 'mocked validation message' })) + .mockImplementationOnce(() => ({ valid: false, message: 'mocked validation message' })) + .mockImplementationOnce(() => ({ valid: false, message: 'test failure' })) + .mockImplementationOnce(() => ({ valid: true })) + .mockImplementationOnce(() => ({ valid: true })), + }); + const response = await server.inject(getBulkActionEditRequest(), context); + + expect(response.status).toEqual(500); expect(response.body).toEqual({ - message: 'mocked validation message', - status_code: 403, + attributes: { + rules: { + failed: 3, + succeeded: 2, + total: 5, + }, + errors: [ + { + message: 'mocked validation message', + status_code: 403, + rules: [ + { + id: 'failed-rule-id-1', + name: 'Detect Root/Admin Users', + }, + { + id: 'failed-rule-id-2', + name: 'Detect Root/Admin Users', + }, + ], + }, + { + message: 'test failure', + status_code: 403, + rules: [ + { + id: 'failed-rule-id-3', + name: 'Detect Root/Admin Users', + }, + ], + }, + ], + }, + message: 'Bulk edit partially failed', + status_code: 500, }); }); + + it('return error message limited to length of 1000, to prevent large response size', async () => { + clients.rulesClient.disable.mockImplementation(async () => { + throw new Error('a'.repeat(1_300)); + }); + const response = await server.inject(getBulkActionRequest(), context); + expect(response.status).toEqual(500); + expect(response.body.attributes.errors[0].message.length).toEqual(1000); + }); }); describe('request validation', () => { @@ -111,7 +267,7 @@ describe.each([ }); const result = server.validate(request); expect(result.badRequest).toHaveBeenCalledWith( - 'Invalid value "undefined" supplied to "action"' + 'Invalid value "undefined" supplied to "action",Invalid value "undefined" supplied to "edit"' ); }); @@ -123,7 +279,7 @@ describe.each([ }); const result = server.validate(request); expect(result.badRequest).toHaveBeenCalledWith( - 'Invalid value "unknown" supplied to "action"' + 'Invalid value "unknown" supplied to "action",Invalid value "undefined" supplied to "edit"' ); }); @@ -149,4 +305,19 @@ describe.each([ expect(result.ok).toHaveBeenCalled(); }); }); + + it('should process large number of rules, larger than configured concurrency', async () => { + const rulesNumber = 6_000; + clients.rulesClient.find.mockResolvedValue( + getFindResultWithMultiHits({ + data: Array.from({ length: rulesNumber }).map(() => mockRule), + total: rulesNumber, + }) + ); + + const response = await server.inject(getBulkActionEditRequest(), context); + + expect(response.status).toEqual(200); + expect(response.body).toEqual({ success: true, rules_count: rulesNumber }); + }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.ts index fb766124ea6ee..f263cd7b9cec1 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.ts @@ -5,25 +5,129 @@ * 2.0. */ +import moment from 'moment'; import { transformError } from '@kbn/securitysolution-es-utils'; import { Logger } from 'src/core/server'; -import { DETECTION_ENGINE_RULES_BULK_ACTION } from '../../../../../common/constants'; +import { RuleAlertType as Rule } from '../../rules/types'; + +import { + DETECTION_ENGINE_RULES_BULK_ACTION, + MAX_RULES_TO_UPDATE_IN_PARALLEL, +} from '../../../../../common/constants'; import { BulkAction } from '../../../../../common/detection_engine/schemas/common/schemas'; import { performBulkActionSchema } from '../../../../../common/detection_engine/schemas/request/perform_bulk_action_schema'; import { SetupPlugins } from '../../../../plugin'; import type { SecuritySolutionPluginRouter } from '../../../../types'; import { buildRouteValidation } from '../../../../utils/build_validation/route_validation'; +import { routeLimitedConcurrencyTag } from '../../../../utils/route_limited_concurrency_tag'; +import { initPromisePool } from '../../../../utils/promise_pool'; +import { isElasticRule } from '../../../../usage/detections'; import { buildMlAuthz } from '../../../machine_learning/authz'; import { throwHttpError } from '../../../machine_learning/validation'; import { deleteRules } from '../../rules/delete_rules'; import { duplicateRule } from '../../rules/duplicate_rule'; import { enableRule } from '../../rules/enable_rule'; import { findRules } from '../../rules/find_rules'; +import { patchRules } from '../../rules/patch_rules'; +import { appplyBulkActionEditToRule } from '../../rules/bulk_action_edit'; import { getExportByObjectIds } from '../../rules/get_export_by_object_ids'; import { buildSiemResponse } from '../utils'; -const BULK_ACTION_RULES_LIMIT = 10000; +const MAX_RULES_TO_PROCESS_TOTAL = 10000; +const MAX_ERROR_MESSAGE_LENGTH = 1000; +const MAX_ROUTE_CONCURRENCY = 5; + +type RuleActionFn = (rule: Rule) => Promise; + +type RuleActionSuccess = undefined; + +type RuleActionResult = RuleActionSuccess | RuleActionError; + +interface RuleActionError { + error: { + message: string; + statusCode: number; + }; + rule: { + id: string; + name: string; + }; +} + +interface NormalizedRuleError { + message: string; + status_code: number; + rules: Array<{ + id: string; + name: string; + }>; +} + +const normalizeErrorResponse = (errors: RuleActionError[]): NormalizedRuleError[] => { + const errorsMap = new Map(); + + errors.forEach((ruleError) => { + const { message } = ruleError.error; + if (errorsMap.has(message)) { + errorsMap.get(message).rules.push(ruleError.rule); + } else { + const { error, rule } = ruleError; + errorsMap.set(message, { + message: error.message, + status_code: error.statusCode, + rules: [rule], + }); + } + }); + + return Array.from(errorsMap, ([_, normalizedError]) => normalizedError); +}; + +const getErrorResponseBody = (errors: RuleActionError[], rulesCount: number) => { + const errorsCount = errors.length; + return { + message: errorsCount === rulesCount ? 'Bulk edit failed' : 'Bulk edit partially failed', + status_code: 500, + attributes: { + errors: normalizeErrorResponse(errors).map(({ message, ...error }) => ({ + ...error, + message: + message.length > MAX_ERROR_MESSAGE_LENGTH + ? `${message.slice(0, MAX_ERROR_MESSAGE_LENGTH - 3)}...` + : message, + })), + rules: { + total: rulesCount, + failed: errorsCount, + succeeded: rulesCount - errorsCount, + }, + }, + }; +}; + +const executeActionAndHandleErrors = async ( + rule: Rule, + action: RuleActionFn +): Promise => { + try { + await action(rule); + } catch (err) { + const { message, statusCode } = transformError(err); + return { + error: { message, statusCode }, + rule: { id: rule.id, name: rule.name }, + }; + } +}; + +const executeBulkAction = async (rules: Rule[], action: RuleActionFn, abortSignal: AbortSignal) => + initPromisePool({ + concurrency: MAX_RULES_TO_UPDATE_IN_PARALLEL, + items: rules, + executor: async (rule) => executeActionAndHandleErrors(rule, action), + abortSignal, + }); export const performBulkActionRoute = ( router: SecuritySolutionPluginRouter, @@ -38,12 +142,20 @@ export const performBulkActionRoute = ( body: buildRouteValidation(performBulkActionSchema), }, options: { - tags: ['access:securitySolution'], + tags: ['access:securitySolution', routeLimitedConcurrencyTag(MAX_ROUTE_CONCURRENCY)], + timeout: { + idleSocket: moment.duration(15, 'minutes').asMilliseconds(), + }, }, }, async (context, request, response) => { const { body } = request; const siemResponse = buildSiemResponse(response); + const abortController = new AbortController(); + + // subscribing to completed$, because it handles both cases when request was completed and aborted. + // when route is finished by timeout, aborted$ is not getting fired + request.events.completed$.subscribe(() => abortController.abort()); try { const rulesClient = context.alerting?.getRulesClient(); @@ -65,7 +177,7 @@ export const performBulkActionRoute = ( const rules = await findRules({ isRuleRegistryEnabled, rulesClient, - perPage: BULK_ACTION_RULES_LIMIT, + perPage: MAX_RULES_TO_PROCESS_TOTAL, filter: body.query !== '' ? body.query : undefined, page: undefined, sortField: undefined, @@ -73,17 +185,23 @@ export const performBulkActionRoute = ( fields: undefined, }); - if (rules.total > BULK_ACTION_RULES_LIMIT) { + if (rules.total > MAX_RULES_TO_PROCESS_TOTAL) { return siemResponse.error({ - body: `More than ${BULK_ACTION_RULES_LIMIT} rules matched the filter query. Try to narrow it down.`, + body: `More than ${MAX_RULES_TO_PROCESS_TOTAL} rules matched the filter query. Try to narrow it down.`, statusCode: 400, }); } + let processingResponse: { + results: RuleActionResult[]; + } = { + results: [], + }; switch (body.action) { case BulkAction.enable: - await Promise.all( - rules.data.map(async (rule) => { + processingResponse = await executeBulkAction( + rules.data, + async (rule) => { if (!rule.enabled) { throwHttpError(await mlAuthz.validateRuleType(rule.params.type)); await enableRule({ @@ -91,39 +209,46 @@ export const performBulkActionRoute = ( rulesClient, }); } - }) + }, + abortController.signal ); break; case BulkAction.disable: - await Promise.all( - rules.data.map(async (rule) => { + processingResponse = await executeBulkAction( + rules.data, + async (rule) => { if (rule.enabled) { throwHttpError(await mlAuthz.validateRuleType(rule.params.type)); await rulesClient.disable({ id: rule.id }); } - }) + }, + abortController.signal ); break; case BulkAction.delete: - await Promise.all( - rules.data.map(async (rule) => { + processingResponse = await executeBulkAction( + rules.data, + async (rule) => { await deleteRules({ ruleId: rule.id, rulesClient, ruleStatusClient, }); - }) + }, + abortController.signal ); break; case BulkAction.duplicate: - await Promise.all( - rules.data.map(async (rule) => { + processingResponse = await executeBulkAction( + rules.data, + async (rule) => { throwHttpError(await mlAuthz.validateRuleType(rule.params.type)); await rulesClient.create({ data: duplicateRule(rule, isRuleRegistryEnabled), }); - }) + }, + abortController.signal ); break; case BulkAction.export: @@ -145,9 +270,65 @@ export const performBulkActionRoute = ( }, body: responseBody, }); + case BulkAction.edit: + processingResponse = await executeBulkAction( + rules.data, + async (rule) => { + throwHttpError({ + valid: !isElasticRule(rule.tags), + message: 'Elastic rule can`t be edited', + }); + + throwHttpError(await mlAuthz.validateRuleType(rule.params.type)); + + const editedRule = body[BulkAction.edit].reduce( + (acc, action) => appplyBulkActionEditToRule(acc, action), + rule + ); + + const { tags, params: { timelineTitle, timelineId } = {} } = editedRule; + const index = 'index' in editedRule.params ? editedRule.params.index : undefined; + + await patchRules({ + rulesClient, + rule, + tags, + index, + timelineTitle, + timelineId, + }); + }, + abortController.signal + ); + } + + if (abortController.signal.aborted === true) { + throw Error('Bulk action was aborted'); + } + + const errors = processingResponse.results.filter( + (resp): resp is RuleActionError => resp?.error !== undefined + ); + const rulesCount = rules.data.length; + + if (errors.length > 0) { + const responseBody = getErrorResponseBody(errors, rulesCount); + + return response.custom({ + headers: { + 'content-type': 'application/json', + }, + body: Buffer.from(JSON.stringify(responseBody)), + statusCode: 500, + }); } - return response.ok({ body: { success: true, rules_count: rules.data.length } }); + return response.ok({ + body: { + success: true, + rules_count: rulesCount, + }, + }); } catch (err) { const error = transformError(err); return siemResponse.error({ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts index db4887f14108e..e3a125e50bfe9 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts @@ -85,13 +85,10 @@ export const updateRulesBulkRoute = ( }); const rule = await updateRules({ - spaceId: context.securitySolution.getSpaceId(), rulesClient, - ruleStatusClient, defaultOutputIndex: siemClient.getSignalsIndex(), existingRule: migratedRule, ruleUpdate: payloadRule, - isRuleRegistryEnabled, }); if (rule != null) { const ruleStatus = await ruleStatusClient.getCurrentStatus({ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.ts index d18171c489512..f8bb60eb5f77f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.ts @@ -76,12 +76,9 @@ export const updateRulesRoute = ( }); const rule = await updateRules({ defaultOutputIndex: siemClient.getSignalsIndex(), - isRuleRegistryEnabled, rulesClient, - ruleStatusClient, existingRule: migratedRule, ruleUpdate: request.body, - spaceId: context.securitySolution.getSpaceId(), }); if (rule != null) { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils/import_rules_utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils/import_rules_utils.ts index 02f3ab46f7cf2..3f0adaf58a2fd 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils/import_rules_utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils/import_rules_utils.ts @@ -243,11 +243,8 @@ export const importRules = async ({ }); await patchRules({ rulesClient, - savedObjectsClient, author, buildingBlockType, - spaceId, - ruleStatusClient, description, enabled, eventCategoryOverride, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/bulk_action_edit.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/bulk_action_edit.test.ts new file mode 100644 index 0000000000000..db6ef37cade36 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/bulk_action_edit.test.ts @@ -0,0 +1,138 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + addItemsToArray, + deleteItemsFromArray, + appplyBulkActionEditToRule, +} from './bulk_action_edit'; +import { BulkActionEditType } from '../../../../common/detection_engine/schemas/common/schemas'; +import { RuleAlertType } from './types'; +describe('bulk_action_edit', () => { + describe('addItemsToArray', () => { + test('should add single item to array', () => { + expect(addItemsToArray(['a', 'b', 'c'], ['d'])).toEqual(['a', 'b', 'c', 'd']); + }); + + test('should add multiple items to array', () => { + expect(addItemsToArray(['a', 'b', 'c'], ['d', 'e'])).toEqual(['a', 'b', 'c', 'd', 'e']); + }); + + test('should not allow to add duplicated items', () => { + expect(addItemsToArray(['a', 'c'], ['b', 'c'])).toEqual(['a', 'c', 'b']); + }); + }); + + describe('deleteItemsFromArray', () => { + test('should remove single item from array', () => { + expect(deleteItemsFromArray(['a', 'b', 'c'], ['c'])).toEqual(['a', 'b']); + }); + + test('should remove multiple items from array', () => { + expect(deleteItemsFromArray(['a', 'b', 'c'], ['b', 'c'])).toEqual(['a']); + }); + + test('should return array unchanged if items to remove absent in array', () => { + expect(deleteItemsFromArray(['a', 'c'], ['x', 'z'])).toEqual(['a', 'c']); + }); + }); + + describe('appplyBulkActionEditToRule', () => { + const ruleMock = { + tags: ['tag1', 'tag2'], + params: { index: ['initial-index-*'] }, + }; + describe('tags', () => { + test('should add new tags to rule', () => { + const editedRule = appplyBulkActionEditToRule(ruleMock as RuleAlertType, { + type: BulkActionEditType.add_tags, + value: ['new_tag'], + }); + expect(editedRule.tags).toEqual(['tag1', 'tag2', 'new_tag']); + }); + test('should remove tag from rule', () => { + const editedRule = appplyBulkActionEditToRule(ruleMock as RuleAlertType, { + type: BulkActionEditType.delete_tags, + value: ['tag1'], + }); + expect(editedRule.tags).toEqual(['tag2']); + }); + + test('should rewrite tags in rule', () => { + const editedRule = appplyBulkActionEditToRule(ruleMock as RuleAlertType, { + type: BulkActionEditType.set_tags, + value: ['tag_r_1', 'tag_r_2'], + }); + expect(editedRule.tags).toEqual(['tag_r_1', 'tag_r_2']); + }); + }); + + describe('index_patterns', () => { + test('should add new index pattern to rule', () => { + const editedRule = appplyBulkActionEditToRule(ruleMock as RuleAlertType, { + type: BulkActionEditType.add_index_patterns, + value: ['my-index-*'], + }); + expect(editedRule.params).toHaveProperty('index', ['initial-index-*', 'my-index-*']); + }); + test('should remove index pattern from rule', () => { + const editedRule = appplyBulkActionEditToRule(ruleMock as RuleAlertType, { + type: BulkActionEditType.delete_index_patterns, + value: ['initial-index-*'], + }); + expect(editedRule.params).toHaveProperty('index', []); + }); + + test('should rewrite index pattern in rule', () => { + const editedRule = appplyBulkActionEditToRule(ruleMock as RuleAlertType, { + type: BulkActionEditType.set_index_patterns, + value: ['index'], + }); + expect(editedRule.params).toHaveProperty('index', ['index']); + }); + + test('should not add new index pattern to rule if index pattern is absent', () => { + const editedRule = appplyBulkActionEditToRule({ params: {} } as RuleAlertType, { + type: BulkActionEditType.add_index_patterns, + value: ['my-index-*'], + }); + expect(editedRule.params).not.toHaveProperty('index'); + }); + + test('should not remove index pattern to rule if index pattern is absent', () => { + const editedRule = appplyBulkActionEditToRule({ params: {} } as RuleAlertType, { + type: BulkActionEditType.delete_index_patterns, + value: ['initial-index-*'], + }); + expect(editedRule.params).not.toHaveProperty('index'); + }); + + test('should not set index pattern to rule if index pattern is absent', () => { + const editedRule = appplyBulkActionEditToRule({ params: {} } as RuleAlertType, { + type: BulkActionEditType.set_index_patterns, + value: ['index-*'], + }); + expect(editedRule.params).not.toHaveProperty('index'); + }); + }); + + describe('timeline', () => { + test('should set timeline', () => { + const editedRule = appplyBulkActionEditToRule(ruleMock as RuleAlertType, { + type: BulkActionEditType.set_timeline, + value: { + timeline_id: '91832785-286d-4ebe-b884-1a208d111a70', + timeline_title: 'Test timeline', + }, + }); + + expect(editedRule.params.timelineId).toBe('91832785-286d-4ebe-b884-1a208d111a70'); + expect(editedRule.params.timelineTitle).toBe('Test timeline'); + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/bulk_action_edit.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/bulk_action_edit.ts new file mode 100644 index 0000000000000..0f56fd86be8ed --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/bulk_action_edit.ts @@ -0,0 +1,73 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { RuleAlertType } from './types'; + +import { + BulkActionEditPayload, + BulkActionEditType, +} from '../../../../common/detection_engine/schemas/common/schemas'; + +export const addItemsToArray = (arr: T[], items: T[]): T[] => + Array.from(new Set([...arr, ...items])); + +export const deleteItemsFromArray = (arr: T[], items: T[]): T[] => { + const itemsSet = new Set(items); + return arr.filter((item) => !itemsSet.has(item)); +}; + +export const appplyBulkActionEditToRule = ( + existingRule: RuleAlertType, + action: BulkActionEditPayload +): RuleAlertType => { + const rule = { ...existingRule, params: { ...existingRule.params } }; + switch (action.type) { + // tags actions + case BulkActionEditType.add_tags: + rule.tags = addItemsToArray(rule.tags ?? [], action.value); + break; + + case BulkActionEditType.delete_tags: + rule.tags = deleteItemsFromArray(rule.tags ?? [], action.value); + break; + + case BulkActionEditType.set_tags: + rule.tags = action.value; + break; + + // index_patterns actions + // index is not present in all rule types(machine learning). But it's mandatory for the rest. + // So we check if index is present and only in that case apply action + case BulkActionEditType.add_index_patterns: + if (rule.params && 'index' in rule.params) { + rule.params.index = addItemsToArray(rule.params.index ?? [], action.value); + } + break; + + case BulkActionEditType.delete_index_patterns: + if (rule.params && 'index' in rule.params) { + rule.params.index = deleteItemsFromArray(rule.params.index ?? [], action.value); + } + break; + + case BulkActionEditType.set_index_patterns: + if (rule.params && 'index' in rule.params) { + rule.params.index = action.value; + } + break; + + // timeline actions + case BulkActionEditType.set_timeline: + rule.params = { + ...rule.params, + timelineId: action.value.timeline_id, + timelineTitle: action.value.timeline_title, + }; + } + + return rule; +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.mock.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.mock.ts index 3a602a54ca099..2bd59abb1ea6f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.mock.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.mock.ts @@ -7,18 +7,13 @@ import { PatchRulesOptions } from './types'; import { rulesClientMock } from '../../../../../alerting/server/mocks'; -import { savedObjectsClientMock } from '../../../../../../../src/core/server/mocks'; import { getAlertMock } from '../routes/__mocks__/request_responses'; import { getMlRuleParams, getQueryRuleParams } from '../schemas/rule_schemas.mock'; -import { ruleExecutionLogClientMock } from '../rule_execution_log/__mocks__/rule_execution_log_client'; export const getPatchRulesOptionsMock = (isRuleRegistryEnabled: boolean): PatchRulesOptions => ({ author: ['Elastic'], buildingBlockType: undefined, rulesClient: rulesClientMock.create(), - savedObjectsClient: savedObjectsClientMock.create(), - spaceId: 'default', - ruleStatusClient: ruleExecutionLogClientMock.create(), anomalyThreshold: undefined, description: 'some description', enabled: true, @@ -71,9 +66,6 @@ export const getPatchMlRulesOptionsMock = (isRuleRegistryEnabled: boolean): Patc author: ['Elastic'], buildingBlockType: undefined, rulesClient: rulesClientMock.create(), - savedObjectsClient: savedObjectsClientMock.create(), - spaceId: 'default', - ruleStatusClient: ruleExecutionLogClientMock.create(), anomalyThreshold: 55, description: 'some description', enabled: true, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.ts index 8c256c54c24ab..a10247005c826 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.ts @@ -37,11 +37,8 @@ class PatchError extends Error { export const patchRules = async ({ rulesClient, - savedObjectsClient, author, buildingBlockType, - ruleStatusClient, - spaceId, description, eventCategoryOverride, falsePositives, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts index d4d96098477d1..e2ea5aefaea1a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts @@ -257,20 +257,17 @@ export interface CreateRulesOptions { } export interface UpdateRulesOptions { - isRuleRegistryEnabled: boolean; - spaceId: string; - ruleStatusClient: IRuleExecutionLogClient; rulesClient: RulesClient; defaultOutputIndex: string; existingRule: SanitizedAlert | null | undefined; ruleUpdate: UpdateRulesSchema; } -export interface PatchRulesOptions { - spaceId: string; - ruleStatusClient: IRuleExecutionLogClient; +export interface PatchRulesOptions extends Partial { rulesClient: RulesClient; - savedObjectsClient: SavedObjectsClientContract; + rule: SanitizedAlert | null | undefined; +} +interface PatchRulesFieldsOptions { anomalyThreshold: AnomalyThresholdOrUndefined; author: AuthorOrUndefined; buildingBlockType: BuildingBlockTypeOrUndefined; @@ -318,7 +315,6 @@ export interface PatchRulesOptions { version: VersionOrUndefined; exceptionsList: ListArrayOrUndefined; actions: RuleAlertAction[] | undefined; - rule: SanitizedAlert | null | undefined; namespace?: NamespaceOrUndefined; } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.ts index e24a6a883b6df..71ca8cf8f1dfa 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.ts @@ -8,6 +8,7 @@ import { chunk } from 'lodash/fp'; import { SavedObjectsClientContract } from 'kibana/server'; import { AddPrepackagedRulesSchemaDecoded } from '../../../../common/detection_engine/schemas/request/add_prepackaged_rules_schema'; +import { MAX_RULES_TO_UPDATE_IN_PARALLEL } from '../../../../common/constants'; import { RulesClient, PartialAlert } from '../../../../../alerting/server'; import { patchRules } from './patch_rules'; import { readRules } from './read_rules'; @@ -16,31 +17,6 @@ import { RuleParams } from '../schemas/rule_schemas'; import { IRuleExecutionLogClient } from '../rule_execution_log/types'; import { legacyMigrate } from './utils'; -/** - * How many rules to update at a time is set to 50 from errors coming from - * the slow environments such as cloud when the rule updates are > 100 we were - * seeing timeout issues. - * - * Since there is not timeout options at the alerting API level right now, we are - * at the mercy of the Elasticsearch server client/server default timeouts and what - * we are doing could be considered a workaround to not being able to increase the timeouts. - * - * However, other bad effects and saturation of connections beyond 50 makes this a "noisy neighbor" - * if we don't limit its number of connections as we increase the number of rules that can be - * installed at a time. - * - * Lastly, we saw weird issues where Chrome on upstream 408 timeouts will re-call the REST route - * which in turn could create additional connections we want to avoid. - * - * See file import_rules_route.ts for another area where 50 was chosen, therefore I chose - * 50 here to mimic it as well. If you see this re-opened or what similar to it, consider - * reducing the 50 above to a lower number. - * - * See the original ticket here: - * https://github.com/elastic/kibana/issues/94418 - */ -export const UPDATE_CHUNK_SIZE = 50; - /** * Updates the prepackaged rules given a set of rules and output index. * This implements a chunked approach to not saturate network connections and @@ -60,7 +36,7 @@ export const updatePrepackagedRules = async ( outputIndex: string, isRuleRegistryEnabled: boolean ): Promise => { - const ruleChunks = chunk(UPDATE_CHUNK_SIZE, rules); + const ruleChunks = chunk(MAX_RULES_TO_UPDATE_IN_PARALLEL, rules); for (const ruleChunk of ruleChunks) { const rulePromises = createPromises( rulesClient, @@ -162,7 +138,6 @@ export const createPromises = ( // or enable rules on the user when they were not expecting it if a rule updates return patchRules({ rulesClient, - savedObjectsClient, author, buildingBlockType, description, @@ -175,8 +150,6 @@ export const createPromises = ( outputIndex, rule: migratedRule, savedId, - spaceId, - ruleStatusClient, meta, filters, index, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts index 476a9e4d615f2..62c59bc6a698f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts @@ -27,9 +27,7 @@ class UpdateError extends Error { } export const updateRules = async ({ - spaceId, rulesClient, - ruleStatusClient, defaultOutputIndex, existingRule, ruleUpdate, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.ts index 4ab8afd796f6d..dee2006669f85 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.ts @@ -98,7 +98,7 @@ export interface UpdateProperties { timelineTitle: TimelineTitleOrUndefined; meta: MetaOrUndefined; machineLearningJobId: MachineLearningJobIdOrUndefined; - filters: PartialFilter[]; + filters: PartialFilter[] | undefined; index: IndexOrUndefined; interval: IntervalOrUndefined; maxSignals: MaxSignalsOrUndefined; diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index a33d43f7e080d..dffb984763818 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -37,6 +37,7 @@ import { createThresholdAlertType, } from './lib/detection_engine/rule_types'; import { initRoutes } from './routes'; +import { registerLimitedConcurrencyRoutes } from './routes/limited_concurrency'; import { isAlertExecutor } from './lib/detection_engine/signals/types'; import { signalRulesAlertType } from './lib/detection_engine/signals/signal_rule_alert_type'; import { ManifestTask } from './endpoint/lib/artifacts'; @@ -52,7 +53,6 @@ import { DEFAULT_ALERTS_INDEX, } from '../common/constants'; import { registerEndpointRoutes } from './endpoint/routes/metadata'; -import { registerLimitedConcurrencyRoutes } from './endpoint/routes/limited_concurrency'; import { registerResolverRoutes } from './endpoint/routes/resolver'; import { registerPolicyRoutes } from './endpoint/routes/policy'; import { registerActionRoutes } from './endpoint/routes/actions'; diff --git a/x-pack/plugins/security_solution/server/routes/limited_concurrency.ts b/x-pack/plugins/security_solution/server/routes/limited_concurrency.ts new file mode 100644 index 0000000000000..7e0b1686ee467 --- /dev/null +++ b/x-pack/plugins/security_solution/server/routes/limited_concurrency.ts @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + CoreSetup, + KibanaRequest, + LifecycleResponseFactory, + OnPreAuthToolkit, +} from 'kibana/server'; +import { LIMITED_CONCURRENCY_ROUTE_TAG_PREFIX } from '../../common/constants'; + +class MaxCounter { + constructor(private readonly max: number = 1) {} + private counter = 0; + valueOf() { + return this.counter; + } + increase() { + if (this.counter < this.max) { + this.counter += 1; + } + } + decrease() { + if (this.counter > 0) { + this.counter -= 1; + } + } + lessThanMax() { + return this.counter < this.max; + } +} + +function getRouteConcurrencyTag(request: KibanaRequest) { + const tags = request.route.options.tags; + return tags.find((tag) => tag.startsWith(LIMITED_CONCURRENCY_ROUTE_TAG_PREFIX)); +} + +function shouldHandleRequest(request: KibanaRequest) { + return getRouteConcurrencyTag(request) !== undefined; +} + +function getRouteMaxConcurrency(request: KibanaRequest) { + const tag = getRouteConcurrencyTag(request); + return parseInt(tag?.split(':')[2] || '', 10); +} + +const initCounterMap = () => { + const counterMap = new Map(); + const getCounter = (request: KibanaRequest) => { + const path = request.route.path; + + if (!counterMap.has(path)) { + const maxCount = getRouteMaxConcurrency(request); + if (isNaN(maxCount)) { + return null; + } + + counterMap.set(path, new MaxCounter(maxCount)); + } + + return counterMap.get(path) as MaxCounter; + }; + + return { + getCounter, + }; +}; + +/** + * This method limits concurrency for routes + * It checks if route has tag that begins LIMITED_CONCURRENCY_ROUTE_TAG_PREFIX prefix + * If tag is found and has concurrency number separated by colon ':', this max concurrency number will be applied to the route + * If tag is malformed, i.e. not valid concurrency number, max concurency will not be applied to the route + * @param core CoreSetup Context passed to the `setup` method of `standard` plugins. + */ +export function registerLimitedConcurrencyRoutes(core: CoreSetup) { + const countersMap = initCounterMap(); + + core.http.registerOnPreAuth(function preAuthHandler( + request: KibanaRequest, + response: LifecycleResponseFactory, + toolkit: OnPreAuthToolkit + ) { + if (!shouldHandleRequest(request)) { + return toolkit.next(); + } + + const counter = countersMap.getCounter(request); + + if (counter === null) { + return toolkit.next(); + } + + if (!counter.lessThanMax()) { + return response.customError({ + body: 'Too Many Requests', + statusCode: 429, + }); + } + + counter.increase(); + + // when request is completed or aborted, decrease counter + request.events.completed$.subscribe(() => { + counter.decrease(); + }); + + return toolkit.next(); + }); +} diff --git a/x-pack/plugins/security_solution/server/utils/promise_pool.test.ts b/x-pack/plugins/security_solution/server/utils/promise_pool.test.ts index 3a2e7ad160bd2..585044de5856a 100644 --- a/x-pack/plugins/security_solution/server/utils/promise_pool.test.ts +++ b/x-pack/plugins/security_solution/server/utils/promise_pool.test.ts @@ -9,7 +9,7 @@ import { initPromisePool } from './promise_pool'; const nextTick = () => new Promise((resolve) => setImmediate(resolve)); -const initPoolWithTasks = ({ concurrency = 1, items = [1, 2, 3] }) => { +const initPoolWithTasks = ({ concurrency = 1, items = [1, 2, 3] }, abortSignal?: AbortSignal) => { const asyncTasks: Record< number, { @@ -36,6 +36,7 @@ const initPoolWithTasks = ({ concurrency = 1, items = [1, 2, 3] }) => { }, }; }), + abortSignal, }); return [promisePool, asyncTasks] as const; @@ -112,7 +113,7 @@ describe('initPromisePool', () => { const { results, errors } = await promisePool; - // Check final reesuts + // Check final results expect(results).toEqual([1, 3]); expect(errors).toEqual([new Error(`Error processing 2`)]); }); @@ -167,8 +168,52 @@ describe('initPromisePool', () => { const { results, errors } = await promisePool; - // Check final reesuts + // Check final results expect(results).toEqual([1, 4, 5]); expect(errors).toEqual([new Error(`Error processing 2`), new Error(`Error processing 3`)]); }); + + it('should not execute tasks if abortSignal is aborted', async () => { + const abortSignal = { aborted: true }; + const [promisePool] = initPoolWithTasks( + { + concurrency: 2, + items: [1, 2, 3, 4, 5], + }, + abortSignal as AbortSignal + ); + + const { results, errors, abortedExecutionsCount } = await promisePool; + + // Check final results + expect(results).toEqual([]); + expect(errors).toEqual([]); + expect(abortedExecutionsCount).toEqual(5); + }); + + it('should abort executions of tasks if abortSignal was set to aborted during execution', async () => { + const abortSignal = { aborted: false }; + const [promisePool, asyncTasks] = initPoolWithTasks( + { + concurrency: 1, + items: [1, 2, 3], + }, + abortSignal as AbortSignal + ); + + // resolve first task, and abort execution + asyncTasks[1].resolve(); + expect(asyncTasks).toEqual({ + 1: expect.objectContaining({ status: 'resolved' }), + }); + + abortSignal.aborted = true; + + const { results, errors, abortedExecutionsCount } = await promisePool; + + // Check final results + expect(results).toEqual([1]); + expect(errors).toEqual([]); + expect(abortedExecutionsCount).toEqual(2); + }); }); diff --git a/x-pack/plugins/security_solution/server/utils/promise_pool.ts b/x-pack/plugins/security_solution/server/utils/promise_pool.ts index d0c848bc11787..ed0922b952c77 100644 --- a/x-pack/plugins/security_solution/server/utils/promise_pool.ts +++ b/x-pack/plugins/security_solution/server/utils/promise_pool.ts @@ -9,6 +9,7 @@ interface PromisePoolArgs { concurrency?: number; items: Item[]; executor: (item: Item) => Promise; + abortSignal?: AbortSignal; } /** @@ -18,13 +19,16 @@ interface PromisePoolArgs { * @param concurrency - number of tasks run in parallel * @param items - array of items to be passes to async executor * @param executor - an async function to be called with each provided item + * @param abortSignal - AbortSignal a signal object that allows to abort executing actions * - * @returns Struct holding results or errors of async tasks + * @returns Struct holding results or errors of async tasks, aborted executions count if applicable */ + export const initPromisePool = async ({ concurrency = 1, items, executor, + abortSignal, }: PromisePoolArgs) => { const tasks: Array> = []; const results: Result[] = []; @@ -37,6 +41,11 @@ export const initPromisePool = async ({ await Promise.race(tasks); } + // if abort signal was sent stop processing tasks further + if (abortSignal?.aborted === true) { + break; + } + const task: Promise = executor(item) .then((result) => { results.push(result); @@ -54,5 +63,10 @@ export const initPromisePool = async ({ // Wait for all remaining tasks to finish await Promise.all(tasks); - return { results, errors }; + const aborted = + abortSignal?.aborted === true + ? { abortedExecutionsCount: items.length - results.length - errors.length } + : undefined; + + return { results, errors, ...aborted }; }; diff --git a/x-pack/plugins/security_solution/server/utils/route_limited_concurrency_tag.ts b/x-pack/plugins/security_solution/server/utils/route_limited_concurrency_tag.ts new file mode 100644 index 0000000000000..95092a0e08218 --- /dev/null +++ b/x-pack/plugins/security_solution/server/utils/route_limited_concurrency_tag.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { LIMITED_CONCURRENCY_ROUTE_TAG_PREFIX } from '../../common/constants'; + +/** + * Generates max concurrency tag, that can be passed to route tags + * @param maxConcurrency - number max concurrency to add to tag + * @returns string generetad route tag + * + */ +export const routeLimitedConcurrencyTag = (maxConcurrency: number) => + [LIMITED_CONCURRENCY_ROUTE_TAG_PREFIX, maxConcurrency].join(':'); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/perform_bulk_action.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/perform_bulk_action.ts index bb117b50d5aed..1643c4851c024 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/perform_bulk_action.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/perform_bulk_action.ts @@ -11,7 +11,10 @@ import { DETECTION_ENGINE_RULES_BULK_ACTION, DETECTION_ENGINE_RULES_URL, } from '../../../../plugins/security_solution/common/constants'; -import { BulkAction } from '../../../../plugins/security_solution/common/detection_engine/schemas/common/schemas'; +import { + BulkAction, + BulkActionEditType, +} from '../../../../plugins/security_solution/common/detection_engine/schemas/common/schemas'; import { FtrProviderContext } from '../../common/ftr_provider_context'; import { binaryToString, @@ -29,6 +32,11 @@ export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); const log = getService('log'); + const postBulkAction = () => + supertest.post(DETECTION_ENGINE_RULES_BULK_ACTION).set('kbn-xsrf', 'true'); + const fetchRule = (ruleId: string) => + supertest.get(`${DETECTION_ENGINE_RULES_URL}?rule_id=${ruleId}`).set('kbn-xsrf', 'true'); + describe('perform_bulk_action', () => { beforeEach(async () => { await createSignalsIndex(supertest, log); @@ -42,9 +50,7 @@ export default ({ getService }: FtrProviderContext): void => { it('should export rules', async () => { await createRule(supertest, log, getSimpleRule()); - const { body } = await supertest - .post(DETECTION_ENGINE_RULES_BULK_ACTION) - .set('kbn-xsrf', 'true') + const { body } = await postBulkAction() .send({ query: '', action: BulkAction.export }) .expect(200) .expect('Content-Type', 'application/ndjson') @@ -75,36 +81,26 @@ export default ({ getService }: FtrProviderContext): void => { const ruleId = 'ruleId'; await createRule(supertest, log, getSimpleRule(ruleId)); - const { body } = await supertest - .post(DETECTION_ENGINE_RULES_BULK_ACTION) - .set('kbn-xsrf', 'true') + const { body } = await postBulkAction() .send({ query: '', action: BulkAction.delete }) .expect(200); expect(body).to.eql({ success: true, rules_count: 1 }); - await supertest - .get(`${DETECTION_ENGINE_RULES_URL}?rule_id=${ruleId}`) - .set('kbn-xsrf', 'true') - .expect(404); + await await fetchRule(ruleId).expect(404); }); it('should enable rules', async () => { const ruleId = 'ruleId'; await createRule(supertest, log, getSimpleRule(ruleId)); - const { body } = await supertest - .post(DETECTION_ENGINE_RULES_BULK_ACTION) - .set('kbn-xsrf', 'true') + const { body } = await postBulkAction() .send({ query: '', action: BulkAction.enable }) .expect(200); expect(body).to.eql({ success: true, rules_count: 1 }); - const { body: ruleBody } = await supertest - .get(`${DETECTION_ENGINE_RULES_URL}?rule_id=${ruleId}`) - .set('kbn-xsrf', 'true') - .expect(200); + const { body: ruleBody } = await fetchRule(ruleId).expect(200); const referenceRule = getSimpleRuleOutput(ruleId); referenceRule.enabled = true; @@ -118,18 +114,13 @@ export default ({ getService }: FtrProviderContext): void => { const ruleId = 'ruleId'; await createRule(supertest, log, getSimpleRule(ruleId, true)); - const { body } = await supertest - .post(DETECTION_ENGINE_RULES_BULK_ACTION) - .set('kbn-xsrf', 'true') + const { body } = await postBulkAction() .send({ query: '', action: BulkAction.disable }) .expect(200); expect(body).to.eql({ success: true, rules_count: 1 }); - const { body: ruleBody } = await supertest - .get(`${DETECTION_ENGINE_RULES_URL}?rule_id=${ruleId}`) - .set('kbn-xsrf', 'true') - .expect(200); + const { body: ruleBody } = await fetchRule(ruleId).expect(200); const referenceRule = getSimpleRuleOutput(ruleId); const storedRule = removeServerGeneratedProperties(ruleBody); @@ -141,9 +132,7 @@ export default ({ getService }: FtrProviderContext): void => { const ruleId = 'ruleId'; await createRule(supertest, log, getSimpleRule(ruleId)); - const { body } = await supertest - .post(DETECTION_ENGINE_RULES_BULK_ACTION) - .set('kbn-xsrf', 'true') + const { body } = await postBulkAction() .send({ query: '', action: BulkAction.duplicate }) .expect(200); @@ -156,5 +145,186 @@ export default ({ getService }: FtrProviderContext): void => { expect(rulesResponse.total).to.eql(2); }); + + describe('edit action', () => { + it('should set, add and delete tags in rules', async () => { + const ruleId = 'ruleId'; + const tags = ['tag1', 'tag2']; + await createRule(supertest, log, getSimpleRule(ruleId)); + + const { body: setTagsBody } = await postBulkAction() + .send({ + query: '', + action: BulkAction.edit, + [BulkAction.edit]: [ + { + type: BulkActionEditType.set_tags, + value: ['reset-tag'], + }, + ], + }) + .expect(200); + + expect(setTagsBody).to.eql({ success: true, rules_count: 1 }); + + const { body: setTagsRule } = await fetchRule(ruleId).expect(200); + + expect(setTagsRule.tags).to.eql(['reset-tag']); + + const { body: addTagsBody } = await postBulkAction() + .send({ + query: '', + action: BulkAction.edit, + [BulkAction.edit]: [ + { + type: BulkActionEditType.add_tags, + value: tags, + }, + ], + }) + .expect(200); + + expect(addTagsBody).to.eql({ success: true, rules_count: 1 }); + + const { body: addedTagsRule } = await fetchRule(ruleId).expect(200); + + expect(addedTagsRule.tags).to.eql(['reset-tag', ...tags]); + + await postBulkAction() + .send({ + query: '', + action: BulkAction.edit, + [BulkAction.edit]: [ + { + type: BulkActionEditType.delete_tags, + value: ['reset-tag', 'tag1'], + }, + ], + }) + .expect(200); + + const { body: deletedTagsRule } = await fetchRule(ruleId).expect(200); + + expect(deletedTagsRule.tags).to.eql(['tag2']); + }); + + it('should set, add and delete index patterns in rules', async () => { + const ruleId = 'ruleId'; + const indices = ['index1-*', 'index2-*']; + await createRule(supertest, log, getSimpleRule(ruleId)); + + const { body: setIndexBody } = await postBulkAction() + .send({ + query: '', + action: BulkAction.edit, + [BulkAction.edit]: [ + { + type: BulkActionEditType.set_index_patterns, + value: ['initial-index-*'], + }, + ], + }) + .expect(200); + + expect(setIndexBody).to.eql({ success: true, rules_count: 1 }); + + const { body: setIndexRule } = await fetchRule(ruleId).expect(200); + + expect(setIndexRule.index).to.eql(['initial-index-*']); + + const { body: addIndexBody } = await postBulkAction() + .send({ + query: '', + action: BulkAction.edit, + [BulkAction.edit]: [ + { + type: BulkActionEditType.add_index_patterns, + value: indices, + }, + ], + }) + .expect(200); + + expect(addIndexBody).to.eql({ success: true, rules_count: 1 }); + + const { body: addIndexRule } = await fetchRule(ruleId).expect(200); + + expect(addIndexRule.index).to.eql(['initial-index-*', ...indices]); + + await postBulkAction() + .send({ + query: '', + action: BulkAction.edit, + [BulkAction.edit]: [ + { + type: BulkActionEditType.delete_index_patterns, + value: ['index1-*'], + }, + ], + }) + .expect(200); + + const { body: deleteIndexRule } = await fetchRule(ruleId).expect(200); + + expect(deleteIndexRule.index).to.eql(['initial-index-*', 'index2-*']); + }); + + it('should set timeline values in rule', async () => { + const ruleId = 'ruleId'; + const timelineId = '91832785-286d-4ebe-b884-1a208d111a70'; + const timelineTitle = 'Test timeline'; + await createRule(supertest, log, getSimpleRule(ruleId)); + + const { body } = await postBulkAction() + .send({ + query: '', + action: BulkAction.edit, + [BulkAction.edit]: [ + { + type: BulkActionEditType.set_timeline, + value: { + timeline_id: timelineId, + timeline_title: timelineTitle, + }, + }, + ], + }) + .expect(200); + + expect(body).to.eql({ success: true, rules_count: 1 }); + + const { body: rule } = await fetchRule(ruleId).expect(200); + + expect(rule.timeline_id).to.eql(timelineId); + expect(rule.timeline_title).to.eql(timelineTitle); + }); + }); + + it('should limit concurrent requests to 5', async () => { + const ruleId = 'ruleId'; + const timelineId = '91832785-286d-4ebe-b884-1a208d111a70'; + const timelineTitle = 'Test timeline'; + await createRule(supertest, log, getSimpleRule(ruleId)); + + const responses = await Promise.all( + Array.from({ length: 10 }).map(() => + postBulkAction().send({ + query: '', + action: BulkAction.edit, + [BulkAction.edit]: [ + { + type: BulkActionEditType.set_timeline, + value: { + timeline_id: timelineId, + timeline_title: timelineTitle, + }, + }, + ], + }) + ) + ); + + expect(responses.filter((r) => r.body.statusCode === 429).length).to.eql(5); + }); }); };