[8.7] [drift] Determine if trial is active (plus buffer) before showing chat. (#151548) (#151946)

# Backport

This will backport the following commits from `main` to `8.7`:
- [[drift] Determine if trial is active (plus buffer) before showing
chat. (#151548)](https://github.com/elastic/kibana/pull/151548)

<!--- Backport version: 8.9.7 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT [{"author":{"name":"Clint Andrew
Hall","email":"clint.hall@elastic.co"},"sourceCommit":{"committedDate":"2023-02-22T21:34:44Z","message":"[drift]
Determine if trial is active (plus buffer) before showing chat.
(#151548)\n\n## Summary\r\n\r\nIn-app chat should only be enabled in
Cloud if a trial is still
active.\r\nhttps://github.com/elastic/kibana/pull/143002 added metadata
including\r\n`trial_end_date`. This PR:\r\n\r\n- adds a config key to
`cloud_integrations` for a `trialBuffer`, in\r\ndays, which defaults to
~~`30`~~ `60`.\r\n- adds logic to not display chat if the trial end date
+ buffer exceeds\r\nthe current date.\r\n- adds logic to not add a
server route if the trial end date + buffer\r\nexceeds the current
date.\r\n\r\n## Testing Locally\r\n\r\nAdd the following config to
`kibana.dev.yml`:\r\n\r\n```\r\nxpack.cloud.id:
\"some-id\"\r\nxpack.cloud.trial_end_date:
\"2023-02-21T00:00:00.000Z\"\r\n\r\nxpack.cloud_integrations.chat.enabled:
true\r\nxpack.cloud_integrations.chat.chatURL:
\"https://elasticcloud-production-chat-us-east-1.s3.amazonaws.com/drift-iframe.html\"\r\nxpack.cloud_integrations.chat.chatIdentitySecret:
\"some-secret\"\r\n```\r\n\r\nAnd start Kibana. You can optionally
change the default of `30` days by\r\nadding
`xpack.cloud_integrations.chat.trialBuffer`.\r\n\r\n##
Storybook\r\n\r\nRun `yarn storybook cloud_chat`.\r\n\r\n## Testing in
Cloud\r\n\r\nSet the same config keys as above on a Cloud
deployment.","sha":"00ab82ecd7e6c0e33977d7d37b26c6d88e367fd6","branchLabelMapping":{"^v8.8.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["review","loe:hours","release_note:skip","impact:high","ci:cloud-deploy","v8.7.0","v8.8.0","Feature:Chat"],"number":151548,"url":"https://github.com/elastic/kibana/pull/151548","mergeCommit":{"message":"[drift]
Determine if trial is active (plus buffer) before showing chat.
(#151548)\n\n## Summary\r\n\r\nIn-app chat should only be enabled in
Cloud if a trial is still
active.\r\nhttps://github.com/elastic/kibana/pull/143002 added metadata
including\r\n`trial_end_date`. This PR:\r\n\r\n- adds a config key to
`cloud_integrations` for a `trialBuffer`, in\r\ndays, which defaults to
~~`30`~~ `60`.\r\n- adds logic to not display chat if the trial end date
+ buffer exceeds\r\nthe current date.\r\n- adds logic to not add a
server route if the trial end date + buffer\r\nexceeds the current
date.\r\n\r\n## Testing Locally\r\n\r\nAdd the following config to
`kibana.dev.yml`:\r\n\r\n```\r\nxpack.cloud.id:
\"some-id\"\r\nxpack.cloud.trial_end_date:
\"2023-02-21T00:00:00.000Z\"\r\n\r\nxpack.cloud_integrations.chat.enabled:
true\r\nxpack.cloud_integrations.chat.chatURL:
\"https://elasticcloud-production-chat-us-east-1.s3.amazonaws.com/drift-iframe.html\"\r\nxpack.cloud_integrations.chat.chatIdentitySecret:
\"some-secret\"\r\n```\r\n\r\nAnd start Kibana. You can optionally
change the default of `30` days by\r\nadding
`xpack.cloud_integrations.chat.trialBuffer`.\r\n\r\n##
Storybook\r\n\r\nRun `yarn storybook cloud_chat`.\r\n\r\n## Testing in
Cloud\r\n\r\nSet the same config keys as above on a Cloud
deployment.","sha":"00ab82ecd7e6c0e33977d7d37b26c6d88e367fd6"}},"sourceBranch":"main","suggestedTargetBranches":["8.7"],"targetPullRequestStates":[{"branch":"8.7","label":"v8.7.0","labelRegex":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"},{"branch":"main","label":"v8.8.0","labelRegex":"^v8.8.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/151548","number":151548,"mergeCommit":{"message":"[drift]
Determine if trial is active (plus buffer) before showing chat.
(#151548)\n\n## Summary\r\n\r\nIn-app chat should only be enabled in
Cloud if a trial is still
active.\r\nhttps://github.com/elastic/kibana/pull/143002 added metadata
including\r\n`trial_end_date`. This PR:\r\n\r\n- adds a config key to
`cloud_integrations` for a `trialBuffer`, in\r\ndays, which defaults to
~~`30`~~ `60`.\r\n- adds logic to not display chat if the trial end date
+ buffer exceeds\r\nthe current date.\r\n- adds logic to not add a
server route if the trial end date + buffer\r\nexceeds the current
date.\r\n\r\n## Testing Locally\r\n\r\nAdd the following config to
`kibana.dev.yml`:\r\n\r\n```\r\nxpack.cloud.id:
\"some-id\"\r\nxpack.cloud.trial_end_date:
\"2023-02-21T00:00:00.000Z\"\r\n\r\nxpack.cloud_integrations.chat.enabled:
true\r\nxpack.cloud_integrations.chat.chatURL:
\"https://elasticcloud-production-chat-us-east-1.s3.amazonaws.com/drift-iframe.html\"\r\nxpack.cloud_integrations.chat.chatIdentitySecret:
\"some-secret\"\r\n```\r\n\r\nAnd start Kibana. You can optionally
change the default of `30` days by\r\nadding
`xpack.cloud_integrations.chat.trialBuffer`.\r\n\r\n##
Storybook\r\n\r\nRun `yarn storybook cloud_chat`.\r\n\r\n## Testing in
Cloud\r\n\r\nSet the same config keys as above on a Cloud
deployment.","sha":"00ab82ecd7e6c0e33977d7d37b26c6d88e367fd6"}}]}]
BACKPORT-->

Co-authored-by: Clint Andrew Hall <clint.hall@elastic.co>
This commit is contained in:
Kibana Machine 2023-02-22 17:42:52 -05:00 committed by GitHub
parent 12a539eb84
commit ee7f50abd3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 183 additions and 15 deletions

View file

@ -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.

View file

@ -6,3 +6,4 @@
*/
export const GET_CHAT_USER_DATA_ROUTE_PATH = '/internal/cloud/chat_user';
export const DEFAULT_TRIAL_BUFFER = 60;

View file

@ -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();
};

View file

@ -15,9 +15,12 @@ describe('Cloud Chat Plugin', () => {
describe('#setup', () => {
describe('setupChat', () => {
let consoleMock: jest.SpyInstance<void, [message?: any, ...optionalParams: any[]]>;
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<CloudChatConfigType>;
securityEnabled?: boolean;
currentUserProps?: Record<string, any>;
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();
});

View file

@ -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,

View file

@ -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<typeof configSchema>;
@ -20,6 +23,7 @@ export type CloudChatConfigType = TypeOf<typeof configSchema>;
export const config: PluginConfigDescriptor<CloudChatConfigType> = {
exposeToBrowser: {
chatURL: true,
trialBuffer: true,
},
schema: configSchema,
deprecations: () => [

View file

@ -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<void, void, CloudChatSetupDeps> {
}
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,
});

View file

@ -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(`

View file

@ -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,