[RAM] Revert "RAM Slack connector improvements" (#154093)

Reverts elastic/kibana#149127

from pmuellr:

After looking into https://github.com/elastic/kibana/issues/153939 -
Preconfigured slack Web API connector does not work - we realized we
really should have added the support for the Slack Web API in a
different connector.

Lesson learned: don't design connectors where the set of parameters
differs depending on a config (or secret) value.  We have code - at
least with the "test" functionality - that assumes you can create a form
of parameters based solely on the connector type, without having access
to the connector data (we could/should probably add an enhancement to do
that).  So it would never be able to render the appropriate parameters.

There is also the semantic issue that if you changed the Slack type
(from webhook to web-api), via the HTTP API (prevented in the UX), you
would break all actions using that connector, since they wouldn't have
the right parameters set.

Complete guess, but there may be other lurking bits like this in the
codebase that we just haven't hit yet.

Given all that, we reluctantly decided to split the connector into two,
and revert the PR that added the new code.

RIP Slack connector improvements, hope to see your new version in a few
days! :-)
This commit is contained in:
Xavier Mouligneau 2023-03-31 10:14:28 -04:00 committed by GitHub
parent 0f3b37b63c
commit 7776dfa696
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 815 additions and 2953 deletions

View file

@ -1,9 +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.
*/
export const SLACK_CONNECTOR_ID = '.slack';
export const SLACK_URL = 'https://slack.com/api/';

View file

@ -1,49 +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.
*/
import { schema } from '@kbn/config-schema';
export const SlackConfigSchema = schema.object({
type: schema.oneOf([schema.literal('webhook'), schema.literal('web_api')], {
defaultValue: 'webhook',
}),
});
export const SlackWebhookSecretsSchema = schema.object({
webhookUrl: schema.string({ minLength: 1 }),
});
export const SlackWebApiSecretsSchema = schema.object({
token: schema.string({ minLength: 1 }),
});
export const SlackSecretsSchema = schema.oneOf([
SlackWebhookSecretsSchema,
SlackWebApiSecretsSchema,
]);
export const ExecutorGetChannelsParamsSchema = schema.object({
subAction: schema.literal('getChannels'),
});
export const PostMessageSubActionParamsSchema = schema.object({
channels: schema.arrayOf(schema.string()),
text: schema.string(),
});
export const ExecutorPostMessageParamsSchema = schema.object({
subAction: schema.literal('postMessage'),
subActionParams: PostMessageSubActionParamsSchema,
});
export const WebhookParamsSchema = schema.object({
message: schema.string({ minLength: 1 }),
});
export const WebApiParamsSchema = schema.oneOf([
ExecutorGetChannelsParamsSchema,
ExecutorPostMessageParamsSchema,
]);
export const SlackParamsSchema = schema.oneOf([WebhookParamsSchema, WebApiParamsSchema]);

View file

@ -1,57 +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.
*/
import { TypeOf } from '@kbn/config-schema';
import type { ActionTypeExecutorOptions as ConnectorTypeExecutorOptions } from '@kbn/actions-plugin/server/types';
import type { ActionType as ConnectorType } from '@kbn/actions-plugin/server/types';
import {
ExecutorPostMessageParamsSchema,
PostMessageSubActionParamsSchema,
SlackConfigSchema,
SlackSecretsSchema,
SlackWebhookSecretsSchema,
SlackWebApiSecretsSchema,
WebhookParamsSchema,
WebApiParamsSchema,
} from './schema';
export type SlackConfig = TypeOf<typeof SlackConfigSchema>;
export type SlackSecrets = TypeOf<typeof SlackSecretsSchema>;
export type PostMessageParams = TypeOf<typeof ExecutorPostMessageParamsSchema>;
export type PostMessageSubActionParams = TypeOf<typeof PostMessageSubActionParamsSchema>;
export type SlackWebhookSecrets = TypeOf<typeof SlackWebhookSecretsSchema>;
export type SlackWebApiSecrets = TypeOf<typeof SlackWebApiSecretsSchema>;
export type SlackWebhookExecutorOptions = ConnectorTypeExecutorOptions<
SlackConfig,
SlackWebhookSecrets,
WebhookParams
>;
export type SlackWebApiExecutorOptions = ConnectorTypeExecutorOptions<
SlackConfig,
SlackWebApiSecrets,
WebApiParams
>;
export type SlackExecutorOptions = ConnectorTypeExecutorOptions<
SlackConfig,
SlackSecrets,
WebhookParams | WebApiParams
>;
export type SlackConnectorType = ConnectorType<
SlackConfig,
SlackSecrets,
WebhookParams | WebApiParams,
unknown
>;
export type WebhookParams = TypeOf<typeof WebhookParamsSchema>;
export type WebApiParams = TypeOf<typeof WebApiParamsSchema>;
export type SlackActionParams = WebhookParams | WebApiParams;

View file

@ -30,16 +30,13 @@ describe('connectorTypeRegistry.get() works', () => {
});
describe('slack action params validation', () => {
test('should succeed when action params include valid message', async () => {
test('if action params validation succeeds when action params is valid', async () => {
const actionParams = {
message: 'message {test}',
};
expect(await connectorTypeModel.validateParams(actionParams)).toEqual({
errors: {
message: [],
'subActionParams.channels': [],
},
errors: { message: [] },
});
});
@ -51,77 +48,6 @@ describe('slack action params validation', () => {
expect(await connectorTypeModel.validateParams(actionParams)).toEqual({
errors: {
message: ['Message is required.'],
'subActionParams.channels': [],
},
});
});
test('should succeed when action params include valid message and channels list', async () => {
const actionParams = {
subAction: 'postMessage',
subActionParams: { channels: ['general'], text: 'some text' },
};
expect(await connectorTypeModel.validateParams(actionParams)).toEqual({
errors: {
message: [],
'subActionParams.channels': [],
},
});
});
test('should fail when action params do not includes any channels', async () => {
const actionParams = {
subAction: 'postMessage',
subActionParams: { channels: [], text: 'some text' },
};
expect(await connectorTypeModel.validateParams(actionParams)).toEqual({
errors: {
message: [],
'subActionParams.channels': ['At least one selected channel is required.'],
},
});
});
test('should fail when channels field is missing in action params', async () => {
const actionParams = {
subAction: 'postMessage',
subActionParams: { text: 'some text' },
};
expect(await connectorTypeModel.validateParams(actionParams)).toEqual({
errors: {
message: [],
'subActionParams.channels': ['At least one selected channel is required.'],
},
});
});
test('should fail when field text doesnot exist', async () => {
const actionParams = {
subAction: 'postMessage',
subActionParams: { channels: ['general'] },
};
expect(await connectorTypeModel.validateParams(actionParams)).toEqual({
errors: {
message: ['Message is required.'],
'subActionParams.channels': [],
},
});
});
test('should fail when text is empty string', async () => {
const actionParams = {
subAction: 'postMessage',
subActionParams: { channels: ['general'], text: '' },
};
expect(await connectorTypeModel.validateParams(actionParams)).toEqual({
errors: {
message: ['Message is required.'],
'subActionParams.channels': [],
},
});
});

View file

@ -11,21 +11,11 @@ import type {
ActionTypeModel as ConnectorTypeModel,
GenericValidationResult,
} from '@kbn/triggers-actions-ui-plugin/public/types';
import { SLACK_CONNECTOR_ID } from '../../../common/slack/constants';
import type {
SlackActionParams,
SlackSecrets,
WebhookParams,
PostMessageParams,
} from '../../../common/slack/types';
import { SlackActionParams, SlackSecrets } from '../types';
export function getConnectorType(): ConnectorTypeModel<
unknown,
SlackSecrets,
WebhookParams | PostMessageParams
> {
export function getConnectorType(): ConnectorTypeModel<unknown, SlackSecrets, SlackActionParams> {
return {
id: SLACK_CONNECTOR_ID,
id: '.slack',
iconClass: 'logoSlack',
selectMessage: i18n.translate('xpack.stackConnectors.components.slack.selectMessageText', {
defaultMessage: 'Send a message to a Slack channel or user.',
@ -39,45 +29,14 @@ export function getConnectorType(): ConnectorTypeModel<
const translations = await import('./translations');
const errors = {
message: new Array<string>(),
'subActionParams.channels': new Array<string>(),
};
const validationResult = { errors };
if ('subAction' in actionParams) {
if (actionParams.subAction === 'postMessage') {
if (!actionParams.subActionParams.text) {
errors.message.push(translations.MESSAGE_REQUIRED);
}
if (!actionParams.subActionParams.channels?.length) {
errors['subActionParams.channels'].push(translations.CHANNEL_REQUIRED);
}
}
} else {
if (!actionParams.message) {
errors.message.push(translations.MESSAGE_REQUIRED);
}
if (!actionParams.message?.length) {
errors.message.push(translations.MESSAGE_REQUIRED);
}
return validationResult;
},
actionConnectorFields: lazy(() => import('./slack_connectors')),
actionParamsFields: lazy(() => import('./slack_params')),
resetParamsOnConnectorChange: (
params: WebhookParams | PostMessageParams
): WebhookParams | PostMessageParams | {} => {
if ('message' in params) {
return {
subAction: 'postMessage',
subActionParams: {
channels: [],
text: params.message,
},
};
} else if ('subAction' in params) {
return {
message: (params as PostMessageParams).subActionParams.text,
};
}
return {};
},
};
}

View file

@ -6,20 +6,16 @@
*/
import React from 'react';
import { act, render, fireEvent, screen } from '@testing-library/react';
import { mountWithIntl, nextTick } from '@kbn/test-jest-helpers';
import { act, render } from '@testing-library/react';
import SlackActionFields from './slack_connectors';
import { ConnectorFormTestProvider, waitForComponentToUpdate } from '../lib/test_utils';
import { ConnectorFormTestProvider } from '../lib/test_utils';
import userEvent from '@testing-library/user-event';
jest.mock('@kbn/triggers-actions-ui-plugin/public/common/lib/kibana');
describe('SlackActionFields renders', () => {
const onSubmit = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
it('all connector fields is rendered for webhook type', async () => {
test('all connector fields is rendered', async () => {
const actionConnector = {
secrets: {
webhookUrl: 'http://test.com',
@ -31,174 +27,107 @@ describe('SlackActionFields renders', () => {
isDeprecated: false,
};
render(
const wrapper = mountWithIntl(
<ConnectorFormTestProvider connector={actionConnector}>
<SlackActionFields readOnly={false} isEdit={false} registerPreSubmitValidator={() => {}} />
</ConnectorFormTestProvider>
);
fireEvent.click(screen.getByTestId('webhook'));
expect(screen.getByTestId('slackWebhookUrlInput')).toBeInTheDocument();
expect(screen.getByTestId('slackWebhookUrlInput')).toHaveValue('http://test.com');
});
it('all connector fields is rendered for web_api type', async () => {
const actionConnector = {
secrets: {
token: 'some token',
},
id: 'test',
actionTypeId: '.slack',
name: 'slack',
config: {},
isDeprecated: false,
};
render(
<ConnectorFormTestProvider connector={actionConnector} onSubmit={onSubmit}>
<SlackActionFields readOnly={false} isEdit={false} registerPreSubmitValidator={() => {}} />
</ConnectorFormTestProvider>
);
expect(screen.getByTestId('slackTypeChangeButton')).toBeInTheDocument();
expect(screen.getByTestId('secrets.token-input')).toBeInTheDocument();
expect(screen.getByTestId('secrets.token-input')).toHaveValue('some token');
});
it('should not show slack type tabs when in editing mode', async () => {
const actionConnector = {
secrets: {
token: 'some token',
},
id: 'test',
actionTypeId: '.slack',
name: 'slack',
config: {},
isDeprecated: false,
};
render(
<ConnectorFormTestProvider connector={actionConnector} onSubmit={onSubmit}>
<SlackActionFields readOnly={false} isEdit={true} registerPreSubmitValidator={() => {}} />
</ConnectorFormTestProvider>
);
expect(screen.queryByTestId('slackTypeChangeButton')).not.toBeInTheDocument();
});
it('connector validation succeeds when connector config is valid for Web API type', async () => {
const actionConnector = {
secrets: {
token: 'some token',
},
id: 'test',
actionTypeId: '.slack',
name: 'slack',
config: {},
isDeprecated: false,
};
render(
<ConnectorFormTestProvider connector={actionConnector} onSubmit={onSubmit}>
<SlackActionFields readOnly={false} isEdit={false} registerPreSubmitValidator={() => {}} />
</ConnectorFormTestProvider>
);
await waitForComponentToUpdate();
await act(async () => {
fireEvent.click(screen.getByTestId('form-test-provide-submit'));
await nextTick();
wrapper.update();
});
expect(onSubmit).toBeCalledTimes(1);
expect(onSubmit).toBeCalledWith({
data: {
secrets: {
token: 'some token',
},
config: {
type: 'web_api',
},
id: 'test',
actionTypeId: '.slack',
name: 'slack',
isDeprecated: false,
},
isValid: true,
});
expect(wrapper.find('[data-test-subj="slackWebhookUrlInput"]').length > 0).toBeTruthy();
expect(wrapper.find('[data-test-subj="slackWebhookUrlInput"]').first().prop('value')).toBe(
'http://test.com'
);
});
it('connector validation succeeds when connector config is valid for Webhook type', async () => {
const actionConnector = {
secrets: {
webhookUrl: 'http://test.com',
},
id: 'test',
actionTypeId: '.slack',
name: 'slack',
config: {},
isDeprecated: false,
};
describe('Validation', () => {
const onSubmit = jest.fn();
render(
<ConnectorFormTestProvider connector={actionConnector} onSubmit={onSubmit}>
<SlackActionFields readOnly={false} isEdit={false} registerPreSubmitValidator={() => {}} />
</ConnectorFormTestProvider>
);
await waitForComponentToUpdate();
fireEvent.click(screen.getByTestId('webhook'));
await act(async () => {
fireEvent.click(screen.getByTestId('form-test-provide-submit'));
beforeEach(() => {
jest.clearAllMocks();
});
expect(onSubmit).toBeCalledTimes(1);
expect(onSubmit).toBeCalledWith({
data: {
it('connector validation succeeds when connector config is valid', async () => {
const actionConnector = {
secrets: {
webhookUrl: 'http://test.com',
},
config: {
type: 'webhook',
id: 'test',
actionTypeId: '.slack',
name: 'slack',
config: {},
isDeprecated: false,
};
const { getByTestId } = render(
<ConnectorFormTestProvider connector={actionConnector} onSubmit={onSubmit}>
<SlackActionFields
readOnly={false}
isEdit={false}
registerPreSubmitValidator={() => {}}
/>
</ConnectorFormTestProvider>
);
await act(async () => {
userEvent.click(getByTestId('form-test-provide-submit'));
});
expect(onSubmit).toBeCalledWith({
data: {
secrets: {
webhookUrl: 'http://test.com',
},
id: 'test',
actionTypeId: '.slack',
name: 'slack',
isDeprecated: false,
},
isValid: true,
});
});
it('validates teh web hook url field correctly', async () => {
const actionConnector = {
secrets: {
webhookUrl: 'http://test.com',
},
id: 'test',
actionTypeId: '.slack',
name: 'slack',
config: {},
isDeprecated: false,
},
isValid: true,
};
const { getByTestId } = render(
<ConnectorFormTestProvider connector={actionConnector} onSubmit={onSubmit}>
<SlackActionFields
readOnly={false}
isEdit={false}
registerPreSubmitValidator={() => {}}
/>
</ConnectorFormTestProvider>
);
await act(async () => {
await userEvent.type(
getByTestId('slackWebhookUrlInput'),
`{selectall}{backspace}no-valid`,
{
delay: 10,
}
);
});
await act(async () => {
userEvent.click(getByTestId('form-test-provide-submit'));
});
expect(onSubmit).toHaveBeenCalledWith({ data: {}, isValid: false });
});
});
it('validates teh web hook url field correctly', async () => {
const actionConnector = {
secrets: {},
id: 'test',
actionTypeId: '.slack',
name: 'slack',
config: {},
isDeprecated: false,
};
render(
<ConnectorFormTestProvider connector={actionConnector} onSubmit={onSubmit}>
<SlackActionFields readOnly={false} isEdit={false} registerPreSubmitValidator={() => {}} />
</ConnectorFormTestProvider>
);
await waitForComponentToUpdate();
fireEvent.click(screen.getByTestId('webhook'));
await userEvent.type(
screen.getByTestId('slackWebhookUrlInput'),
`{selectall}{backspace}no-valid`,
{
delay: 10,
}
);
await act(async () => {
fireEvent.click(screen.getByTestId('form-test-provide-submit'));
});
expect(onSubmit).toHaveBeenCalledWith({ data: {}, isValid: false });
});
});

View file

@ -5,88 +5,55 @@
* 2.0.
*/
import React, { useEffect, useState } from 'react';
import { EuiSpacer, EuiButtonGroup } from '@elastic/eui';
import React from 'react';
import { EuiLink } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { FieldConfig, UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib';
import { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers';
import { Field } from '@kbn/es-ui-shared-plugin/static/forms/components';
import { DocLinksStart } from '@kbn/core/public';
import type { ActionConnectorFieldsProps } from '@kbn/triggers-actions-ui-plugin/public';
import { useFormContext } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib';
import { HiddenField } from '@kbn/triggers-actions-ui-plugin/public';
import { useKibana } from '@kbn/triggers-actions-ui-plugin/public';
import * as i18n from './translations';
import { SlackWebApiActionsFields } from './slack_web_api_connectors';
import { SlackWebhookActionFields } from './slack_webhook_connectors';
const slackTypeButtons = [
{
id: 'webhook',
label: i18n.WEBHOOK,
'data-test-subj': 'webhookButton',
},
{
id: 'web_api',
label: i18n.WEB_API,
'data-test-subj': 'webApiButton',
},
];
const { urlField } = fieldValidators;
const getWebhookUrlConfig = (docLinks: DocLinksStart): FieldConfig => ({
label: i18n.WEBHOOK_URL_LABEL,
helpText: (
<EuiLink href={docLinks.links.alerting.slackAction} target="_blank">
<FormattedMessage
id="xpack.stackConnectors.components.slack.webhookUrlHelpLabel"
defaultMessage="Create a Slack Webhook URL"
/>
</EuiLink>
),
validations: [
{
validator: urlField(i18n.WEBHOOK_URL_INVALID),
},
],
});
const SlackActionFields: React.FunctionComponent<ActionConnectorFieldsProps> = ({
isEdit,
readOnly,
registerPreSubmitValidator,
}) => {
const { setFieldValue, getFieldDefaultValue } = useFormContext();
const defaultSlackType = getFieldDefaultValue('config.type');
const [selectedSlackType, setSelectedSlackType] = useState(
getFieldDefaultValue<string>('config.type') ?? 'web_api'
);
const onChange = (id: string) => {
setSelectedSlackType(id);
};
useEffect(() => {
setFieldValue('config.type', selectedSlackType);
}, [selectedSlackType, setFieldValue]);
const { docLinks } = useKibana().services;
return (
<>
<EuiSpacer size="xs" />
{!isEdit && (
<EuiButtonGroup
isFullWidth
buttonSize="m"
color="primary"
legend={i18n.SLACK_LEGEND}
options={slackTypeButtons}
idSelected={selectedSlackType}
onChange={onChange}
data-test-subj="slackTypeChangeButton"
/>
)}
<HiddenField path={'config.type'} config={{ defaultValue: defaultSlackType }} />
<EuiSpacer size="m" />
{/* The components size depends on slack type option we choose. Just putting a limit to form
width would change component dehaviour during the sizing. This line make component size to
max, so it does not change during sizing, but keep the same behaviour the designer put into
it.
*/}
<div style={{ width: '100vw', height: 0 }} />
{selectedSlackType === 'webhook' ? (
<SlackWebhookActionFields
isEdit={isEdit}
readOnly={readOnly}
registerPreSubmitValidator={registerPreSubmitValidator}
/>
) : null}
{selectedSlackType === 'web_api' ? (
<>
<SlackWebApiActionsFields
isEdit={isEdit}
readOnly={readOnly}
registerPreSubmitValidator={registerPreSubmitValidator}
/>
</>
) : null}
</>
<UseField
path="secrets.webhookUrl"
config={getWebhookUrlConfig(docLinks)}
component={Field}
componentProps={{
euiFieldProps: {
readOnly,
'data-test-subj': 'slackWebhookUrlInput',
fullWidth: true,
},
}}
/>
);
};

View file

@ -6,277 +6,78 @@
*/
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import SlackParamsFields from './slack_params';
import type { UseSubActionParams } from '@kbn/triggers-actions-ui-plugin/public/application/hooks/use_sub_action';
import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
interface Result {
isLoading: boolean;
response: Record<string, unknown>;
error: null | Error;
}
const triggersActionsPath = '@kbn/triggers-actions-ui-plugin/public';
const mockUseSubAction = jest.fn<Result, [UseSubActionParams<unknown>]>(
jest.fn<Result, [UseSubActionParams<unknown>]>(() => ({
isLoading: false,
response: {
channels: [
{
id: 'id',
name: 'general',
is_channel: true,
is_archived: false,
is_private: true,
},
],
},
error: null,
}))
);
const mockToasts = { danger: jest.fn(), warning: jest.fn() };
jest.mock(triggersActionsPath, () => {
const original = jest.requireActual(triggersActionsPath);
return {
...original,
useSubAction: (params: UseSubActionParams<unknown>) => mockUseSubAction(params),
useKibana: () => ({
...original.useKibana(),
notifications: { toasts: mockToasts },
}),
};
});
describe('SlackParamsFields renders', () => {
test('all params fields is rendered, Webhook', () => {
render(
test('all params fields is rendered', () => {
const actionParams = {
message: 'test message',
};
const wrapper = mountWithIntl(
<SlackParamsFields
actionConnector={{ config: { type: 'webhook' } } as any}
actionParams={{ message: 'some message' }}
actionParams={actionParams}
errors={{ message: [] }}
editAction={() => {}}
index={0}
defaultMessage="default message"
messageVariables={[]}
/>
);
expect(screen.getByTestId('messageTextArea')).toBeInTheDocument();
expect(screen.getByTestId('messageTextArea')).toHaveValue('some message');
});
test('when useDefaultMessage is set to true and the default message changes, the underlying message is replaced with the default message, Webhook', () => {
const editAction = jest.fn();
const { rerender } = render(
<IntlProvider locale="en">
<SlackParamsFields
actionConnector={{ config: { type: 'webhook' } } as any}
actionParams={{ message: 'some text' }}
errors={{ message: [] }}
editAction={editAction}
index={0}
defaultMessage="default message"
messageVariables={[]}
useDefaultMessage={true}
/>
</IntlProvider>
);
expect(screen.getByTestId('messageTextArea')).toBeInTheDocument();
expect(screen.getByTestId('messageTextArea')).toHaveValue('some text');
rerender(
<IntlProvider locale="en">
<SlackParamsFields
actionConnector={{ config: { type: 'webhook' } } as any}
actionParams={{ message: 'some text' }}
errors={{ message: [] }}
editAction={editAction}
index={0}
defaultMessage="some different default message"
messageVariables={[]}
useDefaultMessage={true}
/>
</IntlProvider>
);
expect(editAction).toHaveBeenCalledWith('message', 'some different default message', 0);
});
test('when useDefaultMessage is set to true and the default message changes, the underlying message is replaced with the default message, Web API', () => {
const editAction = jest.fn();
const { rerender } = render(
<IntlProvider locale="en">
<SlackParamsFields
actionConnector={{ config: { type: 'web_api' } } as any}
actionParams={{
subAction: 'postMessage',
subActionParams: { channels: ['general'], text: 'some text' },
}}
errors={{ message: [] }}
editAction={editAction}
index={0}
defaultMessage="default message"
messageVariables={[]}
useDefaultMessage={true}
/>
</IntlProvider>
);
expect(screen.getByTestId('webApiTextArea')).toBeInTheDocument();
expect(screen.getByTestId('webApiTextArea')).toHaveValue('some text');
rerender(
<IntlProvider locale="en">
<SlackParamsFields
actionConnector={{ config: { type: 'web_api' } } as any}
actionParams={{
subAction: 'postMessage',
subActionParams: { channels: ['general'], text: 'some text' },
}}
errors={{ message: [] }}
editAction={editAction}
index={0}
defaultMessage="some different default message"
messageVariables={[]}
useDefaultMessage={true}
/>
</IntlProvider>
);
expect(editAction).toHaveBeenCalledWith(
'subActionParams',
{ channels: ['general'], text: 'some different default message' },
0
expect(wrapper.find('[data-test-subj="messageTextArea"]').length > 0).toBeTruthy();
expect(wrapper.find('[data-test-subj="messageTextArea"]').first().prop('value')).toStrictEqual(
'test message'
);
});
test('when useDefaultMessage is set to false and the default message changes, the underlying message is not changed, Webhook', () => {
test('when useDefaultMessage is set to true and the default message changes, the underlying message is replaced with the default message', () => {
const actionParams = {
message: 'not the default message',
};
const editAction = jest.fn();
const { rerender } = render(
<IntlProvider locale="en">
<SlackParamsFields
actionConnector={{ config: { type: 'webhook' } } as any}
actionParams={{ message: 'some text' }}
errors={{ message: [] }}
editAction={editAction}
index={0}
defaultMessage="default message"
messageVariables={[]}
useDefaultMessage={false}
/>
</IntlProvider>
const wrapper = mountWithIntl(
<SlackParamsFields
actionParams={actionParams}
errors={{ message: [] }}
editAction={editAction}
defaultMessage={'Some default message'}
index={0}
/>
);
const text = wrapper.find('[data-test-subj="messageTextArea"]').first().text();
expect(text).toEqual('not the default message');
expect(screen.getByTestId('messageTextArea')).toBeInTheDocument();
expect(screen.getByTestId('messageTextArea')).toHaveValue('some text');
wrapper.setProps({
useDefaultMessage: true,
defaultMessage: 'Some different default message',
});
rerender(
<IntlProvider locale="en">
<SlackParamsFields
actionConnector={{ config: { type: 'webhook' } } as any}
actionParams={{ message: 'some text' }}
errors={{ message: [] }}
editAction={editAction}
index={0}
defaultMessage="some different default message"
messageVariables={[]}
useDefaultMessage={false}
/>
</IntlProvider>
expect(editAction).toHaveBeenCalledWith('message', 'Some different default message', 0);
});
test('when useDefaultMessage is set to false and the default message changes, the underlying message is not changed', () => {
const actionParams = {
message: 'not the default message',
};
const editAction = jest.fn();
const wrapper = mountWithIntl(
<SlackParamsFields
actionParams={actionParams}
errors={{ message: [] }}
editAction={editAction}
defaultMessage={'Some default message'}
index={0}
/>
);
const text = wrapper.find('[data-test-subj="messageTextArea"]').first().text();
expect(text).toEqual('not the default message');
wrapper.setProps({
useDefaultMessage: false,
defaultMessage: 'Some different default message',
});
expect(editAction).not.toHaveBeenCalled();
});
test('when useDefaultMessage is set to false and the default message changes, the underlying message is not changed, Web API', () => {
const editAction = jest.fn();
const { rerender } = render(
<IntlProvider locale="en">
<SlackParamsFields
actionConnector={{ config: { type: 'web_api' } } as any}
actionParams={{
subAction: 'postMessage',
subActionParams: { channels: ['general'], text: 'some text' },
}}
errors={{ message: [] }}
editAction={editAction}
index={0}
defaultMessage="default message"
messageVariables={[]}
useDefaultMessage={false}
/>
</IntlProvider>
);
expect(screen.getByTestId('webApiTextArea')).toBeInTheDocument();
expect(screen.getByTestId('webApiTextArea')).toHaveValue('some text');
rerender(
<IntlProvider locale="en">
<SlackParamsFields
actionConnector={{ config: { type: 'web_api' } } as any}
actionParams={{
subAction: 'postMessage',
subActionParams: { channels: ['general'], text: 'some text' },
}}
errors={{ message: [] }}
editAction={editAction}
index={0}
defaultMessage="some different default message"
messageVariables={[]}
useDefaultMessage={false}
/>
</IntlProvider>
);
expect(editAction).not.toHaveBeenCalled();
});
test('all params fields is rendered, Web API, postMessage', async () => {
render(
<IntlProvider locale="en">
<SlackParamsFields
actionConnector={{ config: { type: 'web_api' } } as any}
actionParams={{
subAction: 'postMessage',
subActionParams: { channels: ['general'], text: 'some text' },
}}
errors={{ message: [] }}
editAction={() => {}}
index={0}
defaultMessage="default message"
messageVariables={[]}
/>
</IntlProvider>
);
expect(screen.getByTestId('webApiTextArea')).toBeInTheDocument();
expect(screen.getByTestId('webApiTextArea')).toHaveValue('some text');
});
test('all params fields is rendered, Web API, getChannels', async () => {
render(
<IntlProvider locale="en">
<SlackParamsFields
actionConnector={{ config: { type: 'web_api' } } as any}
actionParams={{
subAction: 'postMessage',
subActionParams: { channels: [], text: 'some text' },
}}
errors={{ message: [] }}
editAction={() => {}}
index={0}
defaultMessage="default message"
messageVariables={[]}
/>
</IntlProvider>
);
expect(screen.getByTestId('slackChannelsButton')).toHaveTextContent('Channels');
fireEvent.click(screen.getByTestId('slackChannelsButton'));
expect(screen.getByTestId('slackChannelsSelectableList')).toBeInTheDocument();
expect(screen.getByTestId('slackChannelsSelectableList')).toHaveTextContent('general');
fireEvent.click(screen.getByText('general'));
expect(screen.getByTitle('general').getAttribute('aria-checked')).toEqual('true');
});
});

View file

@ -5,24 +5,51 @@
* 2.0.
*/
import React from 'react';
import React, { useEffect, useState } from 'react';
import { i18n } from '@kbn/i18n';
import type { ActionParamsProps } from '@kbn/triggers-actions-ui-plugin/public';
import { SlackWebApiParamsFields } from './slack_web_api_params';
import { SlackWebhookParamsFields } from './slack_webhook_params';
import { WebhookParams, PostMessageParams } from '../../../common/slack/types';
import type { SlackActionConnector } from './types';
import { TextAreaWithMessageVariables } from '@kbn/triggers-actions-ui-plugin/public';
import { SlackActionParams } from '../types';
const SlackParamsFields: React.FunctionComponent<
ActionParamsProps<WebhookParams | PostMessageParams>
> = (props) => {
const { actionConnector } = props;
const slackType = (actionConnector as unknown as SlackActionConnector)?.config?.type;
const SlackParamsFields: React.FunctionComponent<ActionParamsProps<SlackActionParams>> = ({
actionParams,
editAction,
index,
errors,
messageVariables,
defaultMessage,
useDefaultMessage,
}) => {
const { message } = actionParams;
const [[isUsingDefault, defaultMessageUsed], setDefaultMessageUsage] = useState<
[boolean, string | undefined]
>([false, defaultMessage]);
useEffect(() => {
if (
useDefaultMessage ||
!actionParams?.message ||
(isUsingDefault &&
actionParams?.message === defaultMessageUsed &&
defaultMessageUsed !== defaultMessage)
) {
setDefaultMessageUsage([true, defaultMessage]);
editAction('message', defaultMessage, index);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [defaultMessage]);
return (
<>
{!slackType || slackType === 'webhook' ? <SlackWebhookParamsFields {...props} /> : null}
{slackType === 'web_api' ? <SlackWebApiParamsFields {...props} /> : null}
</>
<TextAreaWithMessageVariables
index={index}
editAction={editAction}
messageVariables={messageVariables}
paramsProperty={'message'}
inputTargetValue={message}
label={i18n.translate('xpack.stackConnectors.components.slack.messageTextAreaFieldLabel', {
defaultMessage: 'Message',
})}
errors={(errors.message ?? []) as string[]}
/>
);
};

View file

@ -1,34 +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.
*/
import React from 'react';
import {
ActionConnectorFieldsProps,
SecretsFieldSchema,
SimpleConnectorForm,
} from '@kbn/triggers-actions-ui-plugin/public';
import * as i18n from './translations';
const secretsFormSchema: SecretsFieldSchema[] = [
{
id: 'token',
label: i18n.TOKEN_LABEL,
isPasswordField: true,
},
];
export const SlackWebApiActionsFields: React.FunctionComponent<ActionConnectorFieldsProps> = ({
readOnly,
isEdit,
}) => (
<SimpleConnectorForm
isEdit={isEdit}
readOnly={readOnly}
configFormSchema={[]}
secretsFormSchema={secretsFormSchema}
/>
);

View file

@ -1,209 +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.
*/
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { i18n } from '@kbn/i18n';
import type { ActionParamsProps } from '@kbn/triggers-actions-ui-plugin/public';
import { TextAreaWithMessageVariables } from '@kbn/triggers-actions-ui-plugin/public';
import {
EuiSpacer,
EuiFilterGroup,
EuiPopover,
EuiFilterButton,
EuiSelectable,
EuiSelectableOption,
EuiFormRow,
} from '@elastic/eui';
import { useSubAction, useKibana } from '@kbn/triggers-actions-ui-plugin/public';
import { FormattedMessage } from '@kbn/i18n-react';
import type { PostMessageParams } from '../../../common/slack/types';
import type { GetChannelsResponse } from './types';
interface ChannelsStatus {
label: string;
checked?: 'on';
}
export const SlackWebApiParamsFields: React.FunctionComponent<
ActionParamsProps<PostMessageParams>
> = ({
actionConnector,
actionParams,
editAction,
index,
errors,
messageVariables,
defaultMessage,
useDefaultMessage,
}) => {
const { subAction, subActionParams } = actionParams;
const { channels, text } = subActionParams ?? {};
const { toasts } = useKibana().notifications;
useEffect(() => {
if (useDefaultMessage || !text) {
editAction('subActionParams', { channels, text: defaultMessage }, index);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [defaultMessage, useDefaultMessage]);
if (!subAction) {
editAction('subAction', 'postMessage', index);
}
if (!subActionParams) {
editAction(
'subActionParams',
{
channels,
text,
},
index
);
}
const {
response: { channels: channelsInfo } = {},
isLoading: isLoadingChannels,
error: channelsError,
} = useSubAction<void, GetChannelsResponse>({
connectorId: actionConnector?.id,
subAction: 'getChannels',
});
useEffect(() => {
if (channelsError) {
toasts.danger({
title: i18n.translate(
'xpack.stackConnectors.slack.params.componentError.getChannelsRequestFailed',
{
defaultMessage: 'Failed to retrieve Slack channels list',
}
),
body: channelsError.message,
});
}
}, [toasts, channelsError]);
const slackChannels = useMemo(
() =>
channelsInfo
?.filter((slackChannel) => slackChannel.is_channel)
.map((slackChannel) => ({ label: slackChannel.name })) ?? [],
[channelsInfo]
);
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [selectedChannels, setSelectedChannels] = useState<string[]>(channels ?? []);
const button = (
<EuiFilterButton
iconType="arrowDown"
onClick={() => setIsPopoverOpen(!isPopoverOpen)}
numFilters={selectedChannels.length}
hasActiveFilters={selectedChannels.length > 0}
numActiveFilters={selectedChannels.length}
data-test-subj="slackChannelsButton"
>
<FormattedMessage
id="xpack.stackConnectors.slack.params..showChannelsListButton"
defaultMessage="Channels"
/>
</EuiFilterButton>
);
const options: ChannelsStatus[] = useMemo(
() =>
slackChannels.map((slackChannel) => ({
label: slackChannel.label,
...(selectedChannels.includes(slackChannel.label) ? { checked: 'on' } : {}),
})),
[slackChannels, selectedChannels]
);
const onChange = useCallback(
(newOptions: EuiSelectableOption[]) => {
const newSelectedChannels = newOptions.reduce<string[]>((result, option) => {
if (option.checked === 'on') {
result = [...result, option.label];
}
return result;
}, []);
setSelectedChannels(newSelectedChannels);
editAction('subActionParams', { channels: newSelectedChannels, text }, index);
},
[editAction, index, text]
);
return (
<>
<EuiFormRow
fullWidth
error={errors['subActionParams.channels']}
isInvalid={errors['subActionParams.channels']?.length > 0 && channels !== undefined}
>
<EuiFilterGroup>
<EuiPopover
id={'id'}
button={button}
isOpen={isPopoverOpen}
closePopover={() => setIsPopoverOpen(false)}
>
<EuiSelectable
searchable
data-test-subj="slackChannelsSelectableList"
isLoading={isLoadingChannels}
options={options}
loadingMessage={i18n.translate(
'xpack.stackConnectors.components.slack.loadingMessage',
{
defaultMessage: 'Loading channels',
}
)}
noMatchesMessage={i18n.translate(
'xpack.stackConnectors.components.slack.noChannelsFound',
{
defaultMessage: 'No channels found',
}
)}
emptyMessage={i18n.translate(
'xpack.stackConnectors.components.slack.noChannelsAvailable',
{
defaultMessage: 'No channels available',
}
)}
onChange={onChange}
singleSelection={true}
>
{(list, search) => (
<>
{search}
<EuiSpacer size="xs" />
{list}
</>
)}
</EuiSelectable>
</EuiPopover>
</EuiFilterGroup>
</EuiFormRow>
<EuiSpacer size="m" />
<TextAreaWithMessageVariables
index={index}
editAction={(key: string, value: any) =>
editAction('subActionParams', { channels, text: value }, index)
}
messageVariables={messageVariables}
paramsProperty="webApi"
inputTargetValue={text}
label={i18n.translate('xpack.stackConnectors.components.slack.messageTextAreaFieldLabel', {
defaultMessage: 'Message',
})}
errors={(errors.message ?? []) as string[]}
/>
</>
);
};

View file

@ -1,57 +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.
*/
import React from 'react';
import { EuiLink } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { FieldConfig, UseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib';
import { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers';
import { Field } from '@kbn/es-ui-shared-plugin/static/forms/components';
import { DocLinksStart } from '@kbn/core/public';
import type { ActionConnectorFieldsProps } from '@kbn/triggers-actions-ui-plugin/public';
import { useKibana } from '@kbn/triggers-actions-ui-plugin/public';
import * as i18n from './translations';
const { urlField } = fieldValidators;
const getWebhookUrlConfig = (docLinks: DocLinksStart): FieldConfig => ({
label: i18n.WEBHOOK_URL_LABEL,
helpText: (
<EuiLink href={docLinks.links.alerting.slackAction} target="_blank">
<FormattedMessage
id="xpack.stackConnectors.components.slack.webhookUrlHelpLabel"
defaultMessage="Create a Slack Webhook URL"
/>
</EuiLink>
),
validations: [
{
validator: urlField(i18n.WEBHOOK_URL_INVALID),
},
],
});
export const SlackWebhookActionFields: React.FunctionComponent<ActionConnectorFieldsProps> = ({
readOnly,
}) => {
const { docLinks } = useKibana().services;
return (
<UseField
path="secrets.webhookUrl"
config={getWebhookUrlConfig(docLinks)}
component={Field}
componentProps={{
euiFieldProps: {
readOnly,
'data-test-subj': 'slackWebhookUrlInput',
fullWidth: true,
},
}}
/>
);
};

View file

@ -1,47 +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.
*/
import React, { useEffect } from 'react';
import { i18n } from '@kbn/i18n';
import type { ActionParamsProps } from '@kbn/triggers-actions-ui-plugin/public';
import { TextAreaWithMessageVariables } from '@kbn/triggers-actions-ui-plugin/public';
import { WebhookParams } from '../../../common/slack/types';
export const SlackWebhookParamsFields: React.FunctionComponent<
ActionParamsProps<WebhookParams>
> = ({
actionParams,
editAction,
index,
errors,
messageVariables,
defaultMessage,
useDefaultMessage,
}) => {
const { message } = actionParams;
useEffect(() => {
if (useDefaultMessage || !message) {
editAction('message', defaultMessage, index);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [defaultMessage, useDefaultMessage]);
return (
<TextAreaWithMessageVariables
index={index}
editAction={editAction}
messageVariables={messageVariables}
paramsProperty="message"
inputTargetValue={message}
label={i18n.translate('xpack.stackConnectors.components.slack.messageTextAreaFieldLabel', {
defaultMessage: 'Message',
})}
errors={(errors.message ?? []) as string[]}
/>
);
};

View file

@ -15,45 +15,15 @@ export const WEBHOOK_URL_INVALID = i18n.translate(
);
export const MESSAGE_REQUIRED = i18n.translate(
'xpack.stackConnectors.components.slack.error.requiredSlackMessageText',
'xpack.stackConnectors.components.slack..error.requiredSlackMessageText',
{
defaultMessage: 'Message is required.',
}
);
export const CHANNEL_REQUIRED = i18n.translate(
'xpack.stackConnectors.components.slack.error.requiredSlackChannel',
{
defaultMessage: 'At least one selected channel is required.',
}
);
export const WEBHOOK_URL_LABEL = i18n.translate(
'xpack.stackConnectors.components.slack.webhookUrlTextFieldLabel',
{
defaultMessage: 'Webhook URL',
}
);
export const TOKEN_LABEL = i18n.translate(
'xpack.stackConnectors.components.slack.tokenTextFieldLabel',
{
defaultMessage: 'API Token',
}
);
export const URL_TEXT = i18n.translate('xpack.stackConnectors.components.slack.urlFieldLabel', {
defaultMessage: 'URL',
});
export const SLACK_LEGEND = i18n.translate('xpack.stackConnectors.components.slack.slackLegend', {
defaultMessage: 'Slack type',
});
export const WEBHOOK = i18n.translate('xpack.stackConnectors.components.slack.webhook', {
defaultMessage: 'Webhook',
});
export const WEB_API = i18n.translate('xpack.stackConnectors.components.slack.webApi', {
defaultMessage: 'Web API',
});

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.
*/
import { UserConfiguredActionConnector } from '@kbn/triggers-actions-ui-plugin/public/types';
import type { SlackConfig, SlackSecrets } from '../../../common/slack/types';
export type SlackActionConnector = UserConfiguredActionConnector<SlackConfig, SlackSecrets>;
export interface GetChannelsResponse {
ok: true;
error?: string;
channels?: Array<{
id: string;
name: string;
is_channel: boolean;
is_archived: boolean;
is_private: boolean;
}>;
}

View file

@ -60,6 +60,10 @@ export interface ServerLogActionParams {
message: string;
}
export interface SlackActionParams {
message: string;
}
export interface TeamsActionParams {
message: string;
}
@ -112,6 +116,12 @@ export type PagerDutyActionConnector = UserConfiguredActionConnector<
PagerDutySecrets
>;
export interface SlackSecrets {
webhookUrl: string;
}
export type SlackActionConnector = UserConfiguredActionConnector<unknown, SlackSecrets>;
export interface WebhookConfig {
method: string;
url: string;

View file

@ -115,11 +115,6 @@ const XmattersActionConnectorFields: React.FunctionComponent<ActionConnectorFiel
options={authenticationButtons}
/>
<HiddenField path={'config.usesBasic'} config={{ defaultValue: true }} />
{/* The components size depends on auth option we choose. Just putting a limit to form width
would change component dehaviour during the sizing. This line make component size to max, so
it does not change during sizing, but keep the same behaviour the designer put into it.
*/}
<div style={{ width: '100vw', height: 0 }} />
<EuiSpacer size="m" />
{selectedAuth === XmattersAuthenticationType.URL ? (
<EuiFlexGroup justifyContent="spaceBetween">

View file

@ -45,8 +45,8 @@ export type { ActionParamsType as PagerDutyActionParams } from './pagerduty';
export { ConnectorTypeId as ServerLogConnectorTypeId } from './server_log';
export type { ActionParamsType as ServerLogActionParams } from './server_log';
export { ServiceNowITOMConnectorTypeId } from './servicenow_itom';
export type { SlackActionParams as SlackActionParams } from '../../common/slack/types';
export { SLACK_CONNECTOR_ID as SlackConnectorTypeId } from '../../common/slack/constants';
export { ConnectorTypeId as SlackConnectorTypeId } from './slack';
export type { ActionParamsType as SlackActionParams } from './slack';
export { ConnectorTypeId as TeamsConnectorTypeId } from './teams';
export type { ActionParamsType as TeamsActionParams } from './teams';
export { ConnectorTypeId as WebhookConnectorTypeId } from './webhook';
@ -80,7 +80,7 @@ export function registerConnectorTypes({
actions.registerType(getPagerDutyConnectorType());
actions.registerType(getSwimlaneConnectorType());
actions.registerType(getServerLogConnectorType());
actions.registerType(getSlackConnectorType());
actions.registerType(getSlackConnectorType({}));
actions.registerType(getWebhookConnectorType());
actions.registerType(getCasesWebhookConnectorType());
actions.registerType(getXmattersConnectorType());

View file

@ -1,30 +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.
*/
export interface ResponseError {
errorMessages: string[] | null | undefined;
errors: { [k: string]: string } | null | undefined;
}
export const createErrorMessage = (errorResponse: ResponseError | null | undefined): string => {
if (!errorResponse) return 'unknown: errorResponse was empty';
const { errorMessages, errors } = errorResponse;
if (Array.isArray(errorMessages) && errorMessages.length > 0) {
return `${errorMessages.join(', ')}`;
}
if (errors == null) {
return 'unknown: errorResponse.errors was null';
}
return Object.entries(errors).reduce((errorMessage, [, value]) => {
const msg = errorMessage.length > 0 ? `${errorMessage} ${value}` : value;
return msg;
}, '');
};

View file

@ -1,101 +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.
*/
import { SlackService } from './types';
import { api } from './api';
const createMock = (): jest.Mocked<SlackService> => {
const service = {
postMessage: jest.fn().mockImplementation(() => ({
ok: true,
channel: 'general',
message: {
text: 'a message',
type: 'message',
},
})),
getChannels: jest.fn().mockImplementation(() => [
{
ok: true,
channels: [
{
id: 'channel_id_1',
name: 'general',
is_channel: true,
is_archived: false,
is_private: true,
},
{
id: 'channel_id_2',
name: 'privat',
is_channel: true,
is_archived: false,
is_private: false,
},
],
},
]),
};
return service;
};
const slackServiceMock = {
create: createMock,
};
describe('api', () => {
let externalService: jest.Mocked<SlackService>;
beforeEach(() => {
externalService = slackServiceMock.create();
});
test('getChannels', async () => {
const res = await api.getChannels({
externalService,
});
expect(res).toEqual([
{
channels: [
{
id: 'channel_id_1',
is_archived: false,
is_channel: true,
is_private: true,
name: 'general',
},
{
id: 'channel_id_2',
is_archived: false,
is_channel: true,
is_private: false,
name: 'privat',
},
],
ok: true,
},
]);
});
test('postMessage', async () => {
const res = await api.postMessage({
externalService,
params: { channels: ['general'], text: 'a message' },
});
expect(res).toEqual({
channel: 'general',
message: {
text: 'a message',
type: 'message',
},
ok: true,
});
});
});

View file

@ -1,30 +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.
*/
import type { SlackService } from './types';
import type { PostMessageSubActionParams } from '../../../common/slack/types';
const getChannelsHandler = async ({ externalService }: { externalService: SlackService }) => {
const res = await externalService.getChannels();
return res;
};
const postMessageHandler = async ({
externalService,
params: { channels, text },
}: {
externalService: SlackService;
params: PostMessageSubActionParams;
}) => {
const res = await externalService.postMessage({ channels, text });
return res;
};
export const api = {
getChannels: getChannelsHandler,
postMessage: postMessageHandler,
};

View file

@ -5,39 +5,31 @@
* 2.0.
*/
import axios from 'axios';
import { Logger } from '@kbn/core/server';
import { Services } from '@kbn/actions-plugin/server/types';
import {
validateParams,
validateSecrets,
validateConnector,
validateConfig,
} from '@kbn/actions-plugin/server/lib';
import { getConnectorType } from '.';
Services,
ActionTypeExecutorResult as ConnectorTypeExecutorResult,
} from '@kbn/actions-plugin/server/types';
import { validateParams, validateSecrets } from '@kbn/actions-plugin/server/lib';
import {
getConnectorType,
SlackConnectorType,
SlackConnectorTypeExecutorOptions,
ConnectorTypeId,
} from '.';
import { actionsConfigMock } from '@kbn/actions-plugin/server/actions_config.mock';
import { actionsMock } from '@kbn/actions-plugin/server/mocks';
import { ActionsConfigurationUtilities } from '@kbn/actions-plugin/server/actions_config';
import { loggerMock } from '@kbn/logging-mocks';
import * as utils from '@kbn/actions-plugin/server/lib/axios_utils';
import type { PostMessageParams, SlackConnectorType } from '../../../common/slack/types';
import { SLACK_CONNECTOR_ID } from '../../../common/slack/constants';
import { SLACK_CONNECTOR_NAME } from './translations';
jest.mock('axios');
jest.mock('@kbn/actions-plugin/server/lib/axios_utils', () => {
const originalUtils = jest.requireActual('@kbn/actions-plugin/server/lib/axios_utils');
jest.mock('@slack/webhook', () => {
return {
...originalUtils,
request: jest.fn(),
IncomingWebhook: jest.fn().mockImplementation(() => {
return { send: (message: string) => {} };
}),
};
});
const requestMock = utils.request as jest.Mock;
jest.mock('@slack/webhook');
const { IncomingWebhook } = jest.requireMock('@slack/webhook');
const services: Services = actionsMock.createServices();
const mockedLogger: jest.Mocked<Logger> = loggerMock.create();
@ -46,650 +38,313 @@ let configurationUtilities: jest.Mocked<ActionsConfigurationUtilities>;
beforeEach(() => {
configurationUtilities = actionsConfigMock.create();
connectorType = getConnectorType();
connectorType = getConnectorType({
async executor(options) {
return { status: 'ok', actionId: options.actionId };
},
});
});
describe('connector registration', () => {
test('returns connector type', () => {
expect(connectorType.id).toEqual(SLACK_CONNECTOR_ID);
expect(connectorType.name).toEqual(SLACK_CONNECTOR_NAME);
expect(connectorType.id).toEqual(ConnectorTypeId);
expect(connectorType.name).toEqual('Slack');
});
});
describe('validate params', () => {
test('should validate and throw error when params are invalid', () => {
describe('validateParams()', () => {
test('should validate and pass when params is valid', () => {
expect(
validateParams(connectorType, { message: 'a message' }, { configurationUtilities })
).toEqual({
message: 'a message',
});
});
test('should validate and throw error when params is invalid', () => {
expect(() => {
validateParams(connectorType, {}, { configurationUtilities });
}).toThrowErrorMatchingInlineSnapshot(
`"error validating action params: Cannot destructure property 'Symbol(Symbol.iterator)' of 'undefined' as it is undefined."`
`"error validating action params: [message]: expected value of type [string] but got [undefined]"`
);
expect(() => {
validateParams(connectorType, { message: 1 }, { configurationUtilities });
}).toThrowErrorMatchingInlineSnapshot(
`"error validating action params: Cannot destructure property 'Symbol(Symbol.iterator)' of 'undefined' as it is undefined."`
`"error validating action params: [message]: expected value of type [string] but got [number]"`
);
});
});
describe('validateConnectorTypeSecrets()', () => {
test('should validate and pass when config is valid', () => {
validateSecrets(
connectorType,
{
webhookUrl: 'https://example.com',
},
{ configurationUtilities }
);
});
describe('Webhook', () => {
test('should validate and pass when params are valid', () => {
expect(
validateParams(connectorType, { message: 'a message' }, { configurationUtilities })
).toEqual({ message: 'a message' });
});
});
describe('Web API', () => {
test('should validate and pass when params are valid for post message', () => {
expect(
validateParams(
connectorType,
{ subAction: 'postMessage', subActionParams: { channels: ['general'], text: 'a text' } },
{ configurationUtilities }
)
).toEqual({
subAction: 'postMessage',
subActionParams: { channels: ['general'], text: 'a text' },
});
});
test('should validate and pass when params are valid for get channels', () => {
expect(
validateParams(connectorType, { subAction: 'getChannels' }, { configurationUtilities })
).toEqual({
subAction: 'getChannels',
});
});
});
});
describe('validate config, secrets and connector', () => {
test('should validate and throw error when secrets is invalid', () => {
test('should validate and throw error when config is invalid', () => {
expect(() => {
validateSecrets(connectorType, {}, { configurationUtilities });
}).toThrowErrorMatchingInlineSnapshot(`
"error validating action type secrets: types that failed validation:
- [0.webhookUrl]: expected value of type [string] but got [undefined]
- [1.token]: expected value of type [string] but got [undefined]"
`);
});
}).toThrowErrorMatchingInlineSnapshot(
`"error validating action type secrets: [webhookUrl]: expected value of type [string] but got [undefined]"`
);
test('should validate and pass when config is valid', () => {
validateConfig(connectorType, { type: 'web_api' }, { configurationUtilities });
});
test('should validate and pass when config is empty', () => {
validateConfig(connectorType, {}, { configurationUtilities });
});
test('should fail when config is invalid', () => {
expect(() => {
validateConfig(connectorType, { type: 'not_webhook' }, { configurationUtilities });
}).toThrowErrorMatchingInlineSnapshot(`
"error validating action type config: [type]: types that failed validation:
- [type.0]: expected value to equal [webhook]
- [type.1]: expected value to equal [web_api]"
`);
validateSecrets(connectorType, { webhookUrl: 1 }, { configurationUtilities });
}).toThrowErrorMatchingInlineSnapshot(
`"error validating action type secrets: [webhookUrl]: expected value of type [string] but got [number]"`
);
expect(() => {
validateSecrets(connectorType, { webhookUrl: 'fee-fi-fo-fum' }, { configurationUtilities });
}).toThrowErrorMatchingInlineSnapshot(
`"error validating action type secrets: error configuring slack action: unable to parse host name from webhookUrl"`
);
});
describe('Webhook', () => {
test('should validate and pass when config and secrets are invalid together', () => {
expect(() => {
validateConnector(connectorType, {
config: { type: 'webhook' },
secrets: { token: 'fake_token' },
});
}).toThrowErrorMatchingInlineSnapshot(
`"error validating action type connector: Secrets of Slack type webhook should contain webhookUrl field"`
);
});
test('should validate and pass when the slack webhookUrl is added to allowedHosts', () => {
const configUtils = {
...actionsConfigMock.create(),
ensureUriAllowed: (url: string) => {
expect(url).toEqual('https://api.slack.com/');
},
};
test('should validate and pass when secrets is valid', () => {
expect(
validateSecrets(
connectorType,
{ webhookUrl: 'https://example.com' },
{ configurationUtilities }
);
});
test('should validate and pass when the slack webhookUrl is added to allowedHosts', () => {
const configUtils = {
...actionsConfigMock.create(),
ensureUriAllowed: (url: string) => {
expect(url).toEqual('https://api.slack.com/');
},
};
actionsConfigMock.create();
expect(
validateSecrets(
connectorType,
{ webhookUrl: 'https://api.slack.com/' },
{ configurationUtilities: configUtils }
)
).toEqual({
webhookUrl: 'https://api.slack.com/',
});
});
test('config validation returns an error if the specified URL isnt added to allowedHosts', () => {
const configUtils = {
...actionsConfigMock.create(),
ensureUriAllowed: () => {
throw new Error(`target hostname is not added to allowedHosts`);
},
};
expect(() => {
validateSecrets(
connectorType,
{ webhookUrl: 'https://api.slack.com/' },
{ configurationUtilities: configUtils }
);
}).toThrowErrorMatchingInlineSnapshot(
`"error validating action type secrets: error configuring slack action: target hostname is not added to allowedHosts"`
);
});
test('should validate and throw error when secrets is invalid', () => {
expect(() => {
validateSecrets(connectorType, { webhookUrl: 1 }, { configurationUtilities });
}).toThrowErrorMatchingInlineSnapshot(`
"error validating action type secrets: types that failed validation:
- [0.webhookUrl]: expected value of type [string] but got [number]
- [1.token]: expected value of type [string] but got [undefined]"
`);
expect(() => {
validateSecrets(connectorType, { webhookUrl: 'fee-fi-fo-fum' }, { configurationUtilities });
}).toThrowErrorMatchingInlineSnapshot(
`"error validating action type secrets: error configuring slack action: unable to parse host name from webhookUrl"`
);
{ webhookUrl: 'https://api.slack.com/' },
{ configurationUtilities: configUtils }
)
).toEqual({
webhookUrl: 'https://api.slack.com/',
});
});
describe('Web API', () => {
test('should fail when config and secrets are invalid together', () => {
expect(() => {
validateConnector(connectorType, {
config: { type: 'web_api' },
secrets: { webhookUrl: 'https://fake_url' },
});
}).toThrowErrorMatchingInlineSnapshot(
`"error validating action type connector: Secrets of Slack type web_api should contain token field"`
);
});
test('config validation returns an error if the specified URL isnt added to allowedHosts', () => {
const configUtils = {
...actionsConfigMock.create(),
ensureUriAllowed: () => {
throw new Error(`target hostname is not added to allowedHosts`);
},
};
test('should validate and pass when secrets is valid', () => {
expect(() => {
validateSecrets(
connectorType,
{
token: 'token',
},
{ configurationUtilities }
{ webhookUrl: 'https://api.slack.com/' },
{ configurationUtilities: configUtils }
);
});
test('should validate and throw error when secrets is invalid', () => {
expect(() => {
validateSecrets(connectorType, { token: 1 }, { configurationUtilities });
}).toThrowErrorMatchingInlineSnapshot(`
"error validating action type secrets: types that failed validation:
- [0.webhookUrl]: expected value of type [string] but got [undefined]
- [1.token]: expected value of type [string] but got [number]"
`);
});
}).toThrowErrorMatchingInlineSnapshot(
`"error validating action type secrets: error configuring slack action: target hostname is not added to allowedHosts"`
);
});
});
describe('execute', () => {
describe('execute()', () => {
beforeEach(() => {
jest.resetAllMocks();
axios.create = jest.fn().mockImplementation(() => axios);
connectorType = getConnectorType();
});
describe('Webhook', () => {
test('should fail if type is webhook, but params does not include message', async () => {
jest.mock('@kbn/actions-plugin/server/lib/get_custom_agents', () => ({
getCustomAgents: () => ({ httpsAgent: jest.fn(), httpAgent: jest.fn() }),
}));
configurationUtilities = actionsConfigMock.create();
IncomingWebhook.mockImplementation(() => ({
send: () => ({
text: 'ok',
}),
}));
async function mockSlackExecutor(options: SlackConnectorTypeExecutorOptions) {
const { params } = options;
const { message } = params;
if (message == null) throw new Error('message property required in parameter');
await expect(
connectorType.executor({
actionId: '.slack',
services,
config: { type: 'webhook' },
secrets: { webhookUrl: 'http://example.com' },
params: { subAction: 'getChannels' },
configurationUtilities,
logger: mockedLogger,
})
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Slack connector parameters with type Webhook should include message field in parameters"`
);
});
const failureMatch = message.match(/^failure: (.*)$/);
if (failureMatch != null) {
const failMessage = failureMatch[1];
throw new Error(`slack mockExecutor failure: ${failMessage}`);
}
test('should execute with success', async () => {
jest.mock('@kbn/actions-plugin/server/lib/get_custom_agents', () => ({
getCustomAgents: () => ({ httpsAgent: jest.fn(), httpAgent: jest.fn() }),
}));
configurationUtilities = actionsConfigMock.create();
IncomingWebhook.mockImplementation(() => ({
send: () => ({
text: 'ok',
}),
}));
const response = await connectorType.executor({
actionId: '.slack',
services,
config: { type: 'webhook' },
secrets: { webhookUrl: 'http://example.com' },
params: { message: 'this invocation should succeed' },
configurationUtilities,
logger: mockedLogger,
});
expect(response).toEqual({
actionId: '.slack',
data: { text: 'ok' },
return {
text: `slack mockExecutor success: ${message}`,
actionId: '',
status: 'ok',
});
});
} as ConnectorTypeExecutorResult<void>;
}
test('should return an error if test in response is not ok', async () => {
jest.mock('@kbn/actions-plugin/server/lib/get_custom_agents', () => ({
getCustomAgents: () => ({ httpsAgent: jest.fn(), httpAgent: jest.fn() }),
}));
configurationUtilities = actionsConfigMock.create();
IncomingWebhook.mockImplementation(() => ({
send: () => ({
text: 'not ok',
}),
}));
const response = await connectorType.executor({
actionId: '.slack',
services,
config: { type: 'webhook' },
secrets: { webhookUrl: 'http://example.com' },
params: { message: 'this invocation should succeed' },
configurationUtilities,
logger: mockedLogger,
});
expect(response).toEqual({
actionId: '.slack',
message: 'error posting slack message',
serviceMessage: 'not ok',
status: 'error',
});
});
test('should return a null response from slack', async () => {
jest.mock('@kbn/actions-plugin/server/lib/get_custom_agents', () => ({
getCustomAgents: () => ({ httpsAgent: jest.fn(), httpAgent: jest.fn() }),
}));
configurationUtilities = actionsConfigMock.create();
IncomingWebhook.mockImplementation(() => ({
send: jest.fn(),
}));
const response = await connectorType.executor({
actionId: '.slack',
services,
config: { type: 'webhook' },
secrets: { webhookUrl: 'http://example.com' },
params: { message: 'this invocation should succeed' },
configurationUtilities,
logger: mockedLogger,
});
expect(response).toEqual({
actionId: '.slack',
message: 'unexpected null response from slack',
status: 'error',
});
});
test('should return that sending a message fails', async () => {
jest.mock('@kbn/actions-plugin/server/lib/get_custom_agents', () => ({
getCustomAgents: () => ({ httpsAgent: jest.fn(), httpAgent: jest.fn() }),
}));
configurationUtilities = actionsConfigMock.create();
IncomingWebhook.mockImplementation(() => ({
send: () => {
throw new Error('sending a message fails');
},
}));
expect(
await connectorType.executor({
actionId: '.slack',
services,
config: { type: 'webhook' },
secrets: { webhookUrl: 'http://example.com' },
params: { message: 'failure: this invocation should fail' },
configurationUtilities,
logger: mockedLogger,
})
).toEqual({
actionId: '.slack',
message: 'error posting slack message',
serviceMessage: 'sending a message fails',
status: 'error',
});
});
test('calls the mock executor with success proxy', async () => {
const configUtils = actionsConfigMock.create();
configUtils.getProxySettings.mockReturnValue({
proxyUrl: 'https://someproxyhost',
proxySSLSettings: {
verificationMode: 'none',
},
proxyBypassHosts: undefined,
proxyOnlyHosts: undefined,
});
const connectorTypeProxy = getConnectorType();
await connectorTypeProxy.executor({
actionId: '.slack',
services,
config: { type: 'webhook' },
secrets: { webhookUrl: 'http://example.com' },
params: { message: 'this invocation should succeed' },
configurationUtilities: configUtils,
logger: mockedLogger,
});
expect(mockedLogger.debug).toHaveBeenCalledWith(
'IncomingWebhook was called with proxyUrl https://someproxyhost'
);
});
test('ensure proxy bypass will bypass when expected', async () => {
mockedLogger.debug.mockReset();
const configUtils = actionsConfigMock.create();
configUtils.getProxySettings.mockReturnValue({
proxyUrl: 'https://someproxyhost',
proxySSLSettings: {
verificationMode: 'none',
},
proxyBypassHosts: new Set(['example.com']),
proxyOnlyHosts: undefined,
});
const connectorTypeProxy = getConnectorType();
await connectorTypeProxy.executor({
actionId: '.slack',
services,
config: { type: 'webhook' },
secrets: { webhookUrl: 'http://example.com' },
params: { message: 'this invocation should succeed' },
configurationUtilities: configUtils,
logger: mockedLogger,
});
expect(mockedLogger.debug).not.toHaveBeenCalledWith(
'IncomingWebhook was called with proxyUrl https://someproxyhost'
);
});
test('ensure proxy bypass will not bypass when expected', async () => {
mockedLogger.debug.mockReset();
const configUtils = actionsConfigMock.create();
configUtils.getProxySettings.mockReturnValue({
proxyUrl: 'https://someproxyhost',
proxySSLSettings: {
verificationMode: 'none',
},
proxyBypassHosts: new Set(['not-example.com']),
proxyOnlyHosts: undefined,
});
const connectorTypeProxy = getConnectorType();
await connectorTypeProxy.executor({
actionId: '.slack',
services,
config: { type: 'webhook' },
secrets: { webhookUrl: 'http://example.com' },
params: { message: 'this invocation should succeed' },
configurationUtilities: configUtils,
logger: mockedLogger,
});
expect(mockedLogger.debug).toHaveBeenCalledWith(
'IncomingWebhook was called with proxyUrl https://someproxyhost'
);
});
test('ensure proxy only will proxy when expected', async () => {
mockedLogger.debug.mockReset();
const configUtils = actionsConfigMock.create();
configUtils.getProxySettings.mockReturnValue({
proxyUrl: 'https://someproxyhost',
proxySSLSettings: {
verificationMode: 'none',
},
proxyBypassHosts: undefined,
proxyOnlyHosts: new Set(['example.com']),
});
const connectorTypeProxy = getConnectorType();
await connectorTypeProxy.executor({
actionId: '.slack',
services,
config: { type: 'webhook' },
secrets: { webhookUrl: 'http://example.com' },
params: { message: 'this invocation should succeed' },
configurationUtilities: configUtils,
logger: mockedLogger,
});
expect(mockedLogger.debug).toHaveBeenCalledWith(
'IncomingWebhook was called with proxyUrl https://someproxyhost'
);
});
test('ensure proxy only will not proxy when expected', async () => {
mockedLogger.debug.mockReset();
const configUtils = actionsConfigMock.create();
configUtils.getProxySettings.mockReturnValue({
proxyUrl: 'https://someproxyhost',
proxySSLSettings: {
verificationMode: 'none',
},
proxyBypassHosts: undefined,
proxyOnlyHosts: new Set(['not-example.com']),
});
const connectorTypeProxy = getConnectorType();
await connectorTypeProxy.executor({
actionId: '.slack',
services,
config: { type: 'webhook' },
secrets: { webhookUrl: 'http://example.com' },
params: { message: 'this invocation should succeed' },
configurationUtilities: configUtils,
logger: mockedLogger,
});
expect(mockedLogger.debug).not.toHaveBeenCalledWith(
'IncomingWebhook was called with proxyUrl https://someproxyhost'
);
});
test('renders parameter templates as expected', async () => {
expect(connectorType.renderParameterTemplates).toBeTruthy();
const paramsWithTemplates = {
message: '{{rogue}}',
};
const variables = {
rogue: '*bold*',
};
const params = connectorType.renderParameterTemplates!(paramsWithTemplates, variables) as {
message: string;
};
expect(params.message).toBe('`*bold*`');
connectorType = getConnectorType({
executor: mockSlackExecutor,
});
});
describe('Web API', () => {
test('should fail if type is web_api, but params does not include subAction', async () => {
requestMock.mockImplementation(() => ({
data: {
ok: true,
message: { text: 'some text' },
channel: 'general',
},
}));
await expect(
connectorType.executor({
actionId: '.slack',
services,
config: { type: 'web_api' },
secrets: { token: 'some token' },
params: {
message: 'post message',
},
configurationUtilities,
logger: mockedLogger,
})
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Slack connector parameters with type Web API should include subAction field in parameters"`
);
test('calls the mock executor with success', async () => {
const response = await connectorType.executor({
actionId: 'some-id',
services,
config: {},
secrets: { webhookUrl: 'http://example.com' },
params: { message: 'this invocation should succeed' },
configurationUtilities,
logger: mockedLogger,
});
expect(response).toMatchInlineSnapshot(`
Object {
"actionId": "",
"status": "ok",
"text": "slack mockExecutor success: this invocation should succeed",
}
`);
});
test('should fail if type is web_api, but subAction is not postMessage/getChannels', async () => {
requestMock.mockImplementation(() => ({
data: {
ok: true,
message: { text: 'some text' },
channel: 'general',
},
}));
await expect(
connectorType.executor({
actionId: '.slack',
services,
config: { type: 'web_api' },
secrets: { token: 'some token' },
params: {
subAction: 'getMessage' as 'getChannels',
},
configurationUtilities,
logger: mockedLogger,
})
).rejects.toThrowErrorMatchingInlineSnapshot(
`"subAction can be only postMesage or getChannels"`
);
});
test('renders parameter templates as expected', async () => {
expect(connectorType.renderParameterTemplates).toBeTruthy();
const paramsWithTemplates = {
subAction: 'postMessage' as const,
subActionParams: { text: 'some text', channels: ['general'] },
};
const variables = { rogue: '*bold*' };
const params = connectorType.renderParameterTemplates!(
paramsWithTemplates,
variables
) as PostMessageParams;
expect(params.subActionParams.text).toBe('some text');
});
test('should execute with success for post message', async () => {
requestMock.mockImplementation(() => ({
data: {
ok: true,
message: { text: 'some text' },
channel: 'general',
},
}));
const response = await connectorType.executor({
actionId: '.slack',
test('calls the mock executor with failure', async () => {
await expect(
connectorType.executor({
actionId: 'some-id',
services,
config: { type: 'web_api' },
secrets: { token: 'some token' },
params: {
subAction: 'postMessage',
subActionParams: { channels: ['general'], text: 'some text' },
},
config: {},
secrets: { webhookUrl: 'http://example.com' },
params: { message: 'failure: this invocation should fail' },
configurationUtilities,
logger: mockedLogger,
});
})
).rejects.toThrowErrorMatchingInlineSnapshot(
`"slack mockExecutor failure: this invocation should fail"`
);
});
expect(requestMock).toHaveBeenCalledWith({
axios,
configurationUtilities,
logger: mockedLogger,
method: 'post',
url: 'chat.postMessage',
data: { channel: 'general', text: 'some text' },
});
expect(response).toEqual({
actionId: '.slack',
data: {
channel: 'general',
message: {
text: 'some text',
},
ok: true,
},
status: 'ok',
});
test('calls the mock executor with success proxy', async () => {
const configUtils = actionsConfigMock.create();
configUtils.getProxySettings.mockReturnValue({
proxyUrl: 'https://someproxyhost',
proxySSLSettings: {
verificationMode: 'none',
},
proxyBypassHosts: undefined,
proxyOnlyHosts: undefined,
});
test('should execute with success for get channels', async () => {
requestMock.mockImplementation(() => ({
data: {
ok: true,
channels: [
{
id: 'id',
name: 'general',
is_channel: true,
is_archived: false,
is_private: true,
},
],
},
}));
const response = await connectorType.executor({
actionId: '.slack',
services,
config: { type: 'web_api' },
secrets: { token: 'some token' },
params: {
subAction: 'getChannels',
},
configurationUtilities,
logger: mockedLogger,
});
expect(requestMock).toHaveBeenCalledWith({
axios,
configurationUtilities,
logger: mockedLogger,
method: 'get',
url: 'conversations.list?types=public_channel,private_channel',
});
expect(response).toEqual({
actionId: '.slack',
data: {
channels: [
{
id: 'id',
is_archived: false,
is_channel: true,
is_private: true,
name: 'general',
},
],
ok: true,
},
status: 'ok',
});
const connectorTypeProxy = getConnectorType({});
await connectorTypeProxy.executor({
actionId: 'some-id',
services,
config: {},
secrets: { webhookUrl: 'http://example.com' },
params: { message: 'this invocation should succeed' },
configurationUtilities: configUtils,
logger: mockedLogger,
});
expect(mockedLogger.debug).toHaveBeenCalledWith(
'IncomingWebhook was called with proxyUrl https://someproxyhost'
);
});
test('ensure proxy bypass will bypass when expected', async () => {
mockedLogger.debug.mockReset();
const configUtils = actionsConfigMock.create();
configUtils.getProxySettings.mockReturnValue({
proxyUrl: 'https://someproxyhost',
proxySSLSettings: {
verificationMode: 'none',
},
proxyBypassHosts: new Set(['example.com']),
proxyOnlyHosts: undefined,
});
const connectorTypeProxy = getConnectorType({});
await connectorTypeProxy.executor({
actionId: 'some-id',
services,
config: {},
secrets: { webhookUrl: 'http://example.com' },
params: { message: 'this invocation should succeed' },
configurationUtilities: configUtils,
logger: mockedLogger,
});
expect(mockedLogger.debug).not.toHaveBeenCalledWith(
'IncomingWebhook was called with proxyUrl https://someproxyhost'
);
});
test('ensure proxy bypass will not bypass when expected', async () => {
mockedLogger.debug.mockReset();
const configUtils = actionsConfigMock.create();
configUtils.getProxySettings.mockReturnValue({
proxyUrl: 'https://someproxyhost',
proxySSLSettings: {
verificationMode: 'none',
},
proxyBypassHosts: new Set(['not-example.com']),
proxyOnlyHosts: undefined,
});
const connectorTypeProxy = getConnectorType({});
await connectorTypeProxy.executor({
actionId: 'some-id',
services,
config: {},
secrets: { webhookUrl: 'http://example.com' },
params: { message: 'this invocation should succeed' },
configurationUtilities: configUtils,
logger: mockedLogger,
});
expect(mockedLogger.debug).toHaveBeenCalledWith(
'IncomingWebhook was called with proxyUrl https://someproxyhost'
);
});
test('ensure proxy only will proxy when expected', async () => {
mockedLogger.debug.mockReset();
const configUtils = actionsConfigMock.create();
configUtils.getProxySettings.mockReturnValue({
proxyUrl: 'https://someproxyhost',
proxySSLSettings: {
verificationMode: 'none',
},
proxyBypassHosts: undefined,
proxyOnlyHosts: new Set(['example.com']),
});
const connectorTypeProxy = getConnectorType({});
await connectorTypeProxy.executor({
actionId: 'some-id',
services,
config: {},
secrets: { webhookUrl: 'http://example.com' },
params: { message: 'this invocation should succeed' },
configurationUtilities: configUtils,
logger: mockedLogger,
});
expect(mockedLogger.debug).toHaveBeenCalledWith(
'IncomingWebhook was called with proxyUrl https://someproxyhost'
);
});
test('ensure proxy only will not proxy when expected', async () => {
mockedLogger.debug.mockReset();
const configUtils = actionsConfigMock.create();
configUtils.getProxySettings.mockReturnValue({
proxyUrl: 'https://someproxyhost',
proxySSLSettings: {
verificationMode: 'none',
},
proxyBypassHosts: undefined,
proxyOnlyHosts: new Set(['not-example.com']),
});
const connectorTypeProxy = getConnectorType({});
await connectorTypeProxy.executor({
actionId: 'some-id',
services,
config: {},
secrets: { webhookUrl: 'http://example.com' },
params: { message: 'this invocation should succeed' },
configurationUtilities: configUtils,
logger: mockedLogger,
});
expect(mockedLogger.debug).not.toHaveBeenCalledWith(
'IncomingWebhook was called with proxyUrl https://someproxyhost'
);
});
test('renders parameter templates as expected', async () => {
expect(connectorType.renderParameterTemplates).toBeTruthy();
const paramsWithTemplates = {
message: '{{rogue}}',
};
const variables = {
rogue: '*bold*',
};
const params = connectorType.renderParameterTemplates!(paramsWithTemplates, variables);
expect(params.message).toBe('`*bold*`');
});
});

View file

@ -5,13 +5,21 @@
* 2.0.
*/
import { URL } from 'url';
import HttpProxyAgent from 'http-proxy-agent';
import { HttpsProxyAgent } from 'https-proxy-agent';
import { i18n } from '@kbn/i18n';
import { schema, TypeOf } from '@kbn/config-schema';
import { IncomingWebhook, IncomingWebhookResult } from '@slack/webhook';
import { pipe } from 'fp-ts/lib/pipeable';
import { map, getOrElse } from 'fp-ts/lib/Option';
import type { ActionTypeExecutorResult as ConnectorTypeExecutorResult } from '@kbn/actions-plugin/server/types';
import type {
ActionType as ConnectorType,
ActionTypeExecutorOptions as ConnectorTypeExecutorOptions,
ActionTypeExecutorResult as ConnectorTypeExecutorResult,
ExecutorType,
ValidatorServices,
} from '@kbn/actions-plugin/server/types';
import {
AlertingConnectorFeatureId,
UptimeConnectorFeatureId,
@ -20,83 +28,114 @@ import {
import { renderMustacheString } from '@kbn/actions-plugin/server/lib/mustache_renderer';
import { getCustomAgents } from '@kbn/actions-plugin/server/lib/get_custom_agents';
import { getRetryAfterIntervalFromHeaders } from '../lib/http_response_retry_header';
import type {
SlackWebApiExecutorOptions,
SlackWebhookExecutorOptions,
WebhookParams,
SlackExecutorOptions,
SlackConnectorType,
WebApiParams,
} from '../../../common/slack/types';
import {
SlackSecretsSchema,
SlackParamsSchema,
SlackConfigSchema,
} from '../../../common/slack/schema';
import { SLACK_CONNECTOR_ID } from '../../../common/slack/constants';
import { SLACK_CONNECTOR_NAME } from './translations';
import { api } from './api';
import { createExternalService } from './service';
import { validate } from './validators';
export function getConnectorType(): SlackConnectorType {
export type SlackConnectorType = ConnectorType<
{},
ConnectorTypeSecretsType,
ActionParamsType,
unknown
>;
export type SlackConnectorTypeExecutorOptions = ConnectorTypeExecutorOptions<
{},
ConnectorTypeSecretsType,
ActionParamsType
>;
// secrets definition
export type ConnectorTypeSecretsType = TypeOf<typeof SecretsSchema>;
const secretsSchemaProps = {
webhookUrl: schema.string(),
};
const SecretsSchema = schema.object(secretsSchemaProps);
// params definition
export type ActionParamsType = TypeOf<typeof ParamsSchema>;
const ParamsSchema = schema.object({
message: schema.string({ minLength: 1 }),
});
// connector type definition
export const ConnectorTypeId = '.slack';
// customizing executor is only used for tests
export function getConnectorType({
executor = slackExecutor,
}: {
executor?: ExecutorType<{}, ConnectorTypeSecretsType, ActionParamsType, unknown>;
}): SlackConnectorType {
return {
id: SLACK_CONNECTOR_ID,
id: ConnectorTypeId,
minimumLicenseRequired: 'gold',
name: SLACK_CONNECTOR_NAME,
name: i18n.translate('xpack.stackConnectors.slack.title', {
defaultMessage: 'Slack',
}),
supportedFeatureIds: [
AlertingConnectorFeatureId,
UptimeConnectorFeatureId,
SecurityConnectorFeatureId,
],
validate: {
config: {
schema: SlackConfigSchema,
},
secrets: {
schema: SlackSecretsSchema,
customValidator: validate.secrets,
schema: SecretsSchema,
customValidator: validateConnectorTypeConfig,
},
params: {
schema: SlackParamsSchema,
schema: ParamsSchema,
},
connector: validate.connector,
},
renderParameterTemplates,
executor: async (execOptions: SlackExecutorOptions) => {
const slackType =
!execOptions.config?.type || execOptions.config?.type === 'webhook' ? 'webhook' : 'web_api';
validate.validateTypeParamsCombination(slackType, execOptions.params);
const res =
slackType === 'webhook'
? await slackWebhookExecutor(execOptions as SlackWebhookExecutorOptions)
: await slackWebApiExecutor(execOptions as SlackWebApiExecutorOptions);
return res;
},
executor,
};
}
const renderParameterTemplates = (
params: WebhookParams | WebApiParams,
function renderParameterTemplates(
params: ActionParamsType,
variables: Record<string, unknown>
) => {
if ('message' in params)
return { message: renderMustacheString(params.message, variables, 'slack') };
if (params.subAction === 'postMessage')
return {
subAction: params.subAction,
subActionParams: {
...params.subActionParams,
text: renderMustacheString(params.subActionParams.text, variables, 'slack'),
},
};
return params;
};
): ActionParamsType {
return {
message: renderMustacheString(params.message, variables, 'slack'),
};
}
const slackWebhookExecutor = async (
execOptions: SlackWebhookExecutorOptions
): Promise<ConnectorTypeExecutorResult<unknown>> => {
function validateConnectorTypeConfig(
secretsObject: ConnectorTypeSecretsType,
validatorServices: ValidatorServices
) {
const { configurationUtilities } = validatorServices;
const configuredUrl = secretsObject.webhookUrl;
try {
new URL(configuredUrl);
} catch (err) {
throw new Error(
i18n.translate('xpack.stackConnectors.slack.configurationErrorNoHostname', {
defaultMessage: 'error configuring slack action: unable to parse host name from webhookUrl',
})
);
}
try {
configurationUtilities.ensureUriAllowed(configuredUrl);
} catch (allowListError) {
throw new Error(
i18n.translate('xpack.stackConnectors.slack.configurationError', {
defaultMessage: 'error configuring slack action: {message}',
values: {
message: allowListError.message,
},
})
);
}
}
// action executor
async function slackExecutor(
execOptions: SlackConnectorTypeExecutorOptions
): Promise<ConnectorTypeExecutorResult<unknown>> {
const { actionId, secrets, params, configurationUtilities, logger } = execOptions;
let result: IncomingWebhookResult;
@ -121,7 +160,6 @@ const slackWebhookExecutor = async (
const webhook = new IncomingWebhook(webhookUrl, {
agent,
});
result = await webhook.send(message);
} catch (err) {
if (err.original == null || err.original.response == null) {
@ -174,7 +212,7 @@ const slackWebhookExecutor = async (
}
return successResult(actionId, result);
};
}
function successResult(actionId: string, data: unknown): ConnectorTypeExecutorResult<unknown> {
return { status: 'ok', data, actionId };
@ -242,47 +280,3 @@ function retryResultSeconds(
serviceMessage: message,
};
}
const supportedSubActions = ['getChannels', 'postMessage'];
const slackWebApiExecutor = async (
execOptions: SlackWebApiExecutorOptions
): Promise<ConnectorTypeExecutorResult<unknown>> => {
const { actionId, params, secrets, configurationUtilities, logger } = execOptions;
const subAction = params.subAction;
if (!api[subAction]) {
const errorMessage = `[Action][ExternalService] Unsupported subAction type ${subAction}.`;
logger.error(errorMessage);
throw new Error(errorMessage);
}
if (!supportedSubActions.includes(subAction)) {
const errorMessage = `[Action][ExternalService] subAction ${subAction} not implemented.`;
logger.error(errorMessage);
throw new Error(errorMessage);
}
const externalService = createExternalService(
{
secrets,
},
logger,
configurationUtilities
);
if (subAction === 'getChannels') {
return await api.getChannels({
externalService,
});
}
if (subAction === 'postMessage') {
return await api.postMessage({
externalService,
params: params.subActionParams,
});
}
return { status: 'ok', data: {}, actionId };
};

View file

@ -1,79 +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.
*/
import type { ActionTypeExecutorResult as ConnectorTypeExecutorResult } from '@kbn/actions-plugin/server/types';
import { i18n } from '@kbn/i18n';
export function successResult(
actionId: string,
data: unknown
): ConnectorTypeExecutorResult<unknown> {
return { status: 'ok', data, actionId };
}
export function errorResult(actionId: string, message: string): ConnectorTypeExecutorResult<void> {
return {
status: 'error',
message,
actionId,
};
}
export function serviceErrorResult(
actionId: string,
serviceMessage?: string
): ConnectorTypeExecutorResult<void> {
const errMessage = i18n.translate('xpack.stackConnectors.slack.errorPostingErrorMessage', {
defaultMessage: 'error posting slack message',
});
return {
status: 'error',
message: errMessage,
actionId,
serviceMessage,
};
}
export function retryResult(actionId: string, message: string): ConnectorTypeExecutorResult<void> {
const errMessage = i18n.translate(
'xpack.stackConnectors.slack.errorPostingRetryLaterErrorMessage',
{
defaultMessage: 'error posting a slack message, retry later',
}
);
return {
status: 'error',
message: errMessage,
retry: true,
actionId,
};
}
export function retryResultSeconds(
actionId: string,
message: string,
retryAfter: number
): ConnectorTypeExecutorResult<void> {
const retryEpoch = Date.now() + retryAfter * 1000;
const retry = new Date(retryEpoch);
const retryString = retry.toISOString();
const errMessage = i18n.translate(
'xpack.stackConnectors.slack.errorPostingRetryDateErrorMessage',
{
defaultMessage: 'error posting a slack message, retry at {retryString}',
values: {
retryString,
},
}
);
return {
status: 'error',
message: errMessage,
retry,
actionId,
serviceMessage: message,
};
}

View file

@ -1,179 +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.
*/
import axios from 'axios';
import { createExternalService } from './service';
import { request, createAxiosResponse } from '@kbn/actions-plugin/server/lib/axios_utils';
import { SlackService } from './types';
import { Logger } from '@kbn/core/server';
import { loggingSystemMock } from '@kbn/core/server/mocks';
import { actionsConfigMock } from '@kbn/actions-plugin/server/actions_config.mock';
const logger = loggingSystemMock.create().get() as jest.Mocked<Logger>;
jest.mock('axios');
jest.mock('@kbn/actions-plugin/server/lib/axios_utils', () => {
const originalUtils = jest.requireActual('@kbn/actions-plugin/server/lib/axios_utils');
return {
...originalUtils,
request: jest.fn(),
};
});
axios.create = jest.fn(() => axios);
const requestMock = request as jest.Mock;
const configurationUtilities = actionsConfigMock.create();
const channels = [
{
id: 'channel_id_1',
name: 'general',
is_channel: true,
is_archived: false,
is_private: true,
},
{
id: 'channel_id_2',
name: 'privat',
is_channel: true,
is_archived: false,
is_private: false,
},
];
const getChannelsResponse = createAxiosResponse({
data: {
ok: true,
channels,
},
});
const postMessageResponse = createAxiosResponse({
data: [
{
ok: true,
channel: 'general',
message: {
text: 'a message',
type: 'message',
},
},
{
ok: true,
channel: 'privat',
message: {
text: 'a message',
type: 'message',
},
},
],
});
describe('Slack service', () => {
let service: SlackService;
beforeAll(() => {
service = createExternalService(
{
secrets: { token: 'token' },
},
logger,
configurationUtilities
);
});
beforeEach(() => {
jest.clearAllMocks();
});
describe('Secrets validation', () => {
test('throws without token', () => {
expect(() =>
createExternalService(
{
secrets: { token: '' },
},
logger,
configurationUtilities
)
).toThrowErrorMatchingInlineSnapshot(`"[Action][Slack]: Wrong configuration."`);
});
});
describe('getChannels', () => {
test('should get slack channels', async () => {
requestMock.mockImplementation(() => getChannelsResponse);
const res = await service.getChannels();
expect(res).toEqual({
actionId: '.slack',
data: {
ok: true,
channels,
},
status: 'ok',
});
});
test('should call request with correct arguments', async () => {
requestMock.mockImplementation(() => getChannelsResponse);
await service.getChannels();
expect(requestMock).toHaveBeenCalledWith({
axios,
logger,
configurationUtilities,
method: 'get',
url: 'conversations.list?types=public_channel,private_channel',
});
});
test('should throw an error if request to slack fail', async () => {
requestMock.mockImplementation(() => {
throw new Error('request fail');
});
expect(await service.getChannels()).toEqual({
actionId: '.slack',
message: 'error posting slack message',
serviceMessage: 'request fail',
status: 'error',
});
});
});
describe('postMessage', () => {
test('should call request with correct arguments', async () => {
requestMock.mockImplementation(() => postMessageResponse);
await service.postMessage({ channels: ['general', 'privat'], text: 'a message' });
expect(requestMock).toHaveBeenCalledTimes(1);
expect(requestMock).toHaveBeenNthCalledWith(1, {
axios,
logger,
configurationUtilities,
method: 'post',
url: 'chat.postMessage',
data: { channel: 'general', text: 'a message' },
});
});
test('should throw an error if request to slack fail', async () => {
requestMock.mockImplementation(() => {
throw new Error('request fail');
});
expect(
await service.postMessage({ channels: ['general', 'privat'], text: 'a message' })
).toEqual({
actionId: '.slack',
message: 'error posting slack message',
serviceMessage: 'request fail',
status: 'error',
});
});
});
});

View file

@ -1,170 +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.
*/
import axios, { AxiosResponse } from 'axios';
import { Logger } from '@kbn/core/server';
import { i18n } from '@kbn/i18n';
import { ActionsConfigurationUtilities } from '@kbn/actions-plugin/server/actions_config';
import { request } from '@kbn/actions-plugin/server/lib/axios_utils';
import { pipe } from 'fp-ts/lib/pipeable';
import { map, getOrElse } from 'fp-ts/lib/Option';
import type { ActionTypeExecutorResult as ConnectorTypeExecutorResult } from '@kbn/actions-plugin/server/types';
import type { SlackService, PostMessageResponse } from './types';
import { SLACK_CONNECTOR_NAME } from './translations';
import type { PostMessageSubActionParams } from '../../../common/slack/types';
import { SLACK_URL } from '../../../common/slack/constants';
import {
retryResultSeconds,
retryResult,
serviceErrorResult,
errorResult,
successResult,
} from './lib';
import { SLACK_CONNECTOR_ID } from '../../../common/slack/constants';
import { getRetryAfterIntervalFromHeaders } from '../lib/http_response_retry_header';
const buildSlackExecutorErrorResponse = ({
slackApiError,
logger,
}: {
slackApiError: {
message: string;
response: {
status: number;
statusText: string;
headers: Record<string, string>;
};
};
logger: Logger;
}) => {
if (!slackApiError.response) {
return serviceErrorResult(SLACK_CONNECTOR_ID, slackApiError.message);
}
const { status, statusText, headers } = slackApiError.response;
// special handling for 5xx
if (status >= 500) {
return retryResult(SLACK_CONNECTOR_ID, slackApiError.message);
}
// special handling for rate limiting
if (status === 429) {
return pipe(
getRetryAfterIntervalFromHeaders(headers),
map((retry) => retryResultSeconds(SLACK_CONNECTOR_ID, slackApiError.message, retry)),
getOrElse(() => retryResult(SLACK_CONNECTOR_ID, slackApiError.message))
);
}
const errorMessage = i18n.translate(
'xpack.stackConnectors.slack.unexpectedHttpResponseErrorMessage',
{
defaultMessage: 'unexpected http response from slack: {httpStatus} {httpStatusText}',
values: {
httpStatus: status,
httpStatusText: statusText,
},
}
);
logger.error(`error on ${SLACK_CONNECTOR_ID} slack action: ${errorMessage}`);
return errorResult(SLACK_CONNECTOR_ID, errorMessage);
};
const buildSlackExecutorSuccessResponse = ({
slackApiResponseData,
}: {
slackApiResponseData: PostMessageResponse;
}) => {
if (!slackApiResponseData) {
const errMessage = i18n.translate(
'xpack.stackConnectors.slack.unexpectedNullResponseErrorMessage',
{
defaultMessage: 'unexpected null response from slack',
}
);
return errorResult(SLACK_CONNECTOR_ID, errMessage);
}
if (!slackApiResponseData.ok) {
return serviceErrorResult(SLACK_CONNECTOR_ID, slackApiResponseData.error);
}
return successResult(SLACK_CONNECTOR_ID, slackApiResponseData);
};
export const createExternalService = (
{ secrets }: { secrets: { token: string } },
logger: Logger,
configurationUtilities: ActionsConfigurationUtilities
): SlackService => {
const { token } = secrets;
if (!token) {
throw Error(`[Action][${SLACK_CONNECTOR_NAME}]: Wrong configuration.`);
}
const axiosInstance = axios.create({
baseURL: SLACK_URL,
headers: {
Authorization: `Bearer ${token}`,
'Content-type': 'application/json; charset=UTF-8',
},
});
const getChannels = async (): Promise<ConnectorTypeExecutorResult<unknown>> => {
try {
const result = await request({
axios: axiosInstance,
configurationUtilities,
logger,
method: 'get',
url: 'conversations.list?types=public_channel,private_channel',
});
return buildSlackExecutorSuccessResponse({ slackApiResponseData: result.data });
} catch (error) {
return buildSlackExecutorErrorResponse({ slackApiError: error, logger });
}
};
const postMessageInOneChannel = async ({
channel,
text,
}: {
channel: string;
text: string;
}): Promise<ConnectorTypeExecutorResult<unknown>> => {
try {
const result: AxiosResponse<PostMessageResponse> = await request({
axios: axiosInstance,
method: 'post',
url: 'chat.postMessage',
logger,
data: { channel, text },
configurationUtilities,
});
return buildSlackExecutorSuccessResponse({ slackApiResponseData: result.data });
} catch (error) {
return buildSlackExecutorErrorResponse({ slackApiError: error, logger });
}
};
const postMessage = async ({
channels,
text,
}: PostMessageSubActionParams): Promise<ConnectorTypeExecutorResult<unknown>> => {
return await postMessageInOneChannel({ channel: channels[0], text });
};
return {
getChannels,
postMessage,
};
};

View file

@ -1,20 +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.
*/
import { i18n } from '@kbn/i18n';
export const SLACK_CONNECTOR_NAME = i18n.translate('xpack.stackConnectors.slack.title', {
defaultMessage: 'Slack',
});
export const ALLOWED_HOSTS_ERROR = (message: string) =>
i18n.translate('xpack.stackConnectors.slack.v2.configuration.apiAllowedHostsError', {
defaultMessage: 'error configuring connector action: {message}',
values: {
message,
},
});

View file

@ -1,26 +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.
*/
import type { ActionTypeExecutorResult as ConnectorTypeExecutorResult } from '@kbn/actions-plugin/server/types';
import type { PostMessageSubActionParams } from '../../../common/slack/types';
export interface PostMessageResponse {
ok: boolean;
channel?: string;
error?: string;
message?: {
text: string;
};
}
export interface SlackService {
getChannels: () => Promise<ConnectorTypeExecutorResult<unknown>>;
postMessage: ({
channels,
text,
}: PostMessageSubActionParams) => Promise<ConnectorTypeExecutorResult<unknown>>;
}

View file

@ -1,109 +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.
*/
import { ValidatorServices } from '@kbn/actions-plugin/server/types';
import { i18n } from '@kbn/i18n';
import { URL } from 'url';
import type { SlackSecrets, SlackConfig, SlackActionParams } from '../../../common/slack/types';
const SECRETS_DO_NOT_MATCH_SLACK_TYPE = (slackType: 'webhook' | 'web_api', secretsField: string) =>
i18n.translate(
'xpack.stackConnectors.slack.configuration.apiValidateSecretsDoNotMatchSlackType',
{
defaultMessage: 'Secrets of Slack type {slackType} should contain {secretsField} field',
values: {
slackType,
secretsField,
},
}
);
const SLACK_CONNECTOR_WITH_TYPE_SHOULD_INCLUDE_FIELD = (
slackTypeName: 'Webhook' | 'Web API',
paramsField: string
) =>
i18n.translate(
'xpack.stackConnectors.slack.configuration.slackConnectorWithTypeShouldIncludeField',
{
defaultMessage:
'Slack connector parameters with type {slackTypeName} should include {paramsField} field in parameters',
values: {
slackTypeName,
paramsField,
},
}
);
const WRONG_SUBACTION = () =>
i18n.translate('xpack.stackConnectors.slack.configuration.slackConnectorWrongSubAction', {
defaultMessage: 'subAction can be only postMesage or getChannels',
});
export const validateSecrets = (secrets: SlackSecrets, validatorServices: ValidatorServices) => {
if (!('webhookUrl' in secrets)) return;
const { configurationUtilities } = validatorServices;
const configuredUrl = secrets.webhookUrl;
try {
new URL(configuredUrl);
} catch (err) {
throw new Error(
i18n.translate('xpack.stackConnectors.slack.configurationErrorNoHostname', {
defaultMessage: 'error configuring slack action: unable to parse host name from webhookUrl',
})
);
}
try {
configurationUtilities.ensureUriAllowed(configuredUrl);
} catch (allowListError) {
throw new Error(
i18n.translate('xpack.stackConnectors.slack.configurationError', {
defaultMessage: 'error configuring slack action: {message}',
values: {
message: allowListError.message,
},
})
);
}
};
const validateConnector = (config: SlackConfig, secrets: SlackSecrets) => {
const isWebhookType = !config?.type || config?.type === 'webhook';
if (isWebhookType && !('webhookUrl' in secrets)) {
return SECRETS_DO_NOT_MATCH_SLACK_TYPE('webhook', 'webhookUrl');
}
if (!isWebhookType && !('token' in secrets)) {
return SECRETS_DO_NOT_MATCH_SLACK_TYPE('web_api', 'token');
}
return null;
};
const validateTypeParamsCombination = (type: 'webhook' | 'web_api', params: SlackActionParams) => {
if (type === 'webhook') {
if (!('message' in params)) {
throw new Error(SLACK_CONNECTOR_WITH_TYPE_SHOULD_INCLUDE_FIELD('Webhook', 'message'));
}
return true;
}
if (type === 'web_api') {
if (!('subAction' in params)) {
throw new Error(SLACK_CONNECTOR_WITH_TYPE_SHOULD_INCLUDE_FIELD('Web API', 'subAction'));
}
if (params.subAction !== 'postMessage' && params.subAction !== 'getChannels') {
throw new Error(WRONG_SUBACTION());
}
}
return;
};
export const validate = {
secrets: validateSecrets,
connector: validateConnector,
validateTypeParamsCombination,
};

View file

@ -21,6 +21,7 @@ export type {
PagerDutyActionParams,
ServerLogConnectorTypeId,
ServerLogActionParams,
SlackConnectorTypeId,
SlackActionParams,
WebhookConnectorTypeId,
WebhookActionParams,

View file

@ -34116,7 +34116,7 @@
"xpack.stackConnectors.components.serviceNowSIR.correlationIDHelpLabel": "Identificateur pour les incidents de mise à jour",
"xpack.stackConnectors.components.serviceNowSIR.selectMessageText": "Créez un incident dans ServiceNow SecOps.",
"xpack.stackConnectors.components.serviceNowSIR.title": "Incident de sécurité",
"xpack.stackConnectors.components.slack.error.requiredSlackMessageText": "Le message est requis.",
"xpack.stackConnectors.components.slack..error.requiredSlackMessageText": "Le message est requis.",
"xpack.stackConnectors.components.slack.connectorTypeTitle": "Envoyer vers Slack",
"xpack.stackConnectors.components.slack.error.invalidWebhookUrlText": "L'URL de webhook n'est pas valide.",
"xpack.stackConnectors.components.slack.messageTextAreaFieldLabel": "Message",

View file

@ -34095,7 +34095,7 @@
"xpack.stackConnectors.components.serviceNowSIR.correlationIDHelpLabel": "インシデントを更新するID",
"xpack.stackConnectors.components.serviceNowSIR.selectMessageText": "ServiceNow SecOpsでインシデントを作成します。",
"xpack.stackConnectors.components.serviceNowSIR.title": "セキュリティインシデント",
"xpack.stackConnectors.components.slack.error.requiredSlackMessageText": "メッセージが必要です。",
"xpack.stackConnectors.components.slack..error.requiredSlackMessageText": "メッセージが必要です。",
"xpack.stackConnectors.components.slack.connectorTypeTitle": "Slack に送信",
"xpack.stackConnectors.components.slack.error.invalidWebhookUrlText": "Web フック URL が無効です。",
"xpack.stackConnectors.components.slack.messageTextAreaFieldLabel": "メッセージ",

View file

@ -34111,7 +34111,7 @@
"xpack.stackConnectors.components.serviceNowSIR.correlationIDHelpLabel": "用于更新事件的标识符",
"xpack.stackConnectors.components.serviceNowSIR.selectMessageText": "在 ServiceNow SecOps 中创建事件。",
"xpack.stackConnectors.components.serviceNowSIR.title": "安全事件",
"xpack.stackConnectors.components.slack.error.requiredSlackMessageText": "“消息”必填。",
"xpack.stackConnectors.components.slack..error.requiredSlackMessageText": "“消息”必填。",
"xpack.stackConnectors.components.slack.connectorTypeTitle": "发送到 Slack",
"xpack.stackConnectors.components.slack.error.invalidWebhookUrlText": "Webhook URL 无效。",
"xpack.stackConnectors.components.slack.messageTextAreaFieldLabel": "消息",

View file

@ -388,23 +388,6 @@ export const ActionForm = ({
}}
onConnectorSelected={(id: string) => {
setActionIdByIndex(id, index);
const actionTypeRegistered = actionTypeRegistry.get(actionConnector.actionTypeId);
if (actionTypeRegistered.resetParamsOnConnectorChange) {
const updatedActions = actions.map((_item: RuleAction, i: number) => {
if (i === index) {
return {
..._item,
id,
params:
actionTypeRegistered.resetParamsOnConnectorChange != null
? actionTypeRegistered.resetParamsOnConnectorChange(_item.params)
: {},
};
}
return _item;
});
setActions(updatedActions);
}
}}
actionTypeRegistry={actionTypeRegistry}
onDeleteAction={() => {

View file

@ -159,11 +159,6 @@ export const ActionTypeForm = ({
return defaultParams;
};
const handleOnConnectorSelected = (id: string) => {
onConnectorSelected(id);
setUseDefaultMessage(true);
};
const [showMinimumThrottleWarning, showMinimumThrottleUnitWarning] = useMemo(() => {
try {
if (!actionThrottle) return [false, false];
@ -347,7 +342,7 @@ export const ActionTypeForm = ({
actionTypesIndex={actionTypesIndex}
actionTypeRegistered={actionTypeRegistered}
connectors={connectors}
onConnectorSelected={handleOnConnectorSelected}
onConnectorSelected={onConnectorSelected}
/>
</EuiFormRow>
<EuiSpacer size="xl" />

View file

@ -24,7 +24,6 @@ interface CommandType<
| 'setRuleActionParams'
| 'setRuleActionProperty'
| 'setRuleActionFrequency'
| 'clearRuleActionParams'
> {
type: T;
}
@ -85,10 +84,6 @@ export type RuleReducerAction =
| {
command: CommandType<'setRuleActionFrequency'>;
payload: Payload<string, RuleActionParam>;
}
| {
command: CommandType<'clearRuleActionParams'>;
payload: { index: number };
};
export type InitialRuleReducer = Reducer<{ rule: InitialRule }, RuleReducerAction>;
@ -101,26 +96,6 @@ export const ruleReducer = <RulePhase extends InitialRule | Rule>(
const { rule } = state;
switch (action.command.type) {
case 'clearRuleActionParams': {
const { index } = action.payload;
if (index === undefined || rule.actions[index] == null) {
return state;
} else {
const oldAction = rule.actions.splice(index, 1)[0];
const updatedAction = {
...oldAction,
params: {},
};
rule.actions.splice(index, 0, updatedAction);
return {
...state,
rule: {
...rule,
actions: [...rule.actions],
},
};
}
}
case 'setRule': {
const { key, value } = action.payload as Payload<'rule', RulePhase>;
if (key === 'rule') {

View file

@ -253,7 +253,6 @@ export interface ActionTypeModel<ActionConfig = any, ActionSecrets = any, Action
defaultRecoveredActionParams?: RecursivePartial<ActionParams>;
customConnectorSelectItem?: CustomConnectorSelectionItem;
isExperimental?: boolean;
resetParamsOnConnectorChange?: (params: ActionParams) => ActionParams | {};
}
export interface GenericValidationResult<T> {

View file

@ -14,50 +14,18 @@ import { getSlackServer } from '@kbn/actions-simulators-plugin/server/plugin';
import { FtrProviderContext } from '../../../../../common/ftr_provider_context';
// eslint-disable-next-line import/no-default-export
export default ({ getService }: FtrProviderContext) => {
export default function slackTest({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const configService = getService('config');
const mockedSlackActionIdForWebhook = async (slackSimulatorURL: string) => {
const { body: createdSimulatedAction } = await supertest
.post('/api/actions/connector')
.set('kbn-xsrf', 'foo')
.send({
name: 'A slack simulator',
connector_type_id: '.slack',
secrets: {
webhookUrl: slackSimulatorURL,
},
config: { type: 'webhook' },
})
.expect(200);
return createdSimulatedAction.id;
};
const mockedSlackActionIdForWebApi = async () => {
const { body: createdSimulatedAction } = await supertest
.post('/api/actions/connector')
.set('kbn-xsrf', 'foo')
.send({
name: 'A slack simulator',
connector_type_id: '.slack',
secrets: {
token: 'some token',
},
config: { type: 'web_api' },
})
.expect(200);
return createdSimulatedAction.id;
};
describe('Slack', () => {
let slackSimulatorURL = '';
describe('slack action', () => {
let simulatedActionId = '';
let slackSimulatorURL: string = '';
let slackServer: http.Server;
let proxyServer: httpProxy | undefined;
let proxyHaveBeenCalled = false;
// need to wait for kibanaServer to settle ...
before(async () => {
slackServer = await getSlackServer();
const availablePort = await getPort({ port: getPort.makeRange(9000, 9100) });
@ -74,270 +42,207 @@ export default ({ getService }: FtrProviderContext) => {
);
});
it('should return 200 when creating a slack action successfully', async () => {
const { body: createdAction } = await supertest
.post('/api/actions/connector')
.set('kbn-xsrf', 'foo')
.send({
name: 'A slack action',
connector_type_id: '.slack',
secrets: {
webhookUrl: slackSimulatorURL,
},
})
.expect(200);
expect(createdAction).to.eql({
id: createdAction.id,
is_preconfigured: false,
is_deprecated: false,
is_missing_secrets: false,
name: 'A slack action',
connector_type_id: '.slack',
config: {},
});
expect(typeof createdAction.id).to.be('string');
const { body: fetchedAction } = await supertest
.get(`/api/actions/connector/${createdAction.id}`)
.expect(200);
expect(fetchedAction).to.eql({
id: fetchedAction.id,
is_preconfigured: false,
is_deprecated: false,
is_missing_secrets: false,
name: 'A slack action',
connector_type_id: '.slack',
config: {},
});
});
it('should respond with a 400 Bad Request when creating a slack action with no webhookUrl', async () => {
await supertest
.post('/api/actions/connector')
.set('kbn-xsrf', 'foo')
.send({
name: 'A slack action',
connector_type_id: '.slack',
secrets: {},
})
.expect(400)
.then((resp: any) => {
expect(resp.body).to.eql({
statusCode: 400,
error: 'Bad Request',
message:
'error validating action type secrets: [webhookUrl]: expected value of type [string] but got [undefined]',
});
});
});
it('should respond with a 400 Bad Request when creating a slack action with not present in allowedHosts webhookUrl', async () => {
await supertest
.post('/api/actions/connector')
.set('kbn-xsrf', 'foo')
.send({
name: 'A slack action',
connector_type_id: '.slack',
secrets: {
webhookUrl: 'http://slack.mynonexistent.com/other/stuff/in/the/path',
},
})
.expect(400)
.then((resp: any) => {
expect(resp.body).to.eql({
statusCode: 400,
error: 'Bad Request',
message: `error validating action type secrets: error configuring slack action: target url \"http://slack.mynonexistent.com/other/stuff/in/the/path\" is not added to the Kibana config xpack.actions.allowedHosts`,
});
});
});
it('should respond with a 400 Bad Request when creating a slack action with a webhookUrl with no hostname', async () => {
await supertest
.post('/api/actions/connector')
.set('kbn-xsrf', 'foo')
.send({
name: 'A slack action',
connector_type_id: '.slack',
secrets: {
webhookUrl: 'fee-fi-fo-fum',
},
})
.expect(400)
.then((resp: any) => {
expect(resp.body).to.eql({
statusCode: 400,
error: 'Bad Request',
message:
'error validating action type secrets: error configuring slack action: unable to parse host name from webhookUrl',
});
});
});
it('should create our slack simulator action successfully', async () => {
const { body: createdSimulatedAction } = await supertest
.post('/api/actions/connector')
.set('kbn-xsrf', 'foo')
.send({
name: 'A slack simulator',
connector_type_id: '.slack',
secrets: {
webhookUrl: slackSimulatorURL,
},
})
.expect(200);
simulatedActionId = createdSimulatedAction.id;
});
it('should handle firing with a simulated success', async () => {
const { body: result } = await supertest
.post(`/api/actions/connector/${simulatedActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: {
message: 'success',
},
})
.expect(200);
expect(result.status).to.eql('ok');
expect(proxyHaveBeenCalled).to.equal(true);
});
it('should handle an empty message error', async () => {
const { body: result } = await supertest
.post(`/api/actions/connector/${simulatedActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: {
message: '',
},
})
.expect(200);
expect(result.status).to.eql('error');
expect(result.message).to.match(/error validating action params: \[message\]: /);
});
it('should handle a 40x slack error', async () => {
const { body: result } = await supertest
.post(`/api/actions/connector/${simulatedActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: {
message: 'invalid_payload',
},
})
.expect(200);
expect(result.status).to.equal('error');
expect(result.message).to.match(/unexpected http response from slack: /);
});
it('should handle a 429 slack error', async () => {
const dateStart = new Date().getTime();
const { body: result } = await supertest
.post(`/api/actions/connector/${simulatedActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: {
message: 'rate_limit',
},
})
.expect(200);
expect(result.status).to.equal('error');
expect(result.message).to.match(/error posting a slack message, retry at \d\d\d\d-/);
const dateRetry = new Date(result.retry).getTime();
expect(dateRetry).to.greaterThan(dateStart);
});
it('should handle a 500 slack error', async () => {
const { body: result } = await supertest
.post(`/api/actions/connector/${simulatedActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: {
message: 'status_500',
},
})
.expect(200);
expect(result.status).to.equal('error');
expect(result.message).to.match(/error posting a slack message, retry later/);
expect(result.retry).to.equal(true);
});
after(() => {
slackServer.close();
if (proxyServer) {
proxyServer.close();
}
});
describe('Slack - Action Creation', () => {
it('should return 200 when creating a slack action with webhook type successfully', async () => {
const { body: createdAction } = await supertest
.post('/api/actions/connector')
.set('kbn-xsrf', 'foo')
.send({
name: 'A slack action',
connector_type_id: '.slack',
config: { type: 'webhook' },
secrets: {
webhookUrl: slackSimulatorURL,
},
})
.expect(200);
expect(createdAction).to.eql({
id: createdAction.id,
is_preconfigured: false,
is_deprecated: false,
is_missing_secrets: false,
name: 'A slack action',
connector_type_id: '.slack',
config: { type: 'webhook' },
});
expect(typeof createdAction.id).to.be('string');
const { body: fetchedAction } = await supertest
.get(`/api/actions/connector/${createdAction.id}`)
.expect(200);
expect(fetchedAction).to.eql({
id: fetchedAction.id,
is_preconfigured: false,
is_deprecated: false,
is_missing_secrets: false,
name: 'A slack action',
connector_type_id: '.slack',
config: { type: 'webhook' },
});
});
it('should return 200 when creating a slack action with web api type successfully', async () => {
const { body: createdAction } = await supertest
.post('/api/actions/connector')
.set('kbn-xsrf', 'foo')
.send({
name: 'A slack web api action',
connector_type_id: '.slack',
config: { type: 'web_api' },
secrets: {
token: 'some token',
},
})
.expect(200);
expect(createdAction).to.eql({
id: createdAction.id,
is_preconfigured: false,
is_deprecated: false,
is_missing_secrets: false,
name: 'A slack web api action',
connector_type_id: '.slack',
config: { type: 'web_api' },
});
expect(typeof createdAction.id).to.be('string');
const { body: fetchedAction } = await supertest
.get(`/api/actions/connector/${createdAction.id}`)
.expect(200);
expect(fetchedAction).to.eql({
id: fetchedAction.id,
is_preconfigured: false,
is_deprecated: false,
is_missing_secrets: false,
name: 'A slack web api action',
connector_type_id: '.slack',
config: { type: 'web_api' },
});
});
it('should respond with a 400 Bad Request when creating a slack action with no webhookUrl', async () => {
await supertest
.post('/api/actions/connector')
.set('kbn-xsrf', 'foo')
.send({
name: 'A slack action',
connector_type_id: '.slack',
secrets: {},
})
.expect(400)
.then((resp: any) => {
expect(resp.body).to.eql({
statusCode: 400,
error: 'Bad Request',
message:
'error validating action type secrets: types that failed validation:\n- [0.webhookUrl]: expected value of type [string] but got [undefined]\n- [1.token]: expected value of type [string] but got [undefined]',
});
});
});
it('should respond with a 400 Bad Request when creating a slack action with not present in allowedHosts webhookUrl', async () => {
await supertest
.post('/api/actions/connector')
.set('kbn-xsrf', 'foo')
.send({
name: 'A slack action',
connector_type_id: '.slack',
secrets: {
webhookUrl: 'http://slack.mynonexistent.com/other/stuff/in/the/path',
},
})
.expect(400)
.then((resp: any) => {
expect(resp.body).to.eql({
statusCode: 400,
error: 'Bad Request',
message: `error validating action type secrets: error configuring slack action: target url \"http://slack.mynonexistent.com/other/stuff/in/the/path\" is not added to the Kibana config xpack.actions.allowedHosts`,
});
});
});
it('should respond with a 400 Bad Request when creating a slack action with a webhookUrl with no hostname', async () => {
await supertest
.post('/api/actions/connector')
.set('kbn-xsrf', 'foo')
.send({
name: 'A slack action',
connector_type_id: '.slack',
secrets: {
webhookUrl: 'fee-fi-fo-fum',
},
})
.expect(400)
.then((resp: any) => {
expect(resp.body).to.eql({
statusCode: 400,
error: 'Bad Request',
message:
'error validating action type secrets: error configuring slack action: unable to parse host name from webhookUrl',
});
});
});
});
describe('Slack - Executor', () => {
it('should handle firing with a simulated success', async () => {
const simulatedActionId = await mockedSlackActionIdForWebhook(slackSimulatorURL);
const { body: result } = await supertest
.post(`/api/actions/connector/${simulatedActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: {
message: 'success',
},
})
.expect(200);
expect(result.status).to.eql('ok');
expect(proxyHaveBeenCalled).to.equal(true);
});
it('should handle firing with a simulated success', async () => {
const simulatedActionId = await mockedSlackActionIdForWebApi();
const { body: result } = await supertest
.post(`/api/actions/connector/${simulatedActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: {
subAction: 'postMessage',
subActionParams: { channels: ['general'], text: 'really important text' },
},
})
.expect(200);
expect(result).to.eql({
status: 'error',
message: 'error posting slack message',
connector_id: '.slack',
service_message: 'invalid_auth',
});
});
it('should handle an empty message error', async () => {
const simulatedActionId = await mockedSlackActionIdForWebhook(slackSimulatorURL);
const { body: result } = await supertest
.post(`/api/actions/connector/${simulatedActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: {
message: '',
},
})
.expect(200);
expect(result.status).to.eql('error');
expect(result.message).to.equal(
"error validating action params: Cannot destructure property 'Symbol(Symbol.iterator)' of 'undefined' as it is undefined."
);
});
it('should handle a 40x slack error', async () => {
const simulatedActionId = await mockedSlackActionIdForWebhook(slackSimulatorURL);
const { body: result } = await supertest
.post(`/api/actions/connector/${simulatedActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: {
message: 'invalid_payload',
},
})
.expect(200);
expect(result.status).to.equal('error');
expect(result.message).to.match(/unexpected http response from slack: /);
});
it('should handle a 429 slack error', async () => {
const simulatedActionId = await mockedSlackActionIdForWebhook(slackSimulatorURL);
const dateStart = new Date().getTime();
const { body: result } = await supertest
.post(`/api/actions/connector/${simulatedActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: {
message: 'rate_limit',
},
})
.expect(200);
expect(result.status).to.equal('error');
expect(result.message).to.match(/error posting a slack message, retry at \d\d\d\d-/);
const dateRetry = new Date(result.retry).getTime();
expect(dateRetry).to.greaterThan(dateStart);
});
it('should handle a 500 slack error', async () => {
const simulatedActionId = await mockedSlackActionIdForWebhook(slackSimulatorURL);
const { body: result } = await supertest
.post(`/api/actions/connector/${simulatedActionId}/_execute`)
.set('kbn-xsrf', 'foo')
.send({
params: {
message: 'status_500',
},
})
.expect(200);
expect(result.status).to.equal('error');
expect(result.message).to.match(/error posting a slack message, retry later/);
expect(result.retry).to.equal(true);
});
});
});
};
}

View file

@ -100,7 +100,6 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
await testSubjects.click('addNewActionConnectorButton-.slack');
const slackConnectorName = generateUniqueKey();
await testSubjects.setValue('nameInput', slackConnectorName);
await testSubjects.click('webhookButton');
await testSubjects.setValue('slackWebhookUrlInput', 'https://test.com');
await find.clickByCssSelector('[data-test-subj="saveActionButtonModal"]:not(disabled)');
const createdConnectorToastTitle = await pageObjects.common.closeToast();
@ -159,7 +158,6 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
await testSubjects.click('addNewActionConnectorButton-.slack');
const slackConnectorName = generateUniqueKey();
await testSubjects.setValue('nameInput', slackConnectorName);
await testSubjects.click('webhookButton');
await testSubjects.setValue('slackWebhookUrlInput', 'https://test.com');
await find.clickByCssSelector('[data-test-subj="saveActionButtonModal"]:not(disabled)');
const createdConnectorToastTitle = await pageObjects.common.closeToast();

View file

@ -54,7 +54,8 @@ export default ({ getPageObjects, getPageObject, getService }: FtrProviderContex
await testSubjects.click('.slack-card');
await testSubjects.setValue('nameInput', connectorName);
await testSubjects.setValue('secrets.token-input', 'some token');
await testSubjects.setValue('slackWebhookUrlInput', 'https://test.com');
await find.clickByCssSelector(
'[data-test-subj="create-connector-flyout-save-btn"]:not(disabled)'