Skip to content

Commit

Permalink
[drift] Determine if trial is active (plus buffer) before showing cha…
Browse files Browse the repository at this point in the history
…t. (#151548)

## Summary

In-app chat should only be enabled in Cloud if a trial is still active.
#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.
  • Loading branch information
clintandrewhall authored Feb 22, 2023
1 parent 629b876 commit 00ab82e
Show file tree
Hide file tree
Showing 9 changed files with 183 additions and 15 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@
*/

export const GET_CHAT_USER_DATA_ROUTE_PATH = '/internal/cloud/chat_user';
export const DEFAULT_TRIAL_BUFFER = 60;
19 changes: 19 additions & 0 deletions x-pack/plugins/cloud_integrations/cloud_chat/common/util.ts
Original file line number Diff line number Diff line change
@@ -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();
};
24 changes: 20 additions & 4 deletions x-pack/plugins/cloud_integrations/cloud_chat/public/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand All @@ -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);

Expand All @@ -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 } : {}),
});

Expand All @@ -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();
});
Expand Down
15 changes: 13 additions & 2 deletions x-pack/plugins/cloud_integrations/cloud_chat/public/plugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -27,6 +28,7 @@ interface SetupChatDeps extends CloudChatSetupDeps {

interface CloudChatConfig {
chatURL?: string;
trialBuffer: number;
}

export class CloudChatPlugin implements Plugin {
Expand Down Expand Up @@ -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;
}

Expand All @@ -73,7 +84,7 @@ export class CloudChatPlugin implements Plugin {
}

this.chatConfig$.next({
chatURL: this.config.chatURL,
chatURL,
user: {
email,
id,
Expand Down
4 changes: 4 additions & 0 deletions x-pack/plugins/cloud_integrations/cloud_chat/server/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,21 @@ 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>;

export const config: PluginConfigDescriptor<CloudChatConfigType> = {
exposeToBrowser: {
chatURL: true,
trialBuffer: true,
},
schema: configSchema,
deprecations: () => [
Expand Down
15 changes: 10 additions & 5 deletions x-pack/plugins/cloud_integrations/cloud_chat/server/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
});

Expand All @@ -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];

Expand All @@ -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';
Expand All @@ -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(`
Expand Down Expand Up @@ -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(`
Expand Down
17 changes: 17 additions & 0 deletions x-pack/plugins/cloud_integrations/cloud_chat/server/routes/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand All @@ -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;
}) => {
Expand Down Expand Up @@ -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,
Expand Down

0 comments on commit 00ab82e

Please sign in to comment.