[cloud] Create IFRAME chat component; add to Unified Integrations (#123772)

* [cloud] Create IFRAME chat component; add to Unified Integrations

* Provide chat user data from endpoint

* Apply suggestions from code review

Co-authored-by: Luke Elmers <lukeelmers@gmail.com>

* Addressing feedback

* Addressing feedback

* Fixing package.json

* Addressing feedback

* Wrap chat config in an observable

* Add tests

* Add integration tests, docs

Co-authored-by: Sergei Poluektov <sergei.poluektov@elastic.co>
Co-authored-by: Luke Elmers <lukeelmers@gmail.com>
Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Clint Andrew Hall 2022-01-31 08:07:04 -06:00 committed by GitHub
parent fabaa88791
commit 940c4e2833
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 1125 additions and 79 deletions

View file

@ -15,6 +15,7 @@ const STORYBOOKS = [
'apm',
'canvas',
'ci_composite',
'cloud',
'codeeditor',
'custom_integrations',
'dashboard_enhanced',

View file

@ -2,7 +2,24 @@
"id": "cloud",
"client": {
"classes": [],
"functions": [],
"functions": [
{
"parentPluginId": "cloud",
"id": "def-public.Chat",
"type": "Function",
"tags": [],
"label": "Chat",
"description": [],
"signature": [
"() => JSX.Element"
],
"path": "x-pack/plugins/cloud/public/components/index.tsx",
"deprecated": false,
"children": [],
"returnComment": [],
"initialIsOpen": false
}
],
"interfaces": [
{
"parentPluginId": "cloud",
@ -11,7 +28,7 @@
"tags": [],
"label": "CloudConfigType",
"description": [],
"path": "x-pack/plugins/cloud/public/plugin.ts",
"path": "x-pack/plugins/cloud/public/plugin.tsx",
"deprecated": false,
"children": [
{
@ -24,7 +41,7 @@
"signature": [
"string | undefined"
],
"path": "x-pack/plugins/cloud/public/plugin.ts",
"path": "x-pack/plugins/cloud/public/plugin.tsx",
"deprecated": false
},
{
@ -37,7 +54,7 @@
"signature": [
"string | undefined"
],
"path": "x-pack/plugins/cloud/public/plugin.ts",
"path": "x-pack/plugins/cloud/public/plugin.tsx",
"deprecated": false
},
{
@ -50,7 +67,7 @@
"signature": [
"string | undefined"
],
"path": "x-pack/plugins/cloud/public/plugin.ts",
"path": "x-pack/plugins/cloud/public/plugin.tsx",
"deprecated": false
},
{
@ -63,7 +80,7 @@
"signature": [
"string | undefined"
],
"path": "x-pack/plugins/cloud/public/plugin.ts",
"path": "x-pack/plugins/cloud/public/plugin.tsx",
"deprecated": false
},
{
@ -76,7 +93,7 @@
"signature": [
"string | undefined"
],
"path": "x-pack/plugins/cloud/public/plugin.ts",
"path": "x-pack/plugins/cloud/public/plugin.tsx",
"deprecated": false
},
{
@ -89,7 +106,7 @@
"signature": [
"string | undefined"
],
"path": "x-pack/plugins/cloud/public/plugin.ts",
"path": "x-pack/plugins/cloud/public/plugin.tsx",
"deprecated": false
},
{
@ -102,8 +119,78 @@
"signature": [
"{ enabled: boolean; org_id?: string | undefined; }"
],
"path": "x-pack/plugins/cloud/public/plugin.ts",
"path": "x-pack/plugins/cloud/public/plugin.tsx",
"deprecated": false
},
{
"parentPluginId": "cloud",
"id": "def-public.CloudConfigType.chat",
"type": "Object",
"tags": [],
"label": "chat",
"description": [],
"signature": [
"{ enabled: boolean; chatURL: string; }"
],
"path": "x-pack/plugins/cloud/public/plugin.tsx",
"deprecated": false
}
],
"initialIsOpen": false
},
{
"parentPluginId": "cloud",
"id": "def-public.CloudStart",
"type": "Interface",
"tags": [],
"label": "CloudStart",
"description": [],
"path": "x-pack/plugins/cloud/public/plugin.tsx",
"deprecated": false,
"children": [
{
"parentPluginId": "cloud",
"id": "def-public.CloudStart.CloudContextProvider",
"type": "Function",
"tags": [],
"label": "CloudContextProvider",
"description": [
"\nA React component that provides a pre-wired `React.Context` which connects components to Cloud services."
],
"signature": [
"React.FunctionComponent<{}>"
],
"path": "x-pack/plugins/cloud/public/plugin.tsx",
"deprecated": false,
"returnComment": [],
"children": [
{
"parentPluginId": "cloud",
"id": "def-public.CloudStart.CloudContextProvider.$1",
"type": "CompoundType",
"tags": [],
"label": "props",
"description": [],
"signature": [
"P & { children?: React.ReactNode; }"
],
"path": "node_modules/@types/react/index.d.ts",
"deprecated": false
},
{
"parentPluginId": "cloud",
"id": "def-public.CloudStart.CloudContextProvider.$2",
"type": "Any",
"tags": [],
"label": "context",
"description": [],
"signature": [
"any"
],
"path": "node_modules/@types/react/index.d.ts",
"deprecated": false
}
]
}
],
"initialIsOpen": false
@ -119,7 +206,7 @@
"tags": [],
"label": "CloudSetup",
"description": [],
"path": "x-pack/plugins/cloud/public/plugin.ts",
"path": "x-pack/plugins/cloud/public/plugin.tsx",
"deprecated": false,
"children": [
{
@ -132,7 +219,7 @@
"signature": [
"string | undefined"
],
"path": "x-pack/plugins/cloud/public/plugin.ts",
"path": "x-pack/plugins/cloud/public/plugin.tsx",
"deprecated": false
},
{
@ -145,7 +232,7 @@
"signature": [
"string | undefined"
],
"path": "x-pack/plugins/cloud/public/plugin.ts",
"path": "x-pack/plugins/cloud/public/plugin.tsx",
"deprecated": false
},
{
@ -158,7 +245,7 @@
"signature": [
"string | undefined"
],
"path": "x-pack/plugins/cloud/public/plugin.ts",
"path": "x-pack/plugins/cloud/public/plugin.tsx",
"deprecated": false
},
{
@ -171,7 +258,7 @@
"signature": [
"string | undefined"
],
"path": "x-pack/plugins/cloud/public/plugin.ts",
"path": "x-pack/plugins/cloud/public/plugin.tsx",
"deprecated": false
},
{
@ -184,7 +271,7 @@
"signature": [
"string | undefined"
],
"path": "x-pack/plugins/cloud/public/plugin.ts",
"path": "x-pack/plugins/cloud/public/plugin.tsx",
"deprecated": false
},
{
@ -197,7 +284,7 @@
"signature": [
"string | undefined"
],
"path": "x-pack/plugins/cloud/public/plugin.ts",
"path": "x-pack/plugins/cloud/public/plugin.tsx",
"deprecated": false
},
{
@ -210,7 +297,7 @@
"signature": [
"string | undefined"
],
"path": "x-pack/plugins/cloud/public/plugin.ts",
"path": "x-pack/plugins/cloud/public/plugin.tsx",
"deprecated": false
},
{
@ -220,7 +307,7 @@
"tags": [],
"label": "isCloudEnabled",
"description": [],
"path": "x-pack/plugins/cloud/public/plugin.ts",
"path": "x-pack/plugins/cloud/public/plugin.tsx",
"deprecated": false
}
],

View file

@ -278,6 +278,7 @@
"json-stable-stringify": "^1.0.1",
"json-stringify-pretty-compact": "1.2.0",
"json-stringify-safe": "5.0.1",
"jsonwebtoken": "^8.3.0",
"jsts": "^1.6.2",
"kea": "^2.4.2",
"load-json-file": "^6.2.0",

View file

@ -11,6 +11,7 @@ export const storybookAliases = {
apm: 'x-pack/plugins/apm/.storybook',
canvas: 'x-pack/plugins/canvas/storybook',
ci_composite: '.ci/.storybook',
cloud: 'x-pack/plugins/cloud/.storybook',
codeeditor: 'src/plugins/kibana_react/public/code_editor/.storybook',
controls: 'src/plugins/controls/storybook',
custom_integrations: 'src/plugins/custom_integrations/storybook',

View file

@ -8,7 +8,7 @@
jest.mock('jsonwebtoken', () => ({
sign: jest.fn(),
}));
// eslint-disable-next-line import/no-extraneous-dependencies
import jwt from 'jsonwebtoken';
import { Logger } from '../../../../../../src/core/server';
import { loggingSystemMock } from '../../../../../../src/core/server/mocks';

View file

@ -4,7 +4,7 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
// eslint-disable-next-line import/no-extraneous-dependencies
import jwt, { Algorithm } from 'jsonwebtoken';
import { Logger } from '../../../../../../src/core/server';

View file

@ -0,0 +1,35 @@
/*
* 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 React from 'react';
import { DecoratorFn } from '@storybook/react';
import { ServicesProvider, CloudServices } from '../public/services';
// TODO: move to a storybook implementation of the service using parameters.
const services: CloudServices = {
chat: {
enabled: true,
chatURL: 'https://elasticcloud-production-chat-us-east-1.s3.amazonaws.com/drift-iframe.html',
user: {
id: 'user-id',
email: 'test-user@elastic.co',
// this doesn't affect chat appearance,
// but a user identity in Drift only
jwt: 'identity-jwt',
},
},
};
export const getCloudContextProvider: () => React.FC =
() =>
({ children }) =>
<ServicesProvider {...services}>{children}</ServicesProvider>;
export const getCloudContextDecorator: DecoratorFn = (storyFn) => {
const CloudContextProvider = getCloudContextProvider();
return <CloudContextProvider>{storyFn()}</CloudContextProvider>;
};

View file

@ -0,0 +1,8 @@
/*
* 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.
*/
export { getCloudContextDecorator, getCloudContextProvider } from './decorator';

View file

@ -0,0 +1,10 @@
/*
* 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 { defaultConfig } from '@kbn/storybook';
module.exports = defaultConfig;

View file

@ -0,0 +1,20 @@
/*
* 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 { addons } from '@storybook/addons';
import { create } from '@storybook/theming';
import { PANEL_ID } from '@storybook/addon-actions';
addons.setConfig({
theme: create({
base: 'light',
brandTitle: 'Cloud Storybook',
brandUrl: 'https://github.com/elastic/kibana/tree/main/x-pack/plugins/cloud',
}),
showPanel: true.valueOf,
selectedPanel: PANEL_ID,
});

View file

@ -0,0 +1,11 @@
/*
* 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 { addDecorator } from '@storybook/react';
import { getCloudContextDecorator } from './decorator';
addDecorator(getCloudContextDecorator);

View file

@ -6,6 +6,7 @@
*/
export const ELASTIC_SUPPORT_LINK = 'https://cloud.elastic.co/support';
export const GET_CHAT_USER_DATA_ROUTE_PATH = '/internal/cloud/chat_user';
/**
* This is the page for managing your snapshots on Cloud.

View file

@ -0,0 +1,12 @@
/*
* 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.
*/
export interface GetChatUserDataResponseBody {
token: string;
email: string;
id: string;
}

View file

@ -0,0 +1,20 @@
/*
* 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 React from 'react';
import { Chat } from './chat';
export default {
title: 'Chat Widget',
description: '',
parameters: {},
};
export const Component = () => {
return <Chat />;
};

View file

@ -0,0 +1,111 @@
/*
* 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 React, { useEffect, useRef, useState, CSSProperties } from 'react';
import { css } from '@emotion/react';
import { useChat } from '../../services';
import { getChatContext } from './get_chat_context';
type UseChatType =
| { enabled: false }
| {
enabled: true;
src: string;
ref: React.MutableRefObject<HTMLIFrameElement | null>;
style: CSSProperties;
isReady: boolean;
};
const MESSAGE_READY = 'driftIframeReady';
const MESSAGE_RESIZE = 'driftIframeResize';
const MESSAGE_SET_CONTEXT = 'driftSetContext';
const useChatConfig = (): UseChatType => {
const ref = useRef<HTMLIFrameElement>(null);
const chat = useChat();
const [style, setStyle] = useState<CSSProperties>({});
const [isReady, setIsReady] = useState(false);
useEffect(() => {
const handleMessage = (event: MessageEvent): void => {
const { current: chatIframe } = ref;
if (
!chat.enabled ||
!chatIframe?.contentWindow ||
event.source !== chatIframe?.contentWindow
) {
return;
}
const context = getChatContext();
const { data: message } = event;
const { user: userConfig } = chat;
const { id, email, jwt } = userConfig;
switch (message.type) {
case MESSAGE_READY: {
const user = {
id,
attributes: {
email,
},
jwt,
};
chatIframe.contentWindow.postMessage(
{
type: MESSAGE_SET_CONTEXT,
data: { context, user },
},
'*'
);
setIsReady(true);
break;
}
case MESSAGE_RESIZE: {
const styles = message.data.styles || ({} as CSSProperties);
setStyle({ ...style, ...styles });
break;
}
default:
break;
}
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [chat, style]);
if (chat.enabled) {
return { enabled: true, src: chat.chatURL, ref, style, isReady };
}
return { enabled: false };
};
export const Chat = () => {
const config = useChatConfig();
if (!config.enabled) {
return null;
}
const iframeStyle = css`
position: fixed;
botton: 30px;
right: 30px;
visibility: ${config.isReady ? 'visible' : 'hidden'};
`;
return <iframe css={iframeStyle} data-test-subj="floatingChatTrigger" title="chat" {...config} />;
};

View file

@ -0,0 +1,65 @@
/*
* 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 { getChatContext } from './get_chat_context';
const PROTOCOL = 'http:';
const PORT = '1234';
const HASH = '#/discover?_g=()&_a=()';
const HOST_NAME = 'www.kibana.com';
const PATH_NAME = '/app/kibana';
const HOST = `${HOST_NAME}:${PORT}`;
const ORIGIN = `${PROTOCOL}//${HOST}`;
const HREF = `${ORIGIN}${PATH_NAME}${HASH}`;
const USER_AGENT = 'user-agent';
const LANGUAGE = 'la-ng';
const TITLE = 'title';
const REFERRER = 'referrer';
describe('getChatContext', () => {
const url = new URL(HREF);
test('retreive the context', () => {
Object.defineProperty(window, 'location', { value: url });
Object.defineProperty(window, 'navigator', {
value: {
language: LANGUAGE,
userAgent: USER_AGENT,
},
});
Object.defineProperty(window.document, 'referrer', { value: REFERRER });
window.document.title = TITLE;
const context = getChatContext();
expect(context).toStrictEqual({
window: {
location: {
hash: HASH,
host: HOST,
hostname: HOST_NAME,
href: HREF,
origin: ORIGIN,
pathname: PATH_NAME,
port: PORT,
protocol: PROTOCOL,
search: '',
},
navigator: {
language: LANGUAGE,
userAgent: USER_AGENT,
},
innerHeight: 768,
innerWidth: 1024,
},
document: {
title: TITLE,
referrer: REFERRER,
},
});
});
});

View file

@ -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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export const getChatContext = () => {
const { location, navigator, innerHeight, innerWidth } = window;
const { hash, host, hostname, href, origin, pathname, port, protocol, search } = location;
const { language, userAgent } = navigator;
const { title, referrer } = document;
return {
window: {
location: {
hash,
host,
hostname,
href,
origin,
pathname,
port,
protocol,
search,
},
navigator: { language, userAgent },
innerHeight,
innerWidth,
},
document: {
title,
referrer,
},
};
};

View file

@ -0,0 +1,12 @@
/*
* 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.
*/
/**
* A component that will display a trigger that will allow the user to chat with a human operator,
* when the service is enabled; otherwise, it renders nothing.
*/
export { Chat } from './chat';

View file

@ -0,0 +1,26 @@
/*
* 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 React, { Suspense } from 'react';
import { EuiErrorBoundary } from '@elastic/eui';
/**
* A suspense-compatible version of the Chat component.
*/
export const LazyChat = React.lazy(() => import('./chat').then(({ Chat }) => ({ default: Chat })));
/**
* A lazily-loaded component that will display a trigger that will allow the user to chat with a
* human operator when the service is enabled; otherwise, it renders nothing.
*/
export const Chat = () => (
<EuiErrorBoundary>
<Suspense fallback={<div />}>
<LazyChat />
</Suspense>
</EuiErrorBoundary>
);

View file

@ -8,7 +8,10 @@
import { PluginInitializerContext } from '../../../../src/core/public';
import { CloudPlugin } from './plugin';
export type { CloudSetup, CloudConfigType } from './plugin';
export type { CloudSetup, CloudConfigType, CloudStart } from './plugin';
export function plugin(initializerContext: PluginInitializerContext) {
return new CloudPlugin(initializerContext);
}
export { Chat } from './components';

View file

@ -1,22 +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.
*/
function createSetupMock() {
return {
cloudId: 'mock-cloud-id',
isCloudEnabled: true,
cname: 'cname',
baseUrl: 'base-url',
deploymentUrl: 'deployment-url',
profileUrl: 'profile-url',
organizationUrl: 'organization-url',
};
}
export const cloudMock = {
createSetup: createSetupMock,
};

View file

@ -0,0 +1,49 @@
/*
* 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 React from 'react';
import { CloudStart } from '.';
import { ServicesProvider } from '../public/services';
function createSetupMock() {
return {
cloudId: 'mock-cloud-id',
isCloudEnabled: true,
cname: 'cname',
baseUrl: 'base-url',
deploymentUrl: 'deployment-url',
profileUrl: 'profile-url',
organizationUrl: 'organization-url',
};
}
const config = {
chat: {
enabled: true,
chatURL: 'chat-url',
user: {
id: 'user-id',
email: 'test-user@elastic.co',
jwt: 'identity-jwt',
},
},
};
const getContextProvider: () => React.FC =
() =>
({ children }) =>
<ServicesProvider {...config}>{children}</ServicesProvider>;
const createStartMock = (): jest.Mocked<CloudStart> => ({
CloudContextProvider: jest.fn(getContextProvider()),
});
export const cloudMock = {
createSetup: createSetupMock,
createStart: createStartMock,
};

View file

@ -40,6 +40,9 @@ describe('Cloud Plugin', () => {
full_story: {
enabled: false,
},
chat: {
enabled: false,
},
...config,
});
@ -47,11 +50,15 @@ describe('Cloud Plugin', () => {
const coreSetup = coreMock.createSetup();
const coreStart = coreMock.createStart();
if (currentAppId$) {
coreStart.application.currentAppId$ = currentAppId$;
}
coreSetup.getStartServices.mockResolvedValue([coreStart, {}, undefined]);
const securitySetup = securityMock.createSetup();
securitySetup.authc.getCurrentUser.mockResolvedValue(
securityMock.createMockAuthenticatedUser(currentUserProps)
);
@ -212,6 +219,101 @@ describe('Cloud Plugin', () => {
});
});
describe('setupChat', () => {
let consoleMock: jest.SpyInstance<void, [message?: any, ...optionalParams: any[]]>;
beforeEach(() => {
consoleMock = jest.spyOn(console, 'debug').mockImplementation(() => {});
});
afterEach(() => {
consoleMock.mockRestore();
});
const setupPlugin = async ({
config = {},
securityEnabled = true,
currentUserProps = {},
isCloudEnabled = true,
failHttp = false,
}: {
config?: Partial<CloudConfigType>;
securityEnabled?: boolean;
currentUserProps?: Record<string, any>;
isCloudEnabled?: boolean;
failHttp?: boolean;
}) => {
const initContext = coreMock.createPluginInitializerContext({
id: isCloudEnabled ? 'cloud-id' : null,
base_url: 'https://cloud.elastic.co',
deployment_url: '/abc123',
profile_url: '/profile/alice',
organization_url: '/org/myOrg',
full_story: {
enabled: false,
},
chat: {
enabled: false,
},
...config,
});
const plugin = new CloudPlugin(initContext);
const coreSetup = coreMock.createSetup();
const coreStart = coreMock.createStart();
if (failHttp) {
coreSetup.http.get.mockImplementation(() => {
throw new Error('HTTP request failed');
});
}
coreSetup.getStartServices.mockResolvedValue([coreStart, {}, undefined]);
const securitySetup = securityMock.createSetup();
securitySetup.authc.getCurrentUser.mockResolvedValue(
securityMock.createMockAuthenticatedUser(currentUserProps)
);
const setup = plugin.setup(coreSetup, securityEnabled ? { security: securitySetup } : {});
return { initContext, plugin, setup, coreSetup };
};
it('chatConfig is not retrieved if cloud is not enabled', async () => {
const { coreSetup } = await setupPlugin({ isCloudEnabled: false });
expect(coreSetup.http.get).not.toHaveBeenCalled();
});
it('chatConfig is not retrieved if security is not enabled', async () => {
const { coreSetup } = await setupPlugin({ securityEnabled: false });
expect(coreSetup.http.get).not.toHaveBeenCalled();
});
it('chatConfig is not retrieved if chat is enabled but url is not provided', async () => {
// @ts-expect-error 2741
const { coreSetup } = await setupPlugin({ config: { chat: { enabled: true } } });
expect(coreSetup.http.get).not.toHaveBeenCalled();
});
it('chatConfig is not retrieved if internal API fails', async () => {
const { coreSetup } = await setupPlugin({
config: { chat: { enabled: true, chatURL: 'http://chat.elastic.co' } },
failHttp: true,
});
expect(coreSetup.http.get).toHaveBeenCalled();
expect(consoleMock).toHaveBeenCalled();
});
it('chatConfig is retrieved if chat is enabled and url is provided', async () => {
const { coreSetup } = await setupPlugin({
config: { chat: { enabled: true, chatURL: 'http://chat.elastic.co' } },
});
expect(coreSetup.http.get).toHaveBeenCalled();
});
});
describe('interface', () => {
const setupPlugin = () => {
const initContext = coreMock.createPluginInitializerContext({
@ -221,6 +323,12 @@ describe('Cloud Plugin', () => {
deployment_url: '/abc123',
profile_url: '/user/settings/',
organization_url: '/account/',
chat: {
enabled: false,
},
full_story: {
enabled: false,
},
});
const plugin = new CloudPlugin(initContext);
@ -284,6 +392,9 @@ describe('Cloud Plugin', () => {
full_story: {
enabled: false,
},
chat: {
enabled: false,
},
})
);
const coreSetup = coreMock.createSetup();

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import React, { FC } from 'react';
import {
CoreSetup,
CoreStart,
@ -15,17 +16,24 @@ import {
ApplicationStart,
} from 'src/core/public';
import { i18n } from '@kbn/i18n';
import { Subscription } from 'rxjs';
import useObservable from 'react-use/lib/useObservable';
import { BehaviorSubject, Subscription } from 'rxjs';
import type {
AuthenticatedUser,
SecurityPluginSetup,
SecurityPluginStart,
} from '../../security/public';
import { getIsCloudEnabled } from '../common/is_cloud_enabled';
import { ELASTIC_SUPPORT_LINK, CLOUD_SNAPSHOTS_PATH } from '../common/constants';
import {
ELASTIC_SUPPORT_LINK,
CLOUD_SNAPSHOTS_PATH,
GET_CHAT_USER_DATA_ROUTE_PATH,
} from '../common/constants';
import type { GetChatUserDataResponseBody } from '../common/types';
import { HomePublicPluginSetup } from '../../../../src/plugins/home/public';
import { createUserMenuLinks } from './user_menu_links';
import { getFullCloudUrl } from './utils';
import { ChatConfig, ServicesProvider } from './services';
export interface CloudConfigType {
id?: string;
@ -38,6 +46,13 @@ export interface CloudConfigType {
enabled: boolean;
org_id?: string;
};
/** Configuration to enable live chat in Cloud-enabled instances of Kibana. */
chat: {
/** Determines if chat is enabled. */
enabled: boolean;
/** The URL to the remotely-hosted chat application. */
chatURL: string;
};
}
interface CloudSetupDependencies {
@ -49,6 +64,13 @@ interface CloudStartDependencies {
security?: SecurityPluginStart;
}
export interface CloudStart {
/**
* A React component that provides a pre-wired `React.Context` which connects components to Cloud services.
*/
CloudContextProvider: FC<{}>;
}
export interface CloudSetup {
cloudId?: string;
cname?: string;
@ -65,10 +87,15 @@ interface SetupFullstoryDeps extends CloudSetupDependencies {
basePath: IBasePath;
}
interface SetupChatDeps extends Pick<CloudSetupDependencies, 'security'> {
http: CoreSetup['http'];
}
export class CloudPlugin implements Plugin<CloudSetup> {
private config!: CloudConfigType;
private isCloudEnabled: boolean;
private appSubscription?: Subscription;
private chatConfig$ = new BehaviorSubject<ChatConfig>({ enabled: false });
constructor(private readonly initializerContext: PluginInitializerContext) {
this.config = this.initializerContext.config.get<CloudConfigType>();
@ -79,6 +106,7 @@ export class CloudPlugin implements Plugin<CloudSetup> {
const application = core.getStartServices().then(([coreStart]) => {
return coreStart.application;
});
this.setupFullstory({ basePath: core.http.basePath, security, application }).catch((e) =>
// eslint-disable-next-line no-console
console.debug(`Error setting up FullStory: ${e.toString()}`)
@ -95,6 +123,11 @@ export class CloudPlugin implements Plugin<CloudSetup> {
this.isCloudEnabled = getIsCloudEnabled(id);
this.setupChat({ http: core.http, security }).catch((e) =>
// eslint-disable-next-line no-console
console.debug(`Error setting up Chat: ${e.toString()}`)
);
if (home) {
home.environment.update({ cloud: this.isCloudEnabled });
if (this.isCloudEnabled) {
@ -119,7 +152,7 @@ export class CloudPlugin implements Plugin<CloudSetup> {
};
}
public start(coreStart: CoreStart, { security }: CloudStartDependencies) {
public start(coreStart: CoreStart, { security }: CloudStartDependencies): CloudStart {
const { deployment_url: deploymentUrl, base_url: baseUrl } = this.config;
coreStart.chrome.setHelpSupportUrl(ELASTIC_SUPPORT_LINK);
@ -147,6 +180,17 @@ export class CloudPlugin implements Plugin<CloudSetup> {
// In the event of an unexpected error, fail *open*.
// Cloud admin console will always perform the actual authorization checks.
.catch(() => setLinks(true));
// There's a risk that the request for chat config will take too much time to complete, and the provider
// will maintain a stale value. To avoid this, we'll use an Observable.
const CloudContextProvider: FC = ({ children }) => {
const chatConfig = useObservable(this.chatConfig$, { enabled: false });
return <ServicesProvider chat={chatConfig}>{children}</ServicesProvider>;
};
return {
CloudContextProvider,
};
}
public stop() {
@ -266,6 +310,43 @@ export class CloudPlugin implements Plugin<CloudSetup> {
...memoryInfo,
});
}
private async setupChat({ http, security }: SetupChatDeps) {
if (!this.isCloudEnabled) {
return;
}
const { enabled, chatURL } = this.config.chat;
if (!security || !enabled || !chatURL) {
return;
}
try {
const {
email,
id,
token: jwt,
} = await http.get<GetChatUserDataResponseBody>(GET_CHAT_USER_DATA_ROUTE_PATH);
if (!email || !id || !jwt) {
return;
}
this.chatConfig$.next({
enabled,
chatURL,
user: {
email,
id,
jwt,
},
});
} catch (e) {
// eslint-disable-next-line no-console
console.debug(`[cloud.chat] Could not retrieve chat config: ${e.res.status} ${e.message}`, e);
}
}
}
/** @internal exported for testing */

View file

@ -0,0 +1,46 @@
/*
* 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 React, { FC, createContext, useContext } from 'react';
interface WithoutChat {
enabled: false;
}
interface WithChat {
enabled: true;
chatURL: string;
user: {
jwt: string;
id: string;
email: string;
};
}
export type ChatConfig = WithChat | WithoutChat;
export interface CloudServices {
chat: ChatConfig;
}
const ServicesContext = createContext<CloudServices>({ chat: { enabled: false } });
export const ServicesProvider: FC<CloudServices> = ({ children, ...services }) => (
<ServicesContext.Provider value={services}>{children}</ServicesContext.Provider>
);
/**
* React hook for accessing the pre-wired `CloudServices`.
*/
export function useServices() {
return useContext(ServicesContext);
}
export function useChat(): ChatConfig {
const { chat } = useServices();
return chat;
}

View file

@ -28,28 +28,36 @@ const fullStoryConfigSchema = schema.object({
),
});
const chatConfigSchema = schema.object({
enabled: schema.boolean({ defaultValue: false }),
chatURL: schema.maybe(schema.string()),
});
const configSchema = schema.object({
id: schema.maybe(schema.string()),
apm: schema.maybe(apmConfigSchema),
cname: schema.maybe(schema.string()),
base_url: schema.maybe(schema.string()),
profile_url: schema.maybe(schema.string()),
chat: chatConfigSchema,
chatIdentitySecret: schema.maybe(schema.string()),
cname: schema.maybe(schema.string()),
deployment_url: schema.maybe(schema.string()),
organization_url: schema.maybe(schema.string()),
full_story: fullStoryConfigSchema,
id: schema.maybe(schema.string()),
organization_url: schema.maybe(schema.string()),
profile_url: schema.maybe(schema.string()),
});
export type CloudConfigType = TypeOf<typeof configSchema>;
export const config: PluginConfigDescriptor<CloudConfigType> = {
exposeToBrowser: {
id: true,
cname: true,
base_url: true,
profile_url: true,
chat: true,
cname: true,
deployment_url: true,
organization_url: true,
full_story: true,
id: true,
organization_url: true,
profile_url: true,
},
schema: configSchema,
};

View file

@ -7,14 +7,17 @@
import { UsageCollectionSetup } from 'src/plugins/usage_collection/server';
import { CoreSetup, Logger, Plugin, PluginInitializerContext } from 'src/core/server';
import type { SecurityPluginSetup } from '../../security/server';
import { CloudConfigType } from './config';
import { registerCloudUsageCollector } from './collectors';
import { getIsCloudEnabled } from '../common/is_cloud_enabled';
import { parseDeploymentIdFromDeploymentUrl } from './utils';
import { registerFullstoryRoute } from './routes/fullstory';
import { registerChatRoute } from './routes/chat';
interface PluginsSetup {
usageCollection?: UsageCollectionSetup;
security?: SecurityPluginSetup;
}
export interface CloudSetup {
@ -30,13 +33,15 @@ export interface CloudSetup {
export class CloudPlugin implements Plugin<CloudSetup> {
private readonly logger: Logger;
private readonly config: CloudConfigType;
private isDev: boolean;
constructor(private readonly context: PluginInitializerContext) {
this.logger = this.context.logger.get();
this.config = this.context.config.get<CloudConfigType>();
this.isDev = this.context.env.mode.dev;
}
public setup(core: CoreSetup, { usageCollection }: PluginsSetup) {
public setup(core: CoreSetup, { usageCollection, security }: PluginsSetup) {
this.logger.debug('Setting up Cloud plugin');
const isCloudEnabled = getIsCloudEnabled(this.config.id);
registerCloudUsageCollector(usageCollection, { isCloudEnabled });
@ -48,6 +53,15 @@ export class CloudPlugin implements Plugin<CloudSetup> {
});
}
if (this.config.chat.enabled && this.config.chatIdentitySecret) {
registerChatRoute({
router: core.http.createRouter(),
chatIdentitySecret: this.config.chatIdentitySecret,
security,
isDev: this.isDev,
});
}
return {
cloudId: this.config.id,
deploymentId: parseDeploymentIdFromDeploymentUrl(this.config.deployment_url),

View file

@ -0,0 +1,109 @@
/*
* 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.
*/
jest.mock('jsonwebtoken', () => ({
sign: () => {
return 'json-web-token';
},
}));
import { httpServiceMock, httpServerMock } from '../../../../../src/core/server/mocks';
import { securityMock } from '../../../security/server/mocks';
import { kibanaResponseFactory } from 'src/core/server';
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' });
expect(router.get.mock.calls).toEqual([]);
});
test('error if no user', async () => {
const security = securityMock.createSetup();
security.authc.getCurrentUser.mockReturnValueOnce(null);
const router = httpServiceMock.createRouter();
registerChatRoute({ router, security, isDev: false, chatIdentitySecret: 'secret' });
const [_config, handler] = router.get.mock.calls[0];
await expect(handler({}, httpServerMock.createKibanaRequest(), kibanaResponseFactory)).resolves
.toMatchInlineSnapshot(`
KibanaResponse {
"options": Object {
"body": "User has no email or username",
},
"payload": "User has no email or username",
"status": 400,
}
`);
});
test('returns user information and a token', async () => {
const security = securityMock.createSetup();
const username = 'user.name';
const email = 'user@elastic.co';
security.authc.getCurrentUser.mockReturnValueOnce({
username,
email,
});
const router = httpServiceMock.createRouter();
registerChatRoute({ router, security, isDev: false, chatIdentitySecret: 'secret' });
const [_config, handler] = router.get.mock.calls[0];
await expect(handler({}, httpServerMock.createKibanaRequest(), kibanaResponseFactory)).resolves
.toMatchInlineSnapshot(`
KibanaResponse {
"options": Object {
"body": Object {
"email": "${email}",
"id": "${username}",
"token": "json-web-token",
},
},
"payload": Object {
"email": "${email}",
"id": "${username}",
"token": "json-web-token",
},
"status": 200,
}
`);
});
test('returns placeholder user information and a token in dev mode', async () => {
const security = securityMock.createSetup();
const username = 'first.last';
const email = 'test+first.last@elasticsearch.com';
security.authc.getCurrentUser.mockReturnValueOnce({});
const router = httpServiceMock.createRouter();
registerChatRoute({ router, security, isDev: true, chatIdentitySecret: 'secret' });
const [_config, handler] = router.get.mock.calls[0];
await expect(handler({}, httpServerMock.createKibanaRequest(), kibanaResponseFactory)).resolves
.toMatchInlineSnapshot(`
KibanaResponse {
"options": Object {
"body": Object {
"email": "${email}",
"id": "${username}",
"token": "json-web-token",
},
},
"payload": Object {
"email": "${email}",
"id": "${username}",
"token": "json-web-token",
},
"status": 200,
}
`);
});
});

View file

@ -0,0 +1,64 @@
/*
* 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 { IRouter } from '../../../../../src/core/server';
import type { SecurityPluginSetup } from '../../../security/server';
import { GET_CHAT_USER_DATA_ROUTE_PATH } from '../../common/constants';
import type { GetChatUserDataResponseBody } from '../../common/types';
import { generateSignedJwt } from '../util/generate_jwt';
export const registerChatRoute = ({
router,
chatIdentitySecret,
security,
isDev,
}: {
router: IRouter;
chatIdentitySecret: string;
security?: SecurityPluginSetup;
isDev: boolean;
}) => {
if (!security) {
return;
}
router.get(
{
path: GET_CHAT_USER_DATA_ROUTE_PATH,
validate: {},
},
async (_context, request, response) => {
const user = security.authc.getCurrentUser(request);
let { email: userEmail, username: userId } = user || {};
// In local development, these values are not populated. This is a workaround
// to allow for local testing.
if (isDev) {
if (!userId) {
userId = 'first.last';
}
if (!userEmail) {
userEmail = userEmail || `test+${userId}@elasticsearch.com`;
}
}
if (!userEmail || !userId) {
return response.badRequest({
body: 'User has no email or username',
});
}
const token = generateSignedJwt(userId, chatIdentitySecret);
const body: GetChatUserDataResponseBody = {
token,
email: userEmail,
id: userId,
};
return response.ok({ body });
}
);
};

View file

@ -0,0 +1,23 @@
/*
* 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.
*/
jest.mock('jsonwebtoken', () => ({
sign: (payload: {}, secret: string, options: {}) => {
return `${JSON.stringify(payload)}.${secret}.${JSON.stringify(options)}`;
},
}));
import { generateSignedJwt } from './generate_jwt';
describe('generateSignedJwt', () => {
test('creating a JWT token', () => {
const jwtToken = generateSignedJwt('test', '123456');
expect(jwtToken).toEqual(
'{"sub":"test"}.123456.{"header":{"alg":"HS256","typ":"JWT"},"expiresIn":300}'
);
});
});

View file

@ -0,0 +1,24 @@
/*
* 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 jwt from 'jsonwebtoken';
export const generateSignedJwt = (userId: string, secret: string): string => {
const options = {
header: {
alg: 'HS256',
typ: 'JWT',
},
expiresIn: 5 * 60, // 5m
};
const payload = {
sub: userId,
};
return jwt.sign(payload, secret, options);
};

View file

@ -7,9 +7,11 @@
"declarationMap": true,
},
"include": [
".storybook/**/*",
"common/**/*",
"public/**/*",
"server/**/*",
"../../../typings/**/*"
],
"references": [
{ "path": "../../../src/core/tsconfig.json" },

View file

@ -53,7 +53,10 @@ export const StorybookContext: React.FC<{ storyContext?: StoryContext }> = ({
...stubbedStartServices,
application: getApplication(),
chrome: getChrome(),
cloud: getCloud({ isCloudEnabled }),
cloud: {
...getCloud({ isCloudEnabled }),
CloudContextProvider: () => <></>,
},
customIntegrations: {
ContextProvider: getStorybookContextProvider(),
},

View file

@ -11,5 +11,5 @@
"requiredPlugins": ["licensing", "data", "encryptedSavedObjects", "navigation", "customIntegrations", "share", "spaces", "security"],
"optionalPlugins": ["features", "cloud", "usageCollection", "home", "globalSearch", "telemetry"],
"extraPublicDirs": ["common"],
"requiredBundles": ["kibanaReact", "esUiShared", "home", "infra", "kibanaUtils", "usageCollection"]
"requiredBundles": ["kibanaReact", "cloud", "esUiShared", "home", "infra", "kibanaUtils", "usageCollection"]
}

View file

@ -21,6 +21,7 @@ import {
RedirectAppLinks,
} from '../../../../../../src/plugins/kibana_react/public';
import { EuiThemeProvider } from '../../../../../../src/plugins/kibana_react/common';
import { Chat } from '../../../../cloud/public';
import { KibanaThemeProvider } from '../../../../../../src/plugins/kibana_react/public';
@ -33,6 +34,8 @@ import { EPMApp } from './sections/epm';
import { PackageInstallProvider, UIExtensionsContext } from './hooks';
import { IntegrationsHeader } from './components/header';
const EmptyContext = () => <></>;
/**
* Fleet Application context all the way down to the Router, but with no permissions or setup checks
* and no routes defined
@ -60,6 +63,7 @@ export const IntegrationsAppContext: React.FC<{
theme$,
}) => {
const isDarkMode = useObservable<boolean>(startServices.uiSettings.get$('theme:darkMode'));
const CloudContext = startServices.cloud?.CloudContextProvider || EmptyContext;
return (
<RedirectAppLinks application={startServices.application}>
@ -73,17 +77,20 @@ export const IntegrationsAppContext: React.FC<{
<UIExtensionsContext.Provider value={extensions}>
<FleetStatusProvider>
<startServices.customIntegrations.ContextProvider>
<Router history={history}>
<AgentPolicyContextProvider>
<PackageInstallProvider
notifications={startServices.notifications}
theme$={theme$}
>
<IntegrationsHeader {...{ setHeaderActionMenu, theme$ }} />
{children}
</PackageInstallProvider>
</AgentPolicyContextProvider>
</Router>
<CloudContext>
<Router history={history}>
<AgentPolicyContextProvider>
<PackageInstallProvider
notifications={startServices.notifications}
theme$={theme$}
>
<IntegrationsHeader {...{ setHeaderActionMenu, theme$ }} />
{children}
<Chat />
</PackageInstallProvider>
</AgentPolicyContextProvider>
</Router>
</CloudContext>
</startServices.customIntegrations.ContextProvider>
</FleetStatusProvider>
</UIExtensionsContext.Provider>

View file

@ -16,7 +16,7 @@ import { setHttpClient } from '../hooks/use_request';
import type { FleetAuthz } from '../../common';
import { createStartDepsMock } from './plugin_dependencies';
import { createStartDepsMock, createSetupDepsMock } from './plugin_dependencies';
import type { MockedFleetStartServices } from './types';
// Taken from core. See: src/plugins/kibana_utils/public/storage/storage.test.ts
@ -71,9 +71,16 @@ const configureStartServices = (services: MockedFleetStartServices): void => {
};
export const createStartServices = (basePath: string = '/mock'): MockedFleetStartServices => {
const { cloud: cloudStart, ...startDeps } = createStartDepsMock();
const { cloud: cloudSetup } = createSetupDepsMock();
const startServices: MockedFleetStartServices = {
...coreMock.createStart({ basePath }),
...createStartDepsMock(),
...startDeps,
cloud: {
...cloudStart,
...cloudSetup,
},
storage: new Storage(createMockStore()) as jest.Mocked<Storage>,
authz: fleetAuthzMock,
};

View file

@ -7,27 +7,29 @@
import { dataPluginMock } from '../../../../../src/plugins/data/public/mocks';
import { licensingMock } from '../../../licensing/public/mocks';
import { cloudMock } from '../../../cloud/public/mocks';
import { homePluginMock } from '../../../../../src/plugins/home/public/mocks';
import { navigationPluginMock } from '../../../../../src/plugins/navigation/public/mocks';
import { customIntegrationsMock } from '../../../../../src/plugins/custom_integrations/public/mocks';
import { sharePluginMock } from '../../../../../src/plugins/share/public/mocks';
import type { MockedFleetSetupDeps, MockedFleetStartDeps } from './types';
export const createSetupDepsMock = (): MockedFleetSetupDeps => {
export const createSetupDepsMock = () => {
const cloud = cloudMock.createSetup();
return {
licensing: licensingMock.createSetup(),
data: dataPluginMock.createSetupContract(),
home: homePluginMock.createSetupContract(),
customIntegrations: customIntegrationsMock.createSetup(),
cloud,
};
};
export const createStartDepsMock = (): MockedFleetStartDeps => {
export const createStartDepsMock = () => {
return {
data: dataPluginMock.createStartContract(),
navigation: navigationPluginMock.createStartContract(),
customIntegrations: customIntegrationsMock.createStart(),
share: sharePluginMock.createStartContract(),
cloud: cloudMock.createStart(),
};
};

View file

@ -26,6 +26,8 @@ import type { SharePluginStart } from 'src/plugins/share/public';
import { once } from 'lodash';
import type { CloudStart } from '../../cloud/public';
import type { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/public';
import { DEFAULT_APP_CATEGORIES, AppNavLinkStatus } from '../../../../src/core/public';
@ -94,12 +96,13 @@ export interface FleetStartDeps {
navigation: NavigationPublicPluginStart;
customIntegrations: CustomIntegrationsStart;
share: SharePluginStart;
cloud?: CloudStart;
}
export interface FleetStartServices extends CoreStart, FleetStartDeps {
export interface FleetStartServices extends CoreStart, Exclude<FleetStartDeps, 'cloud'> {
storage: Storage;
share: SharePluginStart;
cloud?: CloudSetup;
cloud?: CloudSetup & CloudStart;
authz: FleetAuthz;
}
@ -141,11 +144,16 @@ export class FleetPlugin implements Plugin<FleetSetup, FleetStart, FleetSetupDep
euiIconType: 'logoElastic',
mount: async (params: AppMountParameters) => {
const [coreStartServices, startDepsServices, fleetStart] = await core.getStartServices();
const cloud =
deps.cloud && startDepsServices.cloud
? { ...deps.cloud, ...startDepsServices.cloud }
: undefined;
const startServices: FleetStartServices = {
...coreStartServices,
...startDepsServices,
storage: this.storage,
cloud: deps.cloud,
cloud,
authz: await fleetStart.authz,
};
const { renderApp, teardownIntegrations } = await import('./applications/integrations');
@ -178,11 +186,15 @@ export class FleetPlugin implements Plugin<FleetSetup, FleetStart, FleetSetupDep
appRoute: '/app/fleet',
mount: async (params: AppMountParameters) => {
const [coreStartServices, startDepsServices, fleetStart] = await core.getStartServices();
const cloud =
deps.cloud && startDepsServices.cloud
? { ...deps.cloud, ...startDepsServices.cloud }
: undefined;
const startServices: FleetStartServices = {
...coreStartServices,
...startDepsServices,
storage: this.storage,
cloud: deps.cloud,
cloud,
authz: await fleetStart.authz,
};
const { renderApp, teardownFleet } = await import('./applications/fleet');

View file

@ -14,6 +14,10 @@ const FULLSTORY_ORG_ID = process.env.FULLSTORY_ORG_ID;
const FULLSTORY_API_KEY = process.env.FULLSTORY_API_KEY;
const RUN_FULLSTORY_TESTS = Boolean(FULLSTORY_ORG_ID && FULLSTORY_API_KEY);
const CHAT_URL = process.env.CHAT_URL;
const CHAT_IDENTITY_SECRET = process.env.CHAT_IDENTITY_SECRET;
const RUN_CHAT_TESTS = Boolean(CHAT_URL);
// the default export of config files must be a config provider
// that returns an object with the projects config values
export default async function ({ readConfigFile }: FtrConfigProviderContext) {
@ -29,7 +33,11 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
const samlIdPPlugin = resolve(__dirname, './fixtures/saml/saml_provider');
return {
testFiles: [...(RUN_FULLSTORY_TESTS ? [resolve(__dirname, './tests/fullstory')] : [])],
testFiles: [
...(RUN_FULLSTORY_TESTS ? [resolve(__dirname, './tests/fullstory')] : []),
...(RUN_CHAT_TESTS ? [resolve(__dirname, './tests/chat')] : []),
...(!RUN_CHAT_TESTS ? [resolve(__dirname, './tests/chat_disabled')] : []),
],
services,
pageObjects,
@ -69,6 +77,14 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
`--xpack.cloud.full_story.org_id=${FULLSTORY_ORG_ID}`,
]
: []),
...(RUN_CHAT_TESTS
? [
'--xpack.cloud.id=5b2de169-2785-441b-ae8c-186a1936b17d',
'--xpack.cloud.chat.enabled=true',
`--xpack.cloud.chat.chatURL=${CHAT_URL}`,
`--xpack.cloud.chatIdentitySecret=${CHAT_IDENTITY_SECRET}`,
]
: []),
],
},
uiSettings: {

View file

@ -0,0 +1,30 @@
/*
* 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 expect from '@kbn/expect';
import { FtrProviderContext } from '../ftr_provider_context';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const find = getService('find');
const PageObjects = getPageObjects(['common']);
describe('Cloud Chat integration', function () {
before(async () => {
// Create role mapping so user gets superuser access
await getService('esSupertest')
.post('/_security/role_mapping/saml1')
.send({ roles: ['superuser'], enabled: true, rules: { field: { 'realm.name': 'saml1' } } })
.expect(200);
});
it('chat widget is present when enabled', async () => {
PageObjects.common.navigateToUrl('integrations', 'browse', { useActualUrl: true });
const chat = await find.byCssSelector('[data-test-subj="floatingChatTrigger"]', 20000);
expect(chat).to.not.be(null);
});
});
}