From 00ab82ecd7e6c0e33977d7d37b26c6d88e367fd6 Mon Sep 17 00:00:00 2001 From: Clint Andrew Hall Date: Wed, 22 Feb 2023 16:34:44 -0500 Subject: [PATCH] [drift] Determine if trial is active (plus buffer) before showing chat. (#151548) ## Summary In-app chat should only be enabled in Cloud if a trial is still active. https://github.com/elastic/kibana/pull/143002 added metadata including `trial_end_date`. This PR: - adds a config key to `cloud_integrations` for a `trialBuffer`, in days, which defaults to ~~`30`~~ `60`. - adds logic to not display chat if the trial end date + buffer exceeds the current date. - adds logic to not add a server route if the trial end date + buffer exceeds the current date. ## Testing Locally Add the following config to `kibana.dev.yml`: ``` xpack.cloud.id: "some-id" xpack.cloud.trial_end_date: "2023-02-21T00:00:00.000Z" xpack.cloud_integrations.chat.enabled: true xpack.cloud_integrations.chat.chatURL: "https://elasticcloud-production-chat-us-east-1.s3.amazonaws.com/drift-iframe.html" xpack.cloud_integrations.chat.chatIdentitySecret: "some-secret" ``` And start Kibana. You can optionally change the default of `30` days by adding `xpack.cloud_integrations.chat.trialBuffer`. ## Storybook Run `yarn storybook cloud_chat`. ## Testing in Cloud Set the same config keys as above on a Cloud deployment. --- .../test_suites/core_plugins/rendering.ts | 1 + .../cloud_chat/common/constants.ts | 1 + .../cloud_chat/common/util.ts | 19 ++++ .../cloud_chat/public/plugin.test.ts | 24 ++++- .../cloud_chat/public/plugin.tsx | 15 ++- .../cloud_chat/server/config.ts | 4 + .../cloud_chat/server/plugin.ts | 15 ++- .../cloud_chat/server/routes/chat.test.ts | 102 +++++++++++++++++- .../cloud_chat/server/routes/chat.ts | 17 +++ 9 files changed, 183 insertions(+), 15 deletions(-) create mode 100644 x-pack/plugins/cloud_integrations/cloud_chat/common/util.ts diff --git a/test/plugin_functional/test_suites/core_plugins/rendering.ts b/test/plugin_functional/test_suites/core_plugins/rendering.ts index 19ee7178fcd94..88cb0f969a776 100644 --- a/test/plugin_functional/test_suites/core_plugins/rendering.ts +++ b/test/plugin_functional/test_suites/core_plugins/rendering.ts @@ -172,6 +172,7 @@ export default function ({ getService }: PluginFunctionalProviderContext) { 'xpack.cloud.is_elastic_staff_owned (boolean)', 'xpack.cloud.trial_end_date (string)', 'xpack.cloud_integrations.chat.chatURL (string)', + 'xpack.cloud_integrations.chat.trialBuffer (number)', // No PII. This is an escape patch to override LaunchDarkly's flag resolution mechanism for testing or quick fix. 'xpack.cloud_integrations.experiments.flag_overrides (record)', // Commented because it's inside a schema conditional, and the test is not able to resolve it. But it's shared. diff --git a/x-pack/plugins/cloud_integrations/cloud_chat/common/constants.ts b/x-pack/plugins/cloud_integrations/cloud_chat/common/constants.ts index d7bd133e5b4f9..33dd954b5756f 100755 --- a/x-pack/plugins/cloud_integrations/cloud_chat/common/constants.ts +++ b/x-pack/plugins/cloud_integrations/cloud_chat/common/constants.ts @@ -6,3 +6,4 @@ */ export const GET_CHAT_USER_DATA_ROUTE_PATH = '/internal/cloud/chat_user'; +export const DEFAULT_TRIAL_BUFFER = 60; diff --git a/x-pack/plugins/cloud_integrations/cloud_chat/common/util.ts b/x-pack/plugins/cloud_integrations/cloud_chat/common/util.ts new file mode 100644 index 0000000000000..9d6cddac77f99 --- /dev/null +++ b/x-pack/plugins/cloud_integrations/cloud_chat/common/util.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; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/** + * Returns true if today's date is within the an end date + buffer, false otherwise. + * + * @param endDate The end date of the trial. + * @param buffer The number of days to add to the end date. + * @returns true if today's date is within the an end date + buffer, false otherwise. + */ +export const isTodayInDateWindow = (endDate: Date, buffer: number) => { + const endDateWithBuffer = new Date(endDate); + endDateWithBuffer.setDate(endDateWithBuffer.getDate() + buffer); + return endDateWithBuffer > new Date(); +}; diff --git a/x-pack/plugins/cloud_integrations/cloud_chat/public/plugin.test.ts b/x-pack/plugins/cloud_integrations/cloud_chat/public/plugin.test.ts index 9e84c86791311..f44c7cd5112e3 100644 --- a/x-pack/plugins/cloud_integrations/cloud_chat/public/plugin.test.ts +++ b/x-pack/plugins/cloud_integrations/cloud_chat/public/plugin.test.ts @@ -15,9 +15,12 @@ describe('Cloud Chat Plugin', () => { describe('#setup', () => { describe('setupChat', () => { let consoleMock: jest.SpyInstance; + let newTrialEndDate: Date; beforeEach(() => { consoleMock = jest.spyOn(console, 'debug').mockImplementation(() => {}); + newTrialEndDate = new Date(); + newTrialEndDate.setDate(new Date().getDate() + 14); }); afterEach(() => { @@ -30,12 +33,14 @@ describe('Cloud Chat Plugin', () => { currentUserProps = {}, isCloudEnabled = true, failHttp = false, + trialEndDate = newTrialEndDate, }: { config?: Partial; securityEnabled?: boolean; currentUserProps?: Record; isCloudEnabled?: boolean; failHttp?: boolean; + trialEndDate?: Date; }) => { const initContext = coreMock.createPluginInitializerContext(config); @@ -60,7 +65,7 @@ describe('Cloud Chat Plugin', () => { const cloud = cloudMock.createSetup(); plugin.setup(coreSetup, { - cloud: { ...cloud, isCloudEnabled }, + cloud: { ...cloud, isCloudEnabled, trialEndDate }, ...(securityEnabled ? { security: securitySetup } : {}), }); @@ -85,16 +90,27 @@ describe('Cloud Chat Plugin', () => { it('chatConfig is not retrieved if internal API fails', async () => { const { coreSetup } = await setupPlugin({ - config: { chatURL: 'http://chat.elastic.co' }, + config: { chatURL: 'http://chat.elastic.co', trialBuffer: 30 }, failHttp: true, }); expect(coreSetup.http.get).toHaveBeenCalled(); expect(consoleMock).toHaveBeenCalled(); }); - it('chatConfig is retrieved if chat is enabled and url is provided', async () => { + it('chatConfig is not retrieved if chat is enabled and url is provided but trial has expired', async () => { + const date = new Date(); + date.setDate(new Date().getDate() - 44); const { coreSetup } = await setupPlugin({ - config: { chatURL: 'http://chat.elastic.co' }, + config: { chatURL: 'http://chat.elastic.co', trialBuffer: 30 }, + trialEndDate: date, + }); + expect(coreSetup.http.get).not.toHaveBeenCalled(); + }); + + it('chatConfig is retrieved if chat is enabled and url is provided and trial is active', async () => { + const { coreSetup } = await setupPlugin({ + config: { chatURL: 'http://chat.elastic.co', trialBuffer: 30 }, + trialEndDate: new Date(), }); expect(coreSetup.http.get).toHaveBeenCalled(); }); diff --git a/x-pack/plugins/cloud_integrations/cloud_chat/public/plugin.tsx b/x-pack/plugins/cloud_integrations/cloud_chat/public/plugin.tsx index 6cdda971b33ca..d85d7415d0288 100755 --- a/x-pack/plugins/cloud_integrations/cloud_chat/public/plugin.tsx +++ b/x-pack/plugins/cloud_integrations/cloud_chat/public/plugin.tsx @@ -15,6 +15,7 @@ import { ReplaySubject } from 'rxjs'; import type { GetChatUserDataResponseBody } from '../common/types'; import { GET_CHAT_USER_DATA_ROUTE_PATH } from '../common/constants'; import { ChatConfig, ServicesProvider } from './services'; +import { isTodayInDateWindow } from '../common/util'; interface CloudChatSetupDeps { cloud: CloudSetup; @@ -27,6 +28,7 @@ interface SetupChatDeps extends CloudChatSetupDeps { interface CloudChatConfig { chatURL?: string; + trialBuffer: number; } export class CloudChatPlugin implements Plugin { @@ -57,7 +59,16 @@ export class CloudChatPlugin implements Plugin { public stop() {} private async setupChat({ cloud, http, security }: SetupChatDeps) { - if (!cloud.isCloudEnabled || !security || !this.config.chatURL) { + const { isCloudEnabled, trialEndDate } = cloud; + const { chatURL, trialBuffer } = this.config; + + if ( + !security || + !isCloudEnabled || + !chatURL || + !trialEndDate || + !isTodayInDateWindow(trialEndDate, trialBuffer) + ) { return; } @@ -73,7 +84,7 @@ export class CloudChatPlugin implements Plugin { } this.chatConfig$.next({ - chatURL: this.config.chatURL, + chatURL, user: { email, id, diff --git a/x-pack/plugins/cloud_integrations/cloud_chat/server/config.ts b/x-pack/plugins/cloud_integrations/cloud_chat/server/config.ts index 72651e1555784..591f80c2bd117 100644 --- a/x-pack/plugins/cloud_integrations/cloud_chat/server/config.ts +++ b/x-pack/plugins/cloud_integrations/cloud_chat/server/config.ts @@ -9,10 +9,13 @@ import { get, has } from 'lodash'; import { schema, TypeOf } from '@kbn/config-schema'; import { PluginConfigDescriptor } from '@kbn/core/server'; +import { DEFAULT_TRIAL_BUFFER } from '../common/constants'; + const configSchema = schema.object({ enabled: schema.boolean({ defaultValue: false }), chatURL: schema.maybe(schema.string()), chatIdentitySecret: schema.maybe(schema.string()), + trialBuffer: schema.number({ defaultValue: DEFAULT_TRIAL_BUFFER }), }); export type CloudChatConfigType = TypeOf; @@ -20,6 +23,7 @@ export type CloudChatConfigType = TypeOf; export const config: PluginConfigDescriptor = { exposeToBrowser: { chatURL: true, + trialBuffer: true, }, schema: configSchema, deprecations: () => [ diff --git a/x-pack/plugins/cloud_integrations/cloud_chat/server/plugin.ts b/x-pack/plugins/cloud_integrations/cloud_chat/server/plugin.ts index 02173d96269b3..f433807f52c47 100755 --- a/x-pack/plugins/cloud_integrations/cloud_chat/server/plugin.ts +++ b/x-pack/plugins/cloud_integrations/cloud_chat/server/plugin.ts @@ -7,10 +7,10 @@ import { PluginInitializerContext, CoreSetup, Plugin } from '@kbn/core/server'; -import { SecurityPluginSetup } from '@kbn/security-plugin/server'; -import { CloudSetup } from '@kbn/cloud-plugin/server'; +import type { SecurityPluginSetup } from '@kbn/security-plugin/server'; +import type { CloudSetup } from '@kbn/cloud-plugin/server'; import { registerChatRoute } from './routes'; -import { CloudChatConfigType } from './config'; +import type { CloudChatConfigType } from './config'; interface CloudChatSetupDeps { cloud: CloudSetup; @@ -27,10 +27,15 @@ export class CloudChatPlugin implements Plugin { } public setup(core: CoreSetup, { cloud, security }: CloudChatSetupDeps) { - if (cloud.isCloudEnabled && this.config.chatIdentitySecret) { + const { chatIdentitySecret, trialBuffer } = this.config; + const { isCloudEnabled, trialEndDate } = cloud; + + if (isCloudEnabled && chatIdentitySecret) { registerChatRoute({ router: core.http.createRouter(), - chatIdentitySecret: this.config.chatIdentitySecret, + chatIdentitySecret, + trialEndDate, + trialBuffer, security, isDev: this.isDev, }); diff --git a/x-pack/plugins/cloud_integrations/cloud_chat/server/routes/chat.test.ts b/x-pack/plugins/cloud_integrations/cloud_chat/server/routes/chat.test.ts index c21b72bd9d10c..be54a46b02875 100644 --- a/x-pack/plugins/cloud_integrations/cloud_chat/server/routes/chat.test.ts +++ b/x-pack/plugins/cloud_integrations/cloud_chat/server/routes/chat.test.ts @@ -19,7 +19,7 @@ import { registerChatRoute } from './chat'; describe('chat route', () => { test('do not add the route if security is not enabled', async () => { const router = httpServiceMock.createRouter(); - registerChatRoute({ router, isDev: false, chatIdentitySecret: 'secret' }); + registerChatRoute({ router, isDev: false, chatIdentitySecret: 'secret', trialBuffer: 60 }); expect(router.get.mock.calls).toEqual([]); }); @@ -28,7 +28,14 @@ describe('chat route', () => { security.authc.getCurrentUser.mockReturnValueOnce(null); const router = httpServiceMock.createRouter(); - registerChatRoute({ router, security, isDev: false, chatIdentitySecret: 'secret' }); + registerChatRoute({ + router, + security, + isDev: false, + chatIdentitySecret: 'secret', + trialBuffer: 60, + trialEndDate: new Date(), + }); const [_config, handler] = router.get.mock.calls[0]; @@ -44,6 +51,79 @@ describe('chat route', () => { `); }); + test('error if no trial end date specified', async () => { + const security = securityMock.createSetup(); + const username = 'user.name'; + const email = 'user@elastic.co'; + + security.authc.getCurrentUser.mockReturnValueOnce({ + username, + metadata: { + saml_email: [email], + }, + }); + + const router = httpServiceMock.createRouter(); + registerChatRoute({ + router, + security, + isDev: false, + chatIdentitySecret: 'secret', + trialBuffer: 2, + }); + + const [_config, handler] = router.get.mock.calls[0]; + + await expect(handler({}, httpServerMock.createKibanaRequest(), kibanaResponseFactory)).resolves + .toMatchInlineSnapshot(` + KibanaResponse { + "options": Object { + "body": "Chat can only be started if a trial end date is specified", + }, + "payload": "Chat can only be started if a trial end date is specified", + "status": 400, + } + `); + }); + + test('error if not in trial window', async () => { + const security = securityMock.createSetup(); + const username = 'user.name'; + const email = 'user@elastic.co'; + + security.authc.getCurrentUser.mockReturnValueOnce({ + username, + metadata: { + saml_email: [email], + }, + }); + + const router = httpServiceMock.createRouter(); + const trialEndDate = new Date(); + trialEndDate.setDate(trialEndDate.getDate() - 30); + registerChatRoute({ + router, + security, + isDev: false, + chatIdentitySecret: 'secret', + trialBuffer: 2, + trialEndDate, + }); + + const [_config, handler] = router.get.mock.calls[0]; + + await expect(handler({}, httpServerMock.createKibanaRequest(), kibanaResponseFactory)).resolves + .toMatchInlineSnapshot(` + KibanaResponse { + "options": Object { + "body": "Chat can only be started during trial and trial chat buffer", + }, + "payload": "Chat can only be started during trial and trial chat buffer", + "status": 400, + } + `); + }); + test('returns user information taken from saml metadata and a token', async () => { const security = securityMock.createSetup(); const username = 'user.name'; @@ -57,7 +137,14 @@ describe('chat route', () => { }); const router = httpServiceMock.createRouter(); - registerChatRoute({ router, security, isDev: false, chatIdentitySecret: 'secret' }); + registerChatRoute({ + router, + security, + isDev: false, + chatIdentitySecret: 'secret', + trialBuffer: 60, + trialEndDate: new Date(), + }); const [_config, handler] = router.get.mock.calls[0]; await expect(handler({}, httpServerMock.createKibanaRequest(), kibanaResponseFactory)).resolves .toMatchInlineSnapshot(` @@ -87,7 +174,14 @@ describe('chat route', () => { security.authc.getCurrentUser.mockReturnValueOnce({}); const router = httpServiceMock.createRouter(); - registerChatRoute({ router, security, isDev: true, chatIdentitySecret: 'secret' }); + registerChatRoute({ + router, + security, + isDev: true, + chatIdentitySecret: 'secret', + trialBuffer: 60, + trialEndDate: new Date(), + }); const [_config, handler] = router.get.mock.calls[0]; await expect(handler({}, httpServerMock.createKibanaRequest(), kibanaResponseFactory)).resolves .toMatchInlineSnapshot(` diff --git a/x-pack/plugins/cloud_integrations/cloud_chat/server/routes/chat.ts b/x-pack/plugins/cloud_integrations/cloud_chat/server/routes/chat.ts index 3efb2cc34b3a1..4d4bda1f2f719 100644 --- a/x-pack/plugins/cloud_integrations/cloud_chat/server/routes/chat.ts +++ b/x-pack/plugins/cloud_integrations/cloud_chat/server/routes/chat.ts @@ -10,6 +10,7 @@ import type { SecurityPluginSetup, AuthenticatedUser } from '@kbn/security-plugi import { GET_CHAT_USER_DATA_ROUTE_PATH } from '../../common/constants'; import type { GetChatUserDataResponseBody } from '../../common/types'; import { generateSignedJwt } from '../util/generate_jwt'; +import { isTodayInDateWindow } from '../../common/util'; type MetaWithSaml = AuthenticatedUser['metadata'] & { saml_name: [string]; @@ -21,11 +22,15 @@ type MetaWithSaml = AuthenticatedUser['metadata'] & { export const registerChatRoute = ({ router, chatIdentitySecret, + trialEndDate, + trialBuffer, security, isDev, }: { router: IRouter; chatIdentitySecret: string; + trialEndDate?: Date; + trialBuffer: number; security?: SecurityPluginSetup; isDev: boolean; }) => { @@ -61,6 +66,18 @@ export const registerChatRoute = ({ }); } + if (!trialEndDate) { + return response.badRequest({ + body: 'Chat can only be started if a trial end date is specified', + }); + } + + if (!trialEndDate || !isTodayInDateWindow(trialEndDate, trialBuffer)) { + return response.badRequest({ + body: 'Chat can only be started during trial and trial chat buffer', + }); + } + const token = generateSignedJwt(userId, chatIdentitySecret); const body: GetChatUserDataResponseBody = { token,