From f48497b6d13fd0a53f684bd343ae2c8464e459ee Mon Sep 17 00:00:00 2001 From: Chris Roberson Date: Mon, 4 Nov 2019 11:22:31 -0500 Subject: [PATCH 1/2] [Monitoring] Ensure cloud cannot see setup mode (#49223) * Ensure cloud cannot see setup mode * Remove cloud check from collection status, as it's an injected var now * Man these tests suck --- .../public/components/no_data/no_data.js | 26 +++++---- .../monitoring/public/lib/setup_mode.js | 17 +++--- .../monitoring/public/lib/setup_mode.test.js | 55 ++++++++++++------- .../public/views/no_data/controller.js | 8 ++- .../setup/collection/get_collection_status.js | 1 - .../legacy/plugins/monitoring/ui_exports.js | 4 +- .../setup/collection/fixtures/detect_apm.json | 1 - .../collection/fixtures/detect_beats.json | 1 - .../fixtures/detect_beats_management.json | 1 - .../collection/fixtures/detect_logstash.json | 1 - .../fixtures/detect_logstash_management.json | 1 - .../fixtures/es_and_kibana_exclusive_mb.json | 1 - .../collection/fixtures/es_and_kibana_mb.json | 1 - .../fixtures/kibana_exclusive_mb.json | 1 - .../setup/collection/fixtures/kibana_mb.json | 1 - 15 files changed, 71 insertions(+), 49 deletions(-) diff --git a/x-pack/legacy/plugins/monitoring/public/components/no_data/no_data.js b/x-pack/legacy/plugins/monitoring/public/components/no_data/no_data.js index e50e49eec9f6e..f2f27499d113c 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/no_data/no_data.js +++ b/x-pack/legacy/plugins/monitoring/public/components/no_data/no_data.js @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useState } from 'react'; +import React, { Fragment, useState } from 'react'; import PropTypes from 'prop-types'; import { EuiSpacer, @@ -43,7 +43,7 @@ function NoDataMessage(props) { export function NoData(props) { const [isLoading, setIsLoading] = useState(false); - const [useInternalCollection, setUseInternalCollection] = useState(false); + const [useInternalCollection, setUseInternalCollection] = useState(props.isOnCloud); async function startSetup() { setIsLoading(true); @@ -64,15 +64,19 @@ export function NoData(props) { - - setUseInternalCollection(false)}> - - - - + { !props.isOnCloud ? ( + + + setUseInternalCollection(false)}> + + + + + + ) : null } diff --git a/x-pack/legacy/plugins/monitoring/public/lib/setup_mode.js b/x-pack/legacy/plugins/monitoring/public/lib/setup_mode.js index 3e7d182f1514c..7da419719e70c 100644 --- a/x-pack/legacy/plugins/monitoring/public/lib/setup_mode.js +++ b/x-pack/legacy/plugins/monitoring/public/lib/setup_mode.js @@ -7,6 +7,7 @@ import { ajaxErrorHandlersProvider } from './ajax_error_handler'; import { get, contains } from 'lodash'; import chrome from 'ui/chrome'; +import { i18n } from '@kbn/i18n'; function isOnPage(hash) { return contains(window.location.hash, hash); @@ -80,7 +81,7 @@ export const updateSetupModeData = async (uuid, fetchWithoutClusterUuid = false) const oldData = setupModeState.data; const data = await fetchCollectionData(uuid, fetchWithoutClusterUuid); setupModeState.data = data; - if (get(data, '_meta.isOnCloud', false)) { + if (chrome.getInjected('isOnCloud')) { return toggleSetupMode(false); // eslint-disable-line no-use-before-define } notifySetupModeDataChange(oldData); @@ -139,15 +140,17 @@ export const setSetupModeMenuItem = () => { } const globalState = angularState.injector.get('globalState'); - const navItems = globalState.inSetupMode - ? [] - : [{ + const navItems = []; + if (!globalState.inSetupMode && !chrome.getInjected('isOnCloud')) { + navItems.push({ id: 'enter', - label: 'Enter Setup Mode', - description: 'Enter setup', + label: i18n.translate('xpack.monitoring.setupMode.enter', { + defaultMessage: 'Enter Setup Mode' + }), run: () => toggleSetupMode(true), testId: 'enterSetupMode' - }]; + }); + } angularState.scope.topNavMenu = [...navItems]; // LOL angular diff --git a/x-pack/legacy/plugins/monitoring/public/lib/setup_mode.test.js b/x-pack/legacy/plugins/monitoring/public/lib/setup_mode.test.js index b5878c7ec5181..4e3a8045048ae 100644 --- a/x-pack/legacy/plugins/monitoring/public/lib/setup_mode.test.js +++ b/x-pack/legacy/plugins/monitoring/public/lib/setup_mode.test.js @@ -4,13 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - toggleSetupMode, - initSetupModeState, - getSetupModeState, - updateSetupModeData, - setSetupModeMenuItem -} from './setup_mode'; +let toggleSetupMode; +let initSetupModeState; +let getSetupModeState; +let updateSetupModeData; +let setSetupModeMenuItem; jest.mock('./ajax_error_handler', () => ({ ajaxErrorHandlersProvider: err => { @@ -52,16 +50,31 @@ function waitForSetupModeData(action) { process.nextTick(action); } +function setModules() { + jest.resetModules(); + injectorModulesMock.globalState.inSetupMode = false; + + const setupMode = require('./setup_mode'); + toggleSetupMode = setupMode.toggleSetupMode; + initSetupModeState = setupMode.initSetupModeState; + getSetupModeState = setupMode.getSetupModeState; + updateSetupModeData = setupMode.updateSetupModeData; + setSetupModeMenuItem = setupMode.setSetupModeMenuItem; +} + describe('setup_mode', () => { - describe('setup', () => { - afterEach(async () => { - try { - toggleSetupMode(false); - } catch (err) { - // Do nothing... + beforeEach(async () => { + jest.doMock('ui/chrome', () => ({ + getInjected: (key) => { + if (key === 'isOnCloud') { + return false; + } } - }); + })); + setModules(); + }); + describe('setup', () => { it('should require angular state', async () => { let error; try { @@ -99,21 +112,25 @@ describe('setup_mode', () => { describe('in setup mode', () => { afterEach(async () => { data = {}; - toggleSetupMode(false); }); it('should enable it through clicking top nav item', async () => { initSetupModeState(angularStateMock.scope, angularStateMock.injector); + setSetupModeMenuItem(); + expect(injectorModulesMock.globalState.inSetupMode).toBe(false); await angularStateMock.scope.topNavMenu[0].run(); expect(injectorModulesMock.globalState.inSetupMode).toBe(true); }); it('should not fetch data if on cloud', async (done) => { - data = { - _meta: { - isOnCloud: true + jest.doMock('ui/chrome', () => ({ + getInjected: (key) => { + if (key === 'isOnCloud') { + return true; + } } - }; + })); + setModules(); initSetupModeState(angularStateMock.scope, angularStateMock.injector); await toggleSetupMode(true); waitForSetupModeData(() => { diff --git a/x-pack/legacy/plugins/monitoring/public/views/no_data/controller.js b/x-pack/legacy/plugins/monitoring/public/views/no_data/controller.js index 0ecd6c83265ff..f364b70fd934f 100644 --- a/x-pack/legacy/plugins/monitoring/public/views/no_data/controller.js +++ b/x-pack/legacy/plugins/monitoring/public/views/no_data/controller.js @@ -5,6 +5,7 @@ */ import React from 'react'; +import chrome from 'ui/chrome'; import { ClusterSettingsChecker, NodeSettingsChecker, @@ -99,7 +100,12 @@ export class NoDataController extends MonitoringViewBaseController { this.renderReact( - + ); } diff --git a/x-pack/legacy/plugins/monitoring/server/lib/setup/collection/get_collection_status.js b/x-pack/legacy/plugins/monitoring/server/lib/setup/collection/get_collection_status.js index a49da8ba60200..cd5781ccf5344 100644 --- a/x-pack/legacy/plugins/monitoring/server/lib/setup/collection/get_collection_status.js +++ b/x-pack/legacy/plugins/monitoring/server/lib/setup/collection/get_collection_status.js @@ -547,7 +547,6 @@ export const getCollectionStatus = async (req, indexPatterns, clusterUuid, nodeU status._meta = { secondsAgo: NUMBER_OF_SECONDS_AGO_TO_LOOK, liveClusterUuid, - isOnCloud: get(req.server.plugins, 'cloud.config.isCloudEnabled', false) }; return status; diff --git a/x-pack/legacy/plugins/monitoring/ui_exports.js b/x-pack/legacy/plugins/monitoring/ui_exports.js index a10b83086f738..0976292be576b 100644 --- a/x-pack/legacy/plugins/monitoring/ui_exports.js +++ b/x-pack/legacy/plugins/monitoring/ui_exports.js @@ -5,6 +5,7 @@ */ import { i18n } from '@kbn/i18n'; +import { get } from 'lodash'; import { resolve } from 'path'; /** @@ -28,7 +29,8 @@ export const getUiExports = () => ({ injectDefaultVars(server) { const config = server.config(); return { - monitoringUiEnabled: config.get('xpack.monitoring.ui.enabled') + monitoringUiEnabled: config.get('xpack.monitoring.ui.enabled'), + isOnCloud: get(server.plugins, 'cloud.config.isCloudEnabled', false) }; }, hacks: [ 'plugins/monitoring/hacks/toggle_app_link_in_nav' ], diff --git a/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/detect_apm.json b/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/detect_apm.json index a791c2b2b7419..ddd8d8c9a1de6 100644 --- a/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/detect_apm.json +++ b/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/detect_apm.json @@ -51,7 +51,6 @@ }, "_meta": { "secondsAgo": 30, - "isOnCloud": false, "liveClusterUuid": null } } diff --git a/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/detect_beats.json b/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/detect_beats.json index 3ce2f20415b5f..fcde71551a4f3 100644 --- a/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/detect_beats.json +++ b/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/detect_beats.json @@ -60,7 +60,6 @@ }, "_meta": { "secondsAgo": 30, - "isOnCloud": false, "liveClusterUuid": null } } diff --git a/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/detect_beats_management.json b/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/detect_beats_management.json index a64e2f40b33dc..9186006415759 100644 --- a/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/detect_beats_management.json +++ b/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/detect_beats_management.json @@ -51,7 +51,6 @@ }, "_meta": { "secondsAgo": 30, - "isOnCloud": false, "liveClusterUuid": null } } diff --git a/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/detect_logstash.json b/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/detect_logstash.json index cc870216d405b..7311230d108e9 100644 --- a/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/detect_logstash.json +++ b/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/detect_logstash.json @@ -51,7 +51,6 @@ }, "_meta": { "secondsAgo": 30, - "isOnCloud": false, "liveClusterUuid": null } } diff --git a/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/detect_logstash_management.json b/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/detect_logstash_management.json index cc870216d405b..7311230d108e9 100644 --- a/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/detect_logstash_management.json +++ b/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/detect_logstash_management.json @@ -51,7 +51,6 @@ }, "_meta": { "secondsAgo": 30, - "isOnCloud": false, "liveClusterUuid": null } } diff --git a/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/es_and_kibana_exclusive_mb.json b/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/es_and_kibana_exclusive_mb.json index 4ae753aca5225..69688bc46c27e 100644 --- a/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/es_and_kibana_exclusive_mb.json +++ b/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/es_and_kibana_exclusive_mb.json @@ -71,7 +71,6 @@ }, "_meta": { "secondsAgo": 30, - "isOnCloud": false, "liveClusterUuid": null } } diff --git a/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/es_and_kibana_mb.json b/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/es_and_kibana_mb.json index 6935060b4d5f7..8b207b418dae7 100644 --- a/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/es_and_kibana_mb.json +++ b/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/es_and_kibana_mb.json @@ -71,7 +71,6 @@ }, "_meta": { "secondsAgo": 30, - "isOnCloud": false, "liveClusterUuid": null } } diff --git a/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/kibana_exclusive_mb.json b/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/kibana_exclusive_mb.json index 161ce32e8ff5f..319844a7e093d 100644 --- a/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/kibana_exclusive_mb.json +++ b/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/kibana_exclusive_mb.json @@ -71,7 +71,6 @@ }, "_meta": { "secondsAgo": 30, - "isOnCloud": false, "liveClusterUuid": null } } diff --git a/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/kibana_mb.json b/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/kibana_mb.json index b93edacd82b31..f65436ce28616 100644 --- a/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/kibana_mb.json +++ b/x-pack/test/api_integration/apis/monitoring/setup/collection/fixtures/kibana_mb.json @@ -71,7 +71,6 @@ }, "_meta": { "secondsAgo": 30, - "isOnCloud": false, "liveClusterUuid": null } } From 5ba237a8856716bd1d88f9b4fca224916e378e9a Mon Sep 17 00:00:00 2001 From: Patrick Mueller Date: Mon, 4 Nov 2019 23:28:54 -0500 Subject: [PATCH 2/2] [Telemetry] Remind users about telemetry on each minor version (#49644) resolves #49519 If a user has previously opted out of telemetry, this PR will cause them to be prompted again, when the major or minor version of Kibana changes. Previously, once opted out, they would never get prompted again. --- src/legacy/core_plugins/telemetry/index.ts | 18 +- .../core_plugins/telemetry/mappings.json | 3 + .../server/get_telemetry_opt_in.test.ts | 214 ++++++++++++++++++ .../telemetry/server/get_telemetry_opt_in.ts | 66 +++++- .../core_plugins/telemetry/server/index.ts | 2 +- .../core_plugins/telemetry/server/plugin.ts | 11 +- .../telemetry/server/routes/index.ts | 9 +- .../telemetry/server/routes/opt_in.ts | 31 ++- .../api_integration/apis/telemetry/index.js | 1 + .../api_integration/apis/telemetry/opt_in.ts | 63 ++++++ 10 files changed, 397 insertions(+), 21 deletions(-) create mode 100644 src/legacy/core_plugins/telemetry/server/get_telemetry_opt_in.test.ts create mode 100644 x-pack/test/api_integration/apis/telemetry/opt_in.ts diff --git a/src/legacy/core_plugins/telemetry/index.ts b/src/legacy/core_plugins/telemetry/index.ts index 3271373449eb3..4b6566415f3e1 100644 --- a/src/legacy/core_plugins/telemetry/index.ts +++ b/src/legacy/core_plugins/telemetry/index.ts @@ -48,6 +48,9 @@ const telemetry = (kibana: any) => { // `config` is used internally and not intended to be set config: Joi.string().default(Joi.ref('$defaultConfigPath')), banner: Joi.boolean().default(true), + lastVersionChecked: Joi.string() + .allow('') + .default(''), url: Joi.when('$dev', { is: true, then: Joi.string().default( @@ -77,7 +80,8 @@ const telemetry = (kibana: any) => { }, }, async replaceInjectedVars(originalInjectedVars: any, request: any) { - const telemetryOptedIn = await getTelemetryOptIn(request); + const currentKibanaVersion = getCurrentKibanaVersion(request.server); + const telemetryOptedIn = await getTelemetryOptIn({ request, currentKibanaVersion }); return { ...originalInjectedVars, @@ -97,7 +101,13 @@ const telemetry = (kibana: any) => { mappings, }, init(server: Server) { - const initializerContext = {} as PluginInitializerContext; + const initializerContext = { + env: { + packageInfo: { + version: getCurrentKibanaVersion(server), + }, + }, + } as PluginInitializerContext; const coreSetup = ({ http: { server }, @@ -116,3 +126,7 @@ const telemetry = (kibana: any) => { // eslint-disable-next-line import/no-default-export export default telemetry; + +function getCurrentKibanaVersion(server: Server): string { + return server.config().get('pkg.version'); +} diff --git a/src/legacy/core_plugins/telemetry/mappings.json b/src/legacy/core_plugins/telemetry/mappings.json index d83f7f5967630..1245ef88f5892 100644 --- a/src/legacy/core_plugins/telemetry/mappings.json +++ b/src/legacy/core_plugins/telemetry/mappings.json @@ -3,6 +3,9 @@ "properties": { "enabled": { "type": "boolean" + }, + "lastVersionChecked": { + "type": "keyword" } } } diff --git a/src/legacy/core_plugins/telemetry/server/get_telemetry_opt_in.test.ts b/src/legacy/core_plugins/telemetry/server/get_telemetry_opt_in.test.ts new file mode 100644 index 0000000000000..67ad3aaae427d --- /dev/null +++ b/src/legacy/core_plugins/telemetry/server/get_telemetry_opt_in.test.ts @@ -0,0 +1,214 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { getTelemetryOptIn } from './get_telemetry_opt_in'; + +describe('get_telemetry_opt_in', () => { + it('returns false when request path is not /app*', async () => { + const params = getCallGetTelemetryOptInParams({ + requestPath: '/foo/bar', + }); + + const result = await callGetTelemetryOptIn(params); + + expect(result).toBe(false); + }); + + it('returns null when saved object not found', async () => { + const params = getCallGetTelemetryOptInParams({ + savedObjectNotFound: true, + }); + + const result = await callGetTelemetryOptIn(params); + + expect(result).toBe(null); + }); + + it('returns false when saved object forbidden', async () => { + const params = getCallGetTelemetryOptInParams({ + savedObjectForbidden: true, + }); + + const result = await callGetTelemetryOptIn(params); + + expect(result).toBe(false); + }); + + it('throws an error on unexpected saved object error', async () => { + const params = getCallGetTelemetryOptInParams({ + savedObjectOtherError: true, + }); + + let threw = false; + try { + await callGetTelemetryOptIn(params); + } catch (err) { + threw = true; + expect(err.message).toBe(SavedObjectOtherErrorMessage); + } + + expect(threw).toBe(true); + }); + + it('returns null if enabled is null or undefined', async () => { + for (const enabled of [null, undefined]) { + const params = getCallGetTelemetryOptInParams({ + enabled, + }); + + const result = await callGetTelemetryOptIn(params); + + expect(result).toBe(null); + } + }); + + it('returns true when enabled is true', async () => { + const params = getCallGetTelemetryOptInParams({ + enabled: true, + }); + + const result = await callGetTelemetryOptIn(params); + + expect(result).toBe(true); + }); + + // build a table of tests with version checks, with results for enabled false + type VersionCheckTable = Array>; + + const EnabledFalseVersionChecks: VersionCheckTable = [ + { lastVersionChecked: '8.0.0', currentKibanaVersion: '8.0.0', result: false }, + { lastVersionChecked: '8.0.0', currentKibanaVersion: '8.0.1', result: false }, + { lastVersionChecked: '8.0.1', currentKibanaVersion: '8.0.0', result: false }, + { lastVersionChecked: '8.0.0', currentKibanaVersion: '8.1.0', result: null }, + { lastVersionChecked: '8.0.0', currentKibanaVersion: '9.0.0', result: null }, + { lastVersionChecked: '8.0.0', currentKibanaVersion: '7.0.0', result: false }, + { lastVersionChecked: '8.1.0', currentKibanaVersion: '8.0.0', result: false }, + { lastVersionChecked: '8.0.0-X', currentKibanaVersion: '8.0.0', result: false }, + { lastVersionChecked: '8.0.0', currentKibanaVersion: '8.0.0-X', result: false }, + { lastVersionChecked: null, currentKibanaVersion: '8.0.0', result: null }, + { lastVersionChecked: undefined, currentKibanaVersion: '8.0.0', result: null }, + { lastVersionChecked: 5, currentKibanaVersion: '8.0.0', result: null }, + { lastVersionChecked: '8.0.0', currentKibanaVersion: 'beta', result: null }, + { lastVersionChecked: 'beta', currentKibanaVersion: '8.0.0', result: null }, + { lastVersionChecked: 'beta', currentKibanaVersion: 'beta', result: false }, + { lastVersionChecked: 'BETA', currentKibanaVersion: 'beta', result: null }, + ].map(el => ({ ...el, enabled: false })); + + // build a table of tests with version checks, with results for enabled true/null/undefined + const EnabledTrueVersionChecks: VersionCheckTable = EnabledFalseVersionChecks.map(el => ({ + ...el, + enabled: true, + result: true, + })); + + const EnabledNullVersionChecks: VersionCheckTable = EnabledFalseVersionChecks.map(el => ({ + ...el, + enabled: null, + result: null, + })); + + const EnabledUndefinedVersionChecks: VersionCheckTable = EnabledFalseVersionChecks.map(el => ({ + ...el, + enabled: undefined, + result: null, + })); + + const AllVersionChecks = [ + ...EnabledFalseVersionChecks, + ...EnabledTrueVersionChecks, + ...EnabledNullVersionChecks, + ...EnabledUndefinedVersionChecks, + ]; + + test.each(AllVersionChecks)( + 'returns expected result for version check with %j', + async (params: Partial) => { + const result = await callGetTelemetryOptIn({ ...DefaultParams, ...params }); + expect(result).toBe(params.result); + } + ); +}); + +interface CallGetTelemetryOptInParams { + requestPath: string; + savedObjectNotFound: boolean; + savedObjectForbidden: boolean; + savedObjectOtherError: boolean; + enabled: boolean | null | undefined; + lastVersionChecked?: any; // should be a string, but test with non-strings + currentKibanaVersion: string; + result?: boolean | null; +} + +const DefaultParams = { + requestPath: '/app/something', + savedObjectNotFound: false, + savedObjectForbidden: false, + savedObjectOtherError: false, + enabled: true, + lastVersionChecked: '8.0.0', + currentKibanaVersion: '8.0.0', +}; + +function getCallGetTelemetryOptInParams( + overrides: Partial +): CallGetTelemetryOptInParams { + return { ...DefaultParams, ...overrides }; +} + +async function callGetTelemetryOptIn(params: CallGetTelemetryOptInParams): Promise { + const { currentKibanaVersion } = params; + const request = getMockRequest(params); + return await getTelemetryOptIn({ request, currentKibanaVersion }); +} + +function getMockRequest(params: CallGetTelemetryOptInParams): any { + return { + path: params.requestPath, + getSavedObjectsClient() { + return getMockSavedObjectsClient(params); + }, + }; +} + +const SavedObjectNotFoundMessage = 'savedObjectNotFound'; +const SavedObjectForbiddenMessage = 'savedObjectForbidden'; +const SavedObjectOtherErrorMessage = 'savedObjectOtherError'; + +function getMockSavedObjectsClient(params: CallGetTelemetryOptInParams) { + return { + async get(type: string, id: string) { + if (params.savedObjectNotFound) throw new Error(SavedObjectNotFoundMessage); + if (params.savedObjectForbidden) throw new Error(SavedObjectForbiddenMessage); + if (params.savedObjectOtherError) throw new Error(SavedObjectOtherErrorMessage); + + const enabled = params.enabled; + const lastVersionChecked = params.lastVersionChecked; + return { attributes: { enabled, lastVersionChecked } }; + }, + errors: { + isNotFoundError(error: any) { + return error.message === SavedObjectNotFoundMessage; + }, + isForbiddenError(error: any) { + return error.message === SavedObjectForbiddenMessage; + }, + }, + }; +} diff --git a/src/legacy/core_plugins/telemetry/server/get_telemetry_opt_in.ts b/src/legacy/core_plugins/telemetry/server/get_telemetry_opt_in.ts index 9b365d6dd7ae5..c8bd4a4b6dfbd 100644 --- a/src/legacy/core_plugins/telemetry/server/get_telemetry_opt_in.ts +++ b/src/legacy/core_plugins/telemetry/server/get_telemetry_opt_in.ts @@ -17,7 +17,21 @@ * under the License. */ -export async function getTelemetryOptIn(request: any) { +import semver from 'semver'; + +import { SavedObjectAttributes } from './routes/opt_in'; + +interface GetTelemetryOptIn { + request: any; + currentKibanaVersion: string; +} + +// Returns whether telemetry has been opt'ed into or not. +// Returns null not set, meaning Kibana should prompt in the UI. +export async function getTelemetryOptIn({ + request, + currentKibanaVersion, +}: GetTelemetryOptIn): Promise { const isRequestingApplication = request.path.startsWith('/app'); // Prevent interstitial screens (such as the space selector) from prompting for telemetry @@ -27,9 +41,9 @@ export async function getTelemetryOptIn(request: any) { const savedObjectsClient = request.getSavedObjectsClient(); + let savedObject; try { - const { attributes } = await savedObjectsClient.get('telemetry', 'telemetry'); - return attributes.enabled; + savedObject = await savedObjectsClient.get('telemetry', 'telemetry'); } catch (error) { if (savedObjectsClient.errors.isNotFoundError(error)) { return null; @@ -43,4 +57,50 @@ export async function getTelemetryOptIn(request: any) { throw error; } + + const { attributes }: { attributes: SavedObjectAttributes } = savedObject; + + // if enabled is already null, return null + if (attributes.enabled == null) return null; + + const enabled = !!attributes.enabled; + + // if enabled is true, return it + if (enabled === true) return enabled; + + // Additional check if they've already opted out (enabled: false): + // - if the Kibana version has changed by at least a minor version, + // return null to re-prompt. + + const lastKibanaVersion = attributes.lastVersionChecked; + + // if the last kibana version isn't set, or is somehow not a string, return null + if (typeof lastKibanaVersion !== 'string') return null; + + // if version hasn't changed, just return enabled value + if (lastKibanaVersion === currentKibanaVersion) return enabled; + + const lastSemver = parseSemver(lastKibanaVersion); + const currentSemver = parseSemver(currentKibanaVersion); + + // if either version is invalid, return null + if (lastSemver == null || currentSemver == null) return null; + + // actual major/minor version comparison, for cases when to return null + if (currentSemver.major > lastSemver.major) return null; + if (currentSemver.major === lastSemver.major) { + if (currentSemver.minor > lastSemver.minor) return null; + } + + // current version X.Y is not greater than last version X.Y, return enabled + return enabled; +} + +function parseSemver(version: string): semver.SemVer | null { + // semver functions both return nulls AND throw exceptions: "it depends!" + try { + return semver.parse(version); + } catch (err) { + return null; + } } diff --git a/src/legacy/core_plugins/telemetry/server/index.ts b/src/legacy/core_plugins/telemetry/server/index.ts index b8ae5fc231fba..aa13fab9a5f81 100644 --- a/src/legacy/core_plugins/telemetry/server/index.ts +++ b/src/legacy/core_plugins/telemetry/server/index.ts @@ -25,5 +25,5 @@ export { getTelemetryOptIn } from './get_telemetry_opt_in'; export { telemetryCollectionManager } from './collection_manager'; export const telemetryPlugin = (initializerContext: PluginInitializerContext) => - new TelemetryPlugin(); + new TelemetryPlugin(initializerContext); export { constants }; diff --git a/src/legacy/core_plugins/telemetry/server/plugin.ts b/src/legacy/core_plugins/telemetry/server/plugin.ts index 70de51b2abe99..a5f0f1234799a 100644 --- a/src/legacy/core_plugins/telemetry/server/plugin.ts +++ b/src/legacy/core_plugins/telemetry/server/plugin.ts @@ -17,14 +17,21 @@ * under the License. */ -import { CoreSetup } from 'src/core/server'; +import { CoreSetup, PluginInitializerContext } from 'src/core/server'; import { registerRoutes } from './routes'; import { telemetryCollectionManager } from './collection_manager'; import { getStats } from './telemetry_collection'; export class TelemetryPlugin { + private readonly currentKibanaVersion: string; + + constructor(initializerContext: PluginInitializerContext) { + this.currentKibanaVersion = initializerContext.env.packageInfo.version; + } + public setup(core: CoreSetup) { + const currentKibanaVersion = this.currentKibanaVersion; telemetryCollectionManager.setStatsGetter(getStats, 'local'); - registerRoutes(core); + registerRoutes({ core, currentKibanaVersion }); } } diff --git a/src/legacy/core_plugins/telemetry/server/routes/index.ts b/src/legacy/core_plugins/telemetry/server/routes/index.ts index 12ba541d699f9..2eb6bf95b4f45 100644 --- a/src/legacy/core_plugins/telemetry/server/routes/index.ts +++ b/src/legacy/core_plugins/telemetry/server/routes/index.ts @@ -21,7 +21,12 @@ import { CoreSetup } from 'src/core/server'; import { registerOptInRoutes } from './opt_in'; import { registerTelemetryDataRoutes } from './telemetry_stats'; -export function registerRoutes(core: CoreSetup) { - registerOptInRoutes(core); +interface RegisterRoutesParams { + core: CoreSetup; + currentKibanaVersion: string; +} + +export function registerRoutes({ core, currentKibanaVersion }: RegisterRoutesParams) { + registerOptInRoutes({ core, currentKibanaVersion }); registerTelemetryDataRoutes(core); } diff --git a/src/legacy/core_plugins/telemetry/server/routes/opt_in.ts b/src/legacy/core_plugins/telemetry/server/routes/opt_in.ts index aabc0259f08fc..3a7194890b570 100644 --- a/src/legacy/core_plugins/telemetry/server/routes/opt_in.ts +++ b/src/legacy/core_plugins/telemetry/server/routes/opt_in.ts @@ -21,7 +21,17 @@ import Joi from 'joi'; import { boomify } from 'boom'; import { CoreSetup } from 'src/core/server'; -export function registerOptInRoutes(core: CoreSetup) { +interface RegisterOptInRoutesParams { + core: CoreSetup; + currentKibanaVersion: string; +} + +export interface SavedObjectAttributes { + enabled?: boolean; + lastVersionChecked: string; +} + +export function registerOptInRoutes({ core, currentKibanaVersion }: RegisterOptInRoutesParams) { const { server } = core.http as any; server.route({ @@ -36,17 +46,16 @@ export function registerOptInRoutes(core: CoreSetup) { }, handler: async (req: any, h: any) => { const savedObjectsClient = req.getSavedObjectsClient(); + const savedObject: SavedObjectAttributes = { + enabled: req.payload.enabled, + lastVersionChecked: currentKibanaVersion, + }; + const options = { + id: 'telemetry', + overwrite: true, + }; try { - await savedObjectsClient.create( - 'telemetry', - { - enabled: req.payload.enabled, - }, - { - id: 'telemetry', - overwrite: true, - } - ); + await savedObjectsClient.create('telemetry', savedObject, options); } catch (err) { return boomify(err); } diff --git a/x-pack/test/api_integration/apis/telemetry/index.js b/x-pack/test/api_integration/apis/telemetry/index.js index d941cda9e3fae..6f794d56ae713 100644 --- a/x-pack/test/api_integration/apis/telemetry/index.js +++ b/x-pack/test/api_integration/apis/telemetry/index.js @@ -8,5 +8,6 @@ export default function ({ loadTestFile }) { describe('Telemetry', () => { loadTestFile(require.resolve('./telemetry')); loadTestFile(require.resolve('./telemetry_local')); + loadTestFile(require.resolve('./opt_in')); }); } diff --git a/x-pack/test/api_integration/apis/telemetry/opt_in.ts b/x-pack/test/api_integration/apis/telemetry/opt_in.ts new file mode 100644 index 0000000000000..d2ad2d773d692 --- /dev/null +++ b/x-pack/test/api_integration/apis/telemetry/opt_in.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function optInTest({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const kibanaServer = getService('kibanaServer'); + + describe('/api/telemetry/v2/optIn API', () => { + let kibanaVersion: any; + + before(async () => { + const kibanaVersionAccessor = kibanaServer.version; + kibanaVersion = await kibanaVersionAccessor.get(); + + expect(typeof kibanaVersion).to.eql('string'); + expect(kibanaVersion.length).to.be.greaterThan(0); + }); + + it('should support sending false', async () => { + await postTelemetryV2Optin(supertest, false, 200); + const { enabled, lastVersionChecked } = await getSavedObjectAttributes(supertest); + expect(enabled).to.be(false); + expect(lastVersionChecked).to.be(kibanaVersion); + }); + + it('should support sending true', async () => { + await postTelemetryV2Optin(supertest, true, 200); + const { enabled, lastVersionChecked } = await getSavedObjectAttributes(supertest); + expect(enabled).to.be(true); + expect(lastVersionChecked).to.be(kibanaVersion); + }); + + it('should not support sending null', async () => { + await postTelemetryV2Optin(supertest, null, 400); + }); + + it('should not support sending junk', async () => { + await postTelemetryV2Optin(supertest, 42, 400); + }); + }); +} + +async function postTelemetryV2Optin(supertest: any, value: any, statusCode: number): Promise { + const { body } = await supertest + .post('/api/telemetry/v2/optIn') + .set('kbn-xsrf', 'xxx') + .send({ enabled: value }) + .expect(statusCode); + + return body; +} + +async function getSavedObjectAttributes(supertest: any): Promise { + const { body } = await supertest.get('/api/saved_objects/telemetry/telemetry').expect(200); + return body.attributes; +}