diff --git a/docs/management/action-types.asciidoc b/docs/management/action-types.asciidoc index 0cc3caf722467..74738a8fddeb1 100644 --- a/docs/management/action-types.asciidoc +++ b/docs/management/action-types.asciidoc @@ -59,6 +59,10 @@ a| <> | Create an incident in {swimlane}. +a| <> + +| Send events to a Tines Story. + a| <> | Send a request to a web service. diff --git a/docs/management/connectors/action-types/tines.asciidoc b/docs/management/connectors/action-types/tines.asciidoc new file mode 100644 index 0000000000000..f8080be8edda5 --- /dev/null +++ b/docs/management/connectors/action-types/tines.asciidoc @@ -0,0 +1,105 @@ +[role="xpack"] +[[tines-action-type]] +== Tines connector +++++ +Tines +++++ + +The Tines connector uses Tines's https://www.tines.com/docs/actions/types/webhook[Webhook actions] to send events via POST request. + +[float] +[[tines-connector-configuration]] +=== Connector configuration + +Tines connectors have the following configuration properties. + +URL:: The Tines tenant URL. If you are using the <> setting, make sure the hostname is added to the allowed hosts. +Email:: The email used to sign in to Tines. +API Token:: A Tines API token created by the user. https://www.tines.com/api/authentication#generate-api-token[Docs] + +[role="screenshot"] +image::../images/tines-connector.png[Tines connector] + +[float] +[[Preconfigured-tines-configuration]] +==== Preconfigured connector type + +[source,text] +-- + my-tines: + name: preconfigured-tines-connector-type + actionTypeId: .tines + config: + url: https://some-tenant-2345.tines.com + secrets: + email: some.address@test.com + token: ausergeneratedapitoken +-- + +Config defines information for the connector type. + +`url`:: A Tines tenant URL string that corresponds to *URL*. + +Secrets defines sensitive information for the connector type. + +`email`:: A string that corresponds to *Email*. +`token`:: A string that corresponds to *API Token*. + +[float] +[[tines-action-parameters]] +=== Action parameters + +Tines action have the following parameters. + +Story:: The Story to send the events to. +Webhook:: The Webhook action from the previous story that will receive the events, it is the data entry point. + +Test Tines action parameters. + +[role="screenshot"] +image::../images/tines-params-test.png[Tines params test] + +[float] +[[tines-action-format]] +=== Actions + +Once the Tines connector has been configured in an Alerting Rule. + +[role="screenshot"] +image::../images/tines-alerting.png[Tines rule alert] + +It will send a POST request to the Tines webhook action on every action execution with at least one result. + +[float] +[[webhookUrlFallback-tines-configuration]] +==== Webhook URL fallback + +It is possible for the requests to the Tines API, to get the stories and webhooks for the selectors, to hit the 500 results limit; in this scenario, the webhook URL fallback text field will be displayed. +Users can still use the selectors if the story or webhook exists in the 500 options loaded. Otherwise, users can paste the webhook URL in the test input field, it can be copied from the Tines webhook configuration. + +When the webhook URL is defined, the connector will use it directly in the execution stage, and the story and webhook selectors will be disabled and ignored. To re-enable the story and webhook selectors, remove the webhook URL value. + +[role="screenshot"] +image::../images/tines-webhook-url-fallback.png[Tines Webhook URL fallback] + +[float] +[[tines-story-library]] +=== Tines Story Libary + +In order to simplify the integration with Elastic, Tines offers a set of pre-defined Elastic stories in the Story library. +They can be found by searching for "Elastic" in the Tines Story library: + +[role="screenshot"] +image::../images/tines_elastic_stories.png[Tines Elastic stories] + +They can be imported directly into your Tines tenant. + +=== Format + +Tines connector will send the data in JSON format. + +The message contains execution specific fields, such as `alertId`, `date`, `_index`, `kibanaBaseUrl`, along with the `rule` and `params` objects. + +The number of alerts (signals) can be found at `state.signals_count`. + +The alerts (signals) data is stored in the `context.alerts` array, following the https://www.elastic.co/guide/en/ecs/current/ecs-field-reference.html[ECS] format. diff --git a/docs/management/connectors/images/tines-alerting.png b/docs/management/connectors/images/tines-alerting.png new file mode 100644 index 0000000000000..765cd95abb103 Binary files /dev/null and b/docs/management/connectors/images/tines-alerting.png differ diff --git a/docs/management/connectors/images/tines-connector.png b/docs/management/connectors/images/tines-connector.png new file mode 100644 index 0000000000000..b4a1b12a83f0c Binary files /dev/null and b/docs/management/connectors/images/tines-connector.png differ diff --git a/docs/management/connectors/images/tines-params-test.png b/docs/management/connectors/images/tines-params-test.png new file mode 100644 index 0000000000000..a7211b62ad95a Binary files /dev/null and b/docs/management/connectors/images/tines-params-test.png differ diff --git a/docs/management/connectors/images/tines-webhook-url-fallback.png b/docs/management/connectors/images/tines-webhook-url-fallback.png new file mode 100644 index 0000000000000..f2488240d7d1f Binary files /dev/null and b/docs/management/connectors/images/tines-webhook-url-fallback.png differ diff --git a/docs/management/connectors/images/tines_elastic_stories.png b/docs/management/connectors/images/tines_elastic_stories.png new file mode 100644 index 0000000000000..a59d90aed9eec Binary files /dev/null and b/docs/management/connectors/images/tines_elastic_stories.png differ diff --git a/docs/management/connectors/index.asciidoc b/docs/management/connectors/index.asciidoc index d93e36f9e4ca8..f6a605cf491c0 100644 --- a/docs/management/connectors/index.asciidoc +++ b/docs/management/connectors/index.asciidoc @@ -14,4 +14,5 @@ include::action-types/webhook.asciidoc[] include::action-types/cases-webhook.asciidoc[leveloffset=+1] include::action-types/opsgenie.asciidoc[] include::action-types/xmatters.asciidoc[] +include::action-types/tines.asciidoc[] include::pre-configured-connectors.asciidoc[] diff --git a/docs/settings/alert-action-settings.asciidoc b/docs/settings/alert-action-settings.asciidoc index 9501acbfa22bb..92a6ab2729c82 100644 --- a/docs/settings/alert-action-settings.asciidoc +++ b/docs/settings/alert-action-settings.asciidoc @@ -131,7 +131,7 @@ A list of allowed email domains which can be used with the email connector. When WARNING: This feature is available in {kib} 7.17.4 and 8.3.0 onwards but is not supported in {kib} 8.0, 8.1 or 8.2. As such, this setting should be removed before upgrading from 7.17 to 8.0, 8.1 or 8.2. It is possible to configure the settings in 7.17.4 and then upgrade to 8.3.0 directly. `xpack.actions.enabledActionTypes` {ess-icon}:: -A list of action types that are enabled. It defaults to `[*]`, enabling all types. The names for built-in {kib} action types are prefixed with a `.` and include: `.email`, `.index`, `.jira`, `.opsgenie`, `.pagerduty`, `.resilient`, `.server-log`, `.servicenow`, .`servicenow-itom`, `.servicenow-sir`, `.slack`, `.swimlane`, `.teams`, `.xmatters`, and `.webhook`. An empty list `[]` will disable all action types. +A list of action types that are enabled. It defaults to `[*]`, enabling all types. The names for built-in {kib} action types are prefixed with a `.` and include: `.email`, `.index`, `.jira`, `.opsgenie`, `.pagerduty`, `.resilient`, `.server-log`, `.servicenow`, .`servicenow-itom`, `.servicenow-sir`, `.slack`, `.swimlane`, `.teams`, `.tines`, `.xmatters`, and `.webhook`. An empty list `[]` will disable all action types. + Disabled action types will not appear as an option when creating new connectors, but existing connectors and actions of that type will remain in {kib} and will not function. diff --git a/x-pack/plugins/stack_connectors/common/connector_types/security/tines/constants.ts b/x-pack/plugins/stack_connectors/common/connector_types/security/tines/constants.ts new file mode 100644 index 0000000000000..513c45bc07b98 --- /dev/null +++ b/x-pack/plugins/stack_connectors/common/connector_types/security/tines/constants.ts @@ -0,0 +1,16 @@ +/* + * 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 TINES_TITLE = 'Tines'; +export const TINES_CONNECTOR_ID = '.tines'; +export const API_MAX_RESULTS = 500; +export const enum SUB_ACTION { + STORIES = 'stories', + WEBHOOKS = 'webhooks', + RUN = 'run', + TEST = 'test', +} diff --git a/x-pack/plugins/stack_connectors/common/connector_types/security/tines/schema.ts b/x-pack/plugins/stack_connectors/common/connector_types/security/tines/schema.ts new file mode 100644 index 0000000000000..81f176bcec6a9 --- /dev/null +++ b/x-pack/plugins/stack_connectors/common/connector_types/security/tines/schema.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; + +// Connector schema +export const TinesConfigSchema = schema.object({ url: schema.string() }); +export const TinesSecretsSchema = schema.object({ email: schema.string(), token: schema.string() }); + +// Stories action schema +export const TinesStoriesActionParamsSchema = null; +export const TinesStoryObjectSchema = schema.object({ + id: schema.number(), + name: schema.string(), + published: schema.boolean(), +}); +export const TinesStoriesActionResponseSchema = schema.object({ + stories: schema.arrayOf(TinesStoryObjectSchema), + incompleteResponse: schema.boolean(), +}); + +// Webhooks action schema +export const TinesWebhooksActionParamsSchema = schema.object({ storyId: schema.number() }); +export const TinesWebhookObjectSchema = schema.object({ + id: schema.number(), + name: schema.string(), + storyId: schema.number(), + path: schema.string(), + secret: schema.string(), +}); +export const TinesWebhooksActionResponseSchema = schema.object({ + webhooks: schema.arrayOf(TinesWebhookObjectSchema), + incompleteResponse: schema.boolean(), +}); + +// Run action schema +export const TinesRunActionParamsSchema = schema.object({ + webhook: schema.maybe(TinesWebhookObjectSchema), + webhookUrl: schema.maybe(schema.string()), + body: schema.string(), +}); +export const TinesRunActionResponseSchema = schema.object({}, { unknowns: 'ignore' }); diff --git a/x-pack/plugins/stack_connectors/common/connector_types/security/tines/types.ts b/x-pack/plugins/stack_connectors/common/connector_types/security/tines/types.ts new file mode 100644 index 0000000000000..f67d7a072c3b4 --- /dev/null +++ b/x-pack/plugins/stack_connectors/common/connector_types/security/tines/types.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { TypeOf } from '@kbn/config-schema'; +import { + TinesConfigSchema, + TinesSecretsSchema, + TinesRunActionParamsSchema, + TinesRunActionResponseSchema, + TinesStoriesActionResponseSchema, + TinesWebhooksActionResponseSchema, + TinesWebhooksActionParamsSchema, + TinesWebhookObjectSchema, + TinesStoryObjectSchema, +} from './schema'; + +export type TinesConfig = TypeOf; +export type TinesSecrets = TypeOf; +export type TinesRunActionParams = TypeOf; +export type TinesRunActionResponse = TypeOf; +export type TinesStoriesActionParams = void; +export type TinesStoryObject = TypeOf; +export type TinesStoriesActionResponse = TypeOf; +export type TinesWebhooksActionParams = TypeOf; +export type TinesWebhooksActionResponse = TypeOf; +export type TinesWebhookObject = TypeOf; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/index.ts b/x-pack/plugins/stack_connectors/public/connector_types/index.ts index 517bd32f70955..2e8569ae2bebc 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/index.ts +++ b/x-pack/plugins/stack_connectors/public/connector_types/index.ts @@ -29,6 +29,8 @@ import { getSwimlaneConnectorType, } from './cases'; +import { getTinesConnectorType } from './security'; + export interface RegistrationServices { validateEmailAddresses: ( addresses: string[], @@ -59,4 +61,5 @@ export function registerConnectorTypes({ connectorTypeRegistry.register(getResilientConnectorType()); connectorTypeRegistry.register(getOpsgenieConnectorType()); connectorTypeRegistry.register(getTeamsConnectorType()); + connectorTypeRegistry.register(getTinesConnectorType()); } diff --git a/x-pack/plugins/stack_connectors/public/connector_types/security/index.ts b/x-pack/plugins/stack_connectors/public/connector_types/security/index.ts index 1fec1c76430eb..bba063c7ecae3 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/security/index.ts +++ b/x-pack/plugins/stack_connectors/public/connector_types/security/index.ts @@ -4,3 +4,5 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ + +export { getTinesConnectorType } from './tines'; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/security/tines/index.ts b/x-pack/plugins/stack_connectors/public/connector_types/security/tines/index.ts new file mode 100644 index 0000000000000..a4cc203b0cc00 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/security/tines/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { getConnectorType as getTinesConnectorType } from './tines'; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/security/tines/logo.tsx b/x-pack/plugins/stack_connectors/public/connector_types/security/tines/logo.tsx new file mode 100644 index 0000000000000..af02e2705d7ce --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/security/tines/logo.tsx @@ -0,0 +1,40 @@ +/* + * 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 { LogoProps } from '../types'; + +const Logo = (props: LogoProps) => ( + + + + + + +); + +// eslint-disable-next-line import/no-default-export +export { Logo as default }; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/security/tines/tines.test.ts b/x-pack/plugins/stack_connectors/public/connector_types/security/tines/tines.test.ts new file mode 100644 index 0000000000000..5e8ff3750a37a --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/security/tines/tines.test.ts @@ -0,0 +1,192 @@ +/* + * 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 { TypeRegistry } from '@kbn/triggers-actions-ui-plugin/public/application/type_registry'; +import { registerConnectorTypes } from '../..'; +import type { ActionTypeModel as ConnectorTypeModel } from '@kbn/triggers-actions-ui-plugin/public/types'; +import { registrationServicesMock } from '../../../mocks'; +import { TinesExecuteActionParams } from './types'; +import { + SUB_ACTION, + TINES_CONNECTOR_ID, + TINES_TITLE, +} from '../../../../common/connector_types/security/tines/constants'; + +let actionTypeModel: ConnectorTypeModel; + +const webhook = { + id: 1234, + name: 'some webhook action', + storyId: 5435, + path: 'somePath', + secret: 'someSecret', +}; +const actionParams: TinesExecuteActionParams = { + subAction: SUB_ACTION.RUN, + subActionParams: { webhook }, +}; +const defaultValidationErrors = { + subAction: [], + story: [], + webhook: [], + webhookUrl: [], + body: [], +}; + +beforeAll(() => { + const connectorTypeRegistry = new TypeRegistry(); + registerConnectorTypes({ connectorTypeRegistry, services: registrationServicesMock }); + const getResult = connectorTypeRegistry.get(TINES_CONNECTOR_ID); + if (getResult !== null) { + actionTypeModel = getResult; + } +}); + +describe('actionTypeRegistry.get() works', () => { + it('should get Tines action type static data', () => { + expect(actionTypeModel.id).toEqual(TINES_CONNECTOR_ID); + expect(actionTypeModel.actionTypeTitle).toEqual(TINES_TITLE); + }); +}); + +describe('tines action params validation', () => { + it('should fail when storyId is missing', async () => { + const validation = await actionTypeModel.validateParams({ + ...actionParams, + subActionParams: { webhook: { ...webhook, storyId: undefined } }, + }); + expect(validation.errors).toEqual({ + ...defaultValidationErrors, + story: ['Story is required.'], + }); + }); + + it('should fail when webhook is missing', async () => { + const validation = await actionTypeModel.validateParams({ + ...actionParams, + subActionParams: { webhook: { ...webhook, id: undefined } }, + }); + expect(validation.errors).toEqual({ + ...defaultValidationErrors, + webhook: ['Webhook is required.'], + }); + }); + + it('should fail when webhook path is missing', async () => { + const validation = await actionTypeModel.validateParams({ + ...actionParams, + subActionParams: { webhook: { ...webhook, path: '' } }, + }); + expect(validation.errors).toEqual({ + ...defaultValidationErrors, + webhook: ['Webhook action path is missing.'], + }); + }); + + it('should fail when webhook secret is missing', async () => { + const validation = await actionTypeModel.validateParams({ + ...actionParams, + subActionParams: { webhook: { ...webhook, secret: '' } }, + }); + expect(validation.errors).toEqual({ + ...defaultValidationErrors, + webhook: ['Webhook action secret is missing.'], + }); + }); + + it('should succeed when webhook params are correct', async () => { + const validation = await actionTypeModel.validateParams(actionParams); + expect(validation.errors).toEqual(defaultValidationErrors); + }); + + it('should fail when webhookUrl is not a URL', async () => { + const validation = await actionTypeModel.validateParams({ + ...actionParams, + subActionParams: { ...actionParams.subActionParams, webhookUrl: 'foo' }, + }); + expect(validation.errors).toEqual({ + ...defaultValidationErrors, + webhookUrl: ['Webhook URL is invalid.'], + }); + }); + + it('should fail when webhookUrl is not using https', async () => { + const validation = await actionTypeModel.validateParams({ + ...actionParams, + subActionParams: { ...actionParams.subActionParams, webhookUrl: 'http://example.tines.com' }, + }); + expect(validation.errors).toEqual({ + ...defaultValidationErrors, + webhookUrl: ['Webhook URL does not have a valid "https" protocol.'], + }); + }); + + it('should succeed when webhookUrl is a proper Tines URL', async () => { + const validation = await actionTypeModel.validateParams({ + ...actionParams, + subActionParams: { + ...actionParams.subActionParams, + webhookUrl: 'https://example.tines.com/abc/1234', + }, + }); + expect(validation.errors).toEqual({ + ...defaultValidationErrors, + }); + }); + + it('should fail when subAction is missing', async () => { + const validation = await actionTypeModel.validateParams({ + ...actionParams, + subAction: '', + }); + expect(validation.errors).toEqual({ + ...defaultValidationErrors, + subAction: ['Action is required.'], + }); + }); + + it('should fail when subAction is wrong', async () => { + const validation = await actionTypeModel.validateParams({ + ...actionParams, + subAction: 'stories', + }); + expect(validation.errors).toEqual({ + ...defaultValidationErrors, + subAction: ['Invalid action name.'], + }); + }); + + it('should fail when subAction is test and body is missing', async () => { + const validation = await actionTypeModel.validateParams({ + ...actionParams, + subAction: SUB_ACTION.TEST, + }); + expect(validation.errors).toEqual({ + ...defaultValidationErrors, + body: ['Body is required.'], + }); + }); + + it('should fail when subAction is test and body is not JSON format', async () => { + const validation = await actionTypeModel.validateParams({ + subAction: SUB_ACTION.TEST, + subActionParams: { webhook, body: 'not json' }, + }); + expect(validation.errors).toEqual({ + ...defaultValidationErrors, + body: ['Body does not have a valid JSON format.'], + }); + }); + + it('should succeed when subAction is test and params are correct', async () => { + const validation = await actionTypeModel.validateParams({ + subAction: SUB_ACTION.TEST, + subActionParams: { webhook, body: '[]' }, + }); + expect(validation.errors).toEqual(defaultValidationErrors); + }); +}); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/security/tines/tines.ts b/x-pack/plugins/stack_connectors/public/connector_types/security/tines/tines.ts new file mode 100644 index 0000000000000..88e96fbcbbdea --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/security/tines/tines.ts @@ -0,0 +1,106 @@ +/* + * 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 { lazy } from 'react'; +import { i18n } from '@kbn/i18n'; +import type { + ActionTypeModel as ConnectorTypeModel, + GenericValidationResult, +} from '@kbn/triggers-actions-ui-plugin/public'; +import { + TINES_CONNECTOR_ID, + TINES_TITLE, + SUB_ACTION, +} from '../../../../common/connector_types/security/tines/constants'; +import type { + TinesConfig, + TinesSecrets, +} from '../../../../common/connector_types/security/tines/types'; +import type { TinesExecuteActionParams } from './types'; + +interface ValidationErrors { + subAction: string[]; + story: string[]; + webhook: string[]; + webhookUrl: string[]; + body: string[]; +} + +export function getConnectorType(): ConnectorTypeModel< + TinesConfig, + TinesSecrets, + TinesExecuteActionParams +> { + return { + id: TINES_CONNECTOR_ID, + actionTypeTitle: TINES_TITLE, + iconClass: lazy(() => import('./logo')), + selectMessage: i18n.translate('xpack.stackConnectors.security.tines.config.selectMessageText', { + defaultMessage: 'Send events to a Story.', + }), + validateParams: async ( + actionParams: TinesExecuteActionParams + ): Promise> => { + const translations = await import('./translations'); + const errors: ValidationErrors = { + subAction: [], + story: [], + webhook: [], + webhookUrl: [], + body: [], + }; + const { subAction, subActionParams } = actionParams; + + if (subActionParams?.webhookUrl) { + try { + const parsedUrl = new URL(subActionParams.webhookUrl); + if (parsedUrl.protocol !== 'https:') { + errors.webhookUrl.push(translations.INVALID_PROTOCOL_WEBHOOK_URL); + } + } catch (err) { + errors.webhookUrl.push(translations.INVALID_WEBHOOK_URL); + } + } else { + if (!subActionParams?.webhook?.storyId) { + errors.story.push(translations.STORY_REQUIRED); + } else { + if (!subActionParams?.webhook?.id) { + errors.webhook.push(translations.WEBHOOK_REQUIRED); + } else if (!subActionParams?.webhook?.path) { + errors.webhook.push(translations.WEBHOOK_PATH_REQUIRED); + } else if (!subActionParams?.webhook?.secret) { + errors.webhook.push(translations.WEBHOOK_SECRET_REQUIRED); + } + } + } + + if (subAction === SUB_ACTION.TEST) { + if (!subActionParams?.body?.length) { + errors.body.push(translations.BODY_REQUIRED); + } else { + try { + JSON.parse(subActionParams.body); + } catch { + errors.body.push(translations.BODY_INVALID); + } + } + } + + if (errors.story.length || errors.webhook.length || errors.body.length) return { errors }; + + // The internal "subAction" param should always be valid, ensure it is only if "subActionParams" are valid + if (!subAction) { + errors.subAction.push(translations.ACTION_REQUIRED); + } else if (subAction !== SUB_ACTION.RUN && subAction !== SUB_ACTION.TEST) { + errors.subAction.push(translations.INVALID_ACTION); + } + return { errors }; + }, + actionConnectorFields: lazy(() => import('./tines_connector')), + actionParamsFields: lazy(() => import('./tines_params')), + }; +} diff --git a/x-pack/plugins/stack_connectors/public/connector_types/security/tines/tines_connector.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/security/tines/tines_connector.test.tsx new file mode 100644 index 0000000000000..ff19ab397d94d --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/security/tines/tines_connector.test.tsx @@ -0,0 +1,159 @@ +/* + * 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 { mountWithIntl } from '@kbn/test-jest-helpers'; +import { ConnectorFormTestProvider, waitForComponentToUpdate } from '../../lib/test_utils'; +import { act, render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { + TINES_CONNECTOR_ID, + TINES_TITLE, +} from '../../../../common/connector_types/security/tines/constants'; +import TinesConnectorFields from './tines_connector'; + +const url = 'https://example.com'; +const email = 'some.email@test.com'; +const token = '123'; + +const actionConnector = { + actionTypeId: TINES_CONNECTOR_ID, + name: TINES_TITLE, + config: { url }, + secrets: { email, token }, + isDeprecated: false, +}; + +describe('TinesConnectorFields renders', () => { + it('should render all fields', async () => { + const wrapper = mountWithIntl( + + {}} + /> + + ); + + await waitForComponentToUpdate(); + + expect(wrapper.find('input[data-test-subj="config.url-input"]').exists()).toBe(true); + expect(wrapper.find('input[data-test-subj="config.url-input"]').prop('value')).toBe(url); + expect(wrapper.find('input[data-test-subj="secrets.email-input"]').exists()).toBe(true); + expect(wrapper.find('input[data-test-subj="secrets.email-input"]').prop('value')).toBe(email); + expect(wrapper.find('input[data-test-subj="secrets.token-input"]').exists()).toBe(true); + expect(wrapper.find('input[data-test-subj="secrets.token-input"]').prop('value')).toBe(token); + }); + + describe('Validation', () => { + const onSubmit = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should succeed validation when connector config is valid', async () => { + const { getByTestId } = render( + + {}} + /> + + ); + + await act(async () => { + userEvent.click(getByTestId('form-test-provide-submit')); + }); + + expect(onSubmit).toBeCalledWith({ + data: actionConnector, + isValid: true, + }); + }); + + it('should fail validation when connector secrets are empty', async () => { + const connector = { + ...actionConnector, + secrets: {}, + }; + + const { getByTestId } = render( + + {}} + /> + + ); + + await act(async () => { + userEvent.click(getByTestId('form-test-provide-submit')); + }); + + expect(onSubmit).toBeCalledWith({ + data: {}, + isValid: false, + }); + }); + + it('should fail validation when connector url is empty', async () => { + const connector = { + ...actionConnector, + config: { url: '' }, + }; + + const { getByTestId } = render( + + {}} + /> + + ); + + await act(async () => { + userEvent.click(getByTestId('form-test-provide-submit')); + }); + + expect(onSubmit).toBeCalledWith({ + data: {}, + isValid: false, + }); + }); + + it('should fail validation when connector url is invalid', async () => { + const connector = { + ...actionConnector, + config: { url: 'not a url' }, + }; + + const { getByTestId } = render( + + {}} + /> + + ); + + await act(async () => { + userEvent.click(getByTestId('form-test-provide-submit')); + }); + + expect(onSubmit).toBeCalledWith({ + data: {}, + isValid: false, + }); + }); + }); +}); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/security/tines/tines_connector.tsx b/x-pack/plugins/stack_connectors/public/connector_types/security/tines/tines_connector.tsx new file mode 100644 index 0000000000000..52471b9a24716 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/security/tines/tines_connector.tsx @@ -0,0 +1,51 @@ +/* + * 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, + ConfigFieldSchema, + SecretsFieldSchema, + SimpleConnectorForm, +} from '@kbn/triggers-actions-ui-plugin/public'; +import * as i18n from './translations'; + +const configFormSchema: ConfigFieldSchema[] = [ + { + id: 'url', + label: i18n.URL_LABEL, + isUrlField: true, + }, +]; + +const secretsFormSchema: SecretsFieldSchema[] = [ + { + id: 'email', + label: i18n.EMAIL_LABEL, + }, + { + id: 'token', + label: i18n.TOKEN_LABEL, + isPasswordField: true, + }, +]; + +const TinesActionConnectorFields: React.FunctionComponent = ({ + readOnly, + isEdit, +}) => ( + +); + +// eslint-disable-next-line import/no-default-export +export { TinesActionConnectorFields as default }; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/security/tines/tines_params.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/security/tines/tines_params.test.tsx new file mode 100644 index 0000000000000..12fa612ac9794 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/security/tines/tines_params.test.tsx @@ -0,0 +1,532 @@ +/* + * 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 { mountWithIntl } from '@kbn/test-jest-helpers'; +import { MockCodeEditor } from '@kbn/triggers-actions-ui-plugin/public/application/code_editor.mock'; +import type { UseSubActionParams } from '@kbn/triggers-actions-ui-plugin/public/application/hooks/use_sub_action'; +import TinesParamsFields from './tines_params'; +import { ActionConnectorMode } from '@kbn/triggers-actions-ui-plugin/public/types'; + +const kibanaReactPath = '@kbn/kibana-react-plugin/public'; +const triggersActionsPath = '@kbn/triggers-actions-ui-plugin/public'; +interface Result { + isLoading: boolean; + response: Record; + error: null | Error; +} +const mockUseSubActionStories = jest.fn]>(() => ({ + isLoading: false, + response: { stories: [story], incompleteResponse: false }, + error: null, +})); +const mockUseSubActionWebhooks = jest.fn]>(() => ({ + isLoading: false, + response: { webhooks: [webhook], incompleteResponse: false }, + error: null, +})); +const mockUseSubAction = jest.fn]>((params) => + params.subAction === 'stories' + ? mockUseSubActionStories(params) + : mockUseSubActionWebhooks(params) +); + +const mockToasts = { danger: jest.fn(), warning: jest.fn() }; +jest.mock(triggersActionsPath, () => { + const original = jest.requireActual(triggersActionsPath); + return { + ...original, + useSubAction: (params: UseSubActionParams) => mockUseSubAction(params), + useKibana: () => ({ + ...original.useKibana(), + notifications: { toasts: mockToasts }, + }), + }; +}); + +jest.mock(kibanaReactPath, () => { + const original = jest.requireActual(kibanaReactPath); + return { + ...original, + CodeEditor: (props: any) => { + return ; + }, + }; +}); + +const mockEditAction = jest.fn(); +const index = 0; +const webhook = { + id: 1234, + storyId: 5678, + name: 'test webhook', + path: 'somePath', + secret: 'someSecret', +}; +const story = { id: webhook.storyId, name: 'test story', published: false }; +const actionParams = { subActionParams: { webhook } }; +const emptyErrors = { subAction: [], subActionParams: [] }; + +describe('TinesParamsFields renders', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('New connector', () => { + it('should render empty run form', () => { + const wrapper = mountWithIntl( + + ); + + expect(wrapper.find('[data-test-subj="tines-bodyJsonEditor"]').exists()).toBe(false); + + expect(wrapper.find('[data-test-subj="tines-storySelector"]').exists()).toBe(true); + expect(wrapper.find('[data-test-subj="tines-storySelector"]').first().text()).toBe( + 'Select a Tines story' + ); + expect(wrapper.find('[data-test-subj="tines-webhookSelector"]').exists()).toBe(true); + expect(wrapper.find('[data-test-subj="tines-webhookSelector"]').first().text()).toBe( + 'Select a story first' + ); + expect(wrapper.find('[data-test-subj="tines-fallbackCallout"]').exists()).toBe(false); + expect(wrapper.find('[data-test-subj="tines-webhookUrlInput"]').exists()).toBe(false); + + expect(mockEditAction).toHaveBeenCalledWith('subAction', 'run', index); + }); + + it('should render empty test form', () => { + const wrapper = mountWithIntl( + + ); + + expect(wrapper.find('[data-test-subj="tines-bodyJsonEditor"]').exists()).toBe(true); + expect(wrapper.find('[data-test-subj="bodyAddVariableButton"]').exists()).toBe(false); + + expect(wrapper.find('[data-test-subj="tines-storySelector"]').exists()).toBe(true); + expect(wrapper.find('[data-test-subj="tines-storySelector"]').first().text()).toBe( + 'Select a Tines story' + ); + expect(wrapper.find('[data-test-subj="tines-webhookSelector"]').exists()).toBe(true); + expect(wrapper.find('[data-test-subj="tines-webhookSelector"]').first().text()).toBe( + 'Select a story first' + ); + + expect(mockEditAction).toHaveBeenCalledWith('subAction', 'test', index); + }); + + it('should call useSubAction with empty form', () => { + mountWithIntl( + + ); + expect(mockUseSubAction).toHaveBeenCalledTimes(2); + expect(mockUseSubActionStories).toHaveBeenCalledWith( + expect.objectContaining({ subAction: 'stories' }) + ); + expect(mockUseSubActionWebhooks).toHaveBeenCalledWith( + expect.objectContaining({ subAction: 'webhooks', disabled: true }) + ); + }); + + it('should render with story selectable and webhook selector disabled', () => { + const wrapper = mountWithIntl( + + ); + wrapper + .find('[data-test-subj="tines-storySelector"] [data-test-subj="comboBoxToggleListButton"]') + .first() + .simulate('click'); + + expect(wrapper.find('[data-test-subj="tines-storySelector-optionsList"]').exists()).toBe( + true + ); + expect(wrapper.find('[data-test-subj="tines-storySelector-optionsList"]').text()).toBe( + story.name + ); + expect( + wrapper.find('[data-test-subj="tines-webhookSelector"]').first().prop('disabled') + ).toBe(true); + }); + + it('should render with a story option with Published badge', () => { + mockUseSubActionStories.mockReturnValueOnce({ + isLoading: false, + response: { stories: [{ ...story, published: true }], incompleteResponse: false }, + error: null, + }); + + const wrapper = mountWithIntl( + + ); + wrapper + .find('[data-test-subj="tines-storySelector"] [data-test-subj="comboBoxToggleListButton"]') + .first() + .simulate('click'); + + expect(wrapper.find('[data-test-subj="tines-storySelector-optionsList"]').text()).toContain( + 'Published' + ); + }); + + it('should enable with webhook selector when story selected', () => { + const wrapper = mountWithIntl( + + ); + wrapper + .find('[data-test-subj="tines-storySelector"] [data-test-subj="comboBoxToggleListButton"]') + .first() + .simulate('click'); + wrapper + .find('[data-test-subj="tines-storySelector-optionsList"] button') + .first() + .simulate('click'); + + expect( + wrapper.find('[data-test-subj="tines-webhookSelector"]').first().prop('disabled') + ).toBe(false); + expect(wrapper.find('[data-test-subj="tines-webhookSelector"]').first().text()).toBe( + 'Select a webhook action' + ); + wrapper + .find( + '[data-test-subj="tines-webhookSelector"] [data-test-subj="comboBoxToggleListButton"]' + ) + .first() + .simulate('click'); + + expect(wrapper.find('[data-test-subj="tines-webhookSelector-optionsList"]').text()).toBe( + webhook.name + ); + }); + + it('should set form values when selected', () => { + const wrapper = mountWithIntl( + + ); + wrapper + .find('[data-test-subj="tines-storySelector"] [data-test-subj="comboBoxToggleListButton"]') + .first() + .simulate('click'); + wrapper + .find('[data-test-subj="tines-storySelector-optionsList"] button') + .first() + .simulate('click'); + + expect(mockEditAction).toHaveBeenCalledWith( + 'subActionParams', + { webhook: { storyId: story.id } }, + index + ); + + wrapper + .find( + '[data-test-subj="tines-webhookSelector"] [data-test-subj="comboBoxToggleListButton"]' + ) + .first() + .simulate('click'); + wrapper + .find('[data-test-subj="tines-webhookSelector-optionsList"] button') + .first() + .simulate('click'); + + expect(mockEditAction).toHaveBeenCalledWith('subActionParams', { webhook }, index); + }); + + it('should render webhook url fallback when response incomplete', () => { + mockUseSubActionStories.mockReturnValueOnce({ + isLoading: false, + response: { stories: [story], incompleteResponse: true }, + error: null, + }); + + const wrapper = mountWithIntl( + + ); + expect(wrapper.find('[data-test-subj="tines-fallbackCallout"]').exists()).toBe(true); + expect(wrapper.find('[data-test-subj="tines-webhookUrlInput"]').exists()).toBe(true); + }); + }); + + describe('Edit connector', () => { + it('should render form values', () => { + const wrapper = mountWithIntl( + + ); + + expect(wrapper.find('[data-test-subj="tines-bodyJsonEditor"]').exists()).toBe(false); + expect(wrapper.find('[data-test-subj="tines-storySelector"]').exists()).toBe(true); + expect(wrapper.find('[data-test-subj="tines-storySelector"]').first().text()).toBe( + story.name + ); + expect(wrapper.find('[data-test-subj="tines-webhookSelector"]').exists()).toBe(true); + expect(wrapper.find('[data-test-subj="tines-webhookSelector"]').first().text()).toBe( + webhook.name + ); + + expect(wrapper.find('[data-test-subj="tines-fallbackCallout"]').exists()).toBe(false); + expect(wrapper.find('[data-test-subj="tines-webhookUrlInput"]').exists()).toBe(false); + }); + + it('should call useSubAction with form values', () => { + mountWithIntl( + + ); + expect(mockUseSubActionStories).toHaveBeenCalledWith( + expect.objectContaining({ subAction: 'stories' }) + ); + expect(mockUseSubActionWebhooks).toHaveBeenCalledWith( + expect.objectContaining({ + subAction: 'webhooks', + subActionParams: { storyId: story.id }, + }) + ); + }); + + it('should show warning if story not found', () => { + mountWithIntl( + + ); + + expect(mockToasts.warning).toHaveBeenCalledWith({ + title: 'Cannot find the saved story. Please select a valid story from the selector', + }); + }); + + it('should show warning if webhook not found', () => { + mountWithIntl( + + ); + + expect(mockToasts.warning).toHaveBeenCalledWith({ + title: 'Cannot find the saved webhook. Please select a valid webhook from the selector', + }); + }); + + describe('WebhookUrl fallback', () => { + beforeEach(() => { + mockUseSubActionStories.mockReturnValue({ + isLoading: false, + response: { stories: [story], incompleteResponse: true }, + error: null, + }); + + mockUseSubActionWebhooks.mockReturnValue({ + isLoading: false, + response: { webhooks: [webhook], incompleteResponse: true }, + error: null, + }); + }); + + it('should not render webhook url fallback when stories response incomplete but selected story found', () => { + const wrapper = mountWithIntl( + + ); + expect(wrapper.find('[data-test-subj="tines-fallbackCallout"]').exists()).toBe(false); + expect(wrapper.find('[data-test-subj="tines-webhookUrlInput"]').exists()).toBe(false); + }); + + it('should render webhook url fallback when stories response incomplete and selected story not found', () => { + mockUseSubActionStories.mockReturnValue({ + isLoading: false, + response: { stories: [], incompleteResponse: true }, + error: null, + }); + + const wrapper = mountWithIntl( + + ); + expect(wrapper.find('[data-test-subj="tines-fallbackCallout"]').exists()).toBe(true); + expect(wrapper.find('[data-test-subj="tines-webhookUrlInput"]').exists()).toBe(true); + }); + + it('should not render webhook url fallback when webhook response incomplete but webhook selected found', () => { + const wrapper = mountWithIntl( + + ); + expect(wrapper.find('[data-test-subj="tines-fallbackCallout"]').exists()).toBe(false); + expect(wrapper.find('[data-test-subj="tines-webhookUrlInput"]').exists()).toBe(false); + }); + + it('should render webhook url fallback when webhook response incomplete and webhook selected not found', () => { + mockUseSubActionWebhooks.mockReturnValue({ + isLoading: false, + response: { webhooks: [], incompleteResponse: true }, + error: null, + }); + + const wrapper = mountWithIntl( + + ); + expect(wrapper.find('[data-test-subj="tines-fallbackCallout"]').exists()).toBe(true); + expect(wrapper.find('[data-test-subj="tines-webhookUrlInput"]').exists()).toBe(true); + }); + + it('should render webhook url fallback without callout when responses are complete but webhookUrl is stored', () => { + const webhookUrl = 'https://example.tines.com/1234'; + const wrapper = mountWithIntl( + + ); + expect(wrapper.find('[data-test-subj="tines-fallbackCallout"]').exists()).toBe(false); + expect(wrapper.find('input[data-test-subj="tines-webhookUrlInput"]').exists()).toBe(true); + expect(wrapper.find('input[data-test-subj="tines-webhookUrlInput"]').prop('value')).toBe( + webhookUrl + ); + }); + }); + + describe('subActions error', () => { + it('should show error when stories subAction has error', () => { + const errorMessage = 'something broke'; + mockUseSubActionStories.mockReturnValueOnce({ + isLoading: false, + response: { stories: [story] }, + error: new Error(errorMessage), + }); + + mountWithIntl( + + ); + + expect(mockToasts.danger).toHaveBeenCalledWith({ + title: 'Error retrieving stories from Tines', + body: errorMessage, + }); + }); + + it('should show error when webhooks subAction has error', () => { + const errorMessage = 'something broke'; + mockUseSubActionWebhooks.mockReturnValueOnce({ + isLoading: false, + response: { webhooks: [webhook] }, + error: new Error(errorMessage), + }); + + mountWithIntl( + + ); + + expect(mockToasts.danger).toHaveBeenCalledWith({ + title: 'Error retrieving webhook actions from Tines', + body: errorMessage, + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/security/tines/tines_params.tsx b/x-pack/plugins/stack_connectors/public/connector_types/security/tines/tines_params.tsx new file mode 100644 index 0000000000000..5ed68458fa08a --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/security/tines/tines_params.tsx @@ -0,0 +1,329 @@ +/* + * 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, { useCallback, useEffect, useMemo, useState } from 'react'; +import { + EuiBadge, + EuiCallOut, + EuiComboBox, + EuiComboBoxOptionOption, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiHighlight, + EuiSpacer, +} from '@elastic/eui'; +import { ActionConnectorMode, ActionParamsProps } from '@kbn/triggers-actions-ui-plugin/public'; +import { + JsonEditorWithMessageVariables, + useSubAction, + useKibana, +} from '@kbn/triggers-actions-ui-plugin/public'; +import { SUB_ACTION } from '../../../../common/connector_types/security/tines/constants'; +import type { + TinesStoryObject, + TinesWebhookObject, + TinesWebhooksActionParams, + TinesStoriesActionResponse, + TinesWebhooksActionResponse, + TinesStoriesActionParams, +} from '../../../../common/connector_types/security/tines/types'; +import type { TinesExecuteActionParams, TinesExecuteSubActionParams } from './types'; +import * as i18n from './translations'; + +type StoryOption = EuiComboBoxOptionOption; +type WebhookOption = EuiComboBoxOptionOption; + +const createOption = ( + item: T +): EuiComboBoxOptionOption => ({ + key: item.id.toString(), + value: item, + label: item.name, +}); + +const renderStory = ( + { label, value }: StoryOption, + searchValue: string, + contentClassName: string +) => ( + + + {label} + + {value?.published && ( + + {i18n.STORY_PUBLISHED_BADGE_TEXT} + + )} + +); + +const TinesParamsFields: React.FunctionComponent> = ({ + actionConnector, + actionParams, + editAction, + index, + executionMode, + errors, +}) => { + const { toasts } = useKibana().notifications; + const { subAction, subActionParams } = actionParams; + const { body, webhook, webhookUrl } = subActionParams ?? {}; + + const [connectorId, setConnectorId] = useState(actionConnector?.id); + const [selectedStoryOption, setSelectedStoryOption] = useState(); + const [selectedWebhookOption, setSelectedWebhookOption] = useState< + WebhookOption | null | undefined + >(); + + const isTest = useMemo(() => executionMode === ActionConnectorMode.Test, [executionMode]); + + useEffect(() => { + if (!subAction) { + editAction('subAction', isTest ? SUB_ACTION.TEST : SUB_ACTION.RUN, index); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isTest, subAction]); + + if (connectorId !== actionConnector?.id) { + // Story (and webhook) reset needed before requesting with a different connectorId + setSelectedStoryOption(null); + setConnectorId(actionConnector?.id); + } + + const editSubActionParams = useCallback( + (params: TinesExecuteSubActionParams) => { + editAction('subActionParams', { ...subActionParams, ...params }, index); + }, + [editAction, index, subActionParams] + ); + + const { + response: { stories, incompleteResponse: incompleteStories } = {}, + isLoading: isLoadingStories, + error: storiesError, + } = useSubAction({ + connectorId, + subAction: 'stories', + }); + + const { + response: { webhooks, incompleteResponse: incompleteWebhooks } = {}, + isLoading: isLoadingWebhooks, + error: webhooksError, + } = useSubAction({ + connectorId, + subAction: 'webhooks', + ...(selectedStoryOption?.value?.id + ? { subActionParams: { storyId: selectedStoryOption?.value?.id } } + : { disabled: true }), + }); + + const storiesOptions = useMemo(() => stories?.map(createOption) ?? [], [stories]); + const webhooksOptions = useMemo(() => webhooks?.map(createOption) ?? [], [webhooks]); + + useEffect(() => { + if (storiesError) { + toasts.danger({ title: i18n.STORIES_ERROR, body: storiesError.message }); + } + if (webhooksError) { + toasts.danger({ title: i18n.WEBHOOKS_ERROR, body: webhooksError.message }); + } + }, [toasts, storiesError, webhooksError]); + + const showFallbackFrom = useMemo<'Story' | 'Webhook' | 'any' | null>(() => { + if (incompleteStories && !selectedStoryOption) { + return 'Story'; + } + if (incompleteWebhooks && !selectedWebhookOption) { + return 'Webhook'; + } + if (webhookUrl) { + return 'any'; // no incompleteResponse but webhookUrl is stored in the connector + } + return null; + }, [ + webhookUrl, + incompleteStories, + incompleteWebhooks, + selectedStoryOption, + selectedWebhookOption, + ]); + + useEffect(() => { + if (selectedStoryOption === undefined && webhook?.storyId && stories) { + // Set the initial selected story option from saved storyId when stories are loaded + const selectedStory = stories.find(({ id }) => id === webhook.storyId); + if (selectedStory) { + setSelectedStoryOption(createOption(selectedStory)); + } else { + toasts.warning({ title: i18n.STORY_NOT_FOUND_WARNING }); + editSubActionParams({ webhook: undefined }); + } + } + + if (selectedStoryOption !== undefined && selectedStoryOption?.value?.id !== webhook?.storyId) { + // Selected story changed, update storyId param and remove the rest webhook values + editSubActionParams({ webhook: { storyId: selectedStoryOption?.value?.id } }); + // reset selected webhook. Preserve undefined (not edited) to keep selector isInvalid value consistent + setSelectedWebhookOption((current) => (current === undefined ? undefined : null)); + } + }, [selectedStoryOption, webhook?.storyId, stories, toasts, editSubActionParams]); + + useEffect(() => { + if (selectedWebhookOption === undefined && webhook?.id && webhooks) { + // Set the initial selected webhook option from saved webhookId when webhooks are loaded + const selectedWebhook = webhooks.find(({ id }) => id === webhook.id); + if (selectedWebhook) { + setSelectedWebhookOption(createOption(selectedWebhook)); + } else { + toasts.warning({ title: i18n.WEBHOOK_NOT_FOUND_WARNING }); + editSubActionParams({ webhook: { storyId: webhook?.storyId } }); + } + } + + if (selectedWebhookOption !== undefined && selectedWebhookOption?.value?.id !== webhook?.id) { + // Selected webhook changed, update webhook param, preserve storyId if the selected webhook has been reset + editSubActionParams({ + webhook: selectedWebhookOption + ? selectedWebhookOption.value + : { storyId: webhook?.storyId }, + }); + } + }, [selectedWebhookOption, webhook, webhooks, toasts, editSubActionParams]); + + const selectedStoryOptions = useMemo( + () => (selectedStoryOption ? [selectedStoryOption] : []), + [selectedStoryOption] + ); + const selectedWebhookOptions = useMemo( + () => (selectedWebhookOption ? [selectedWebhookOption] : []), + [selectedWebhookOption] + ); + + const onChangeStory = useCallback(([selected]: StoryOption[]) => { + setSelectedStoryOption(selected ?? null); + }, []); + const onChangeWebhook = useCallback(([selected]: WebhookOption[]) => { + setSelectedWebhookOption(selected ?? null); + }, []); + + return ( + + + + + + + + + + + {showFallbackFrom != null && ( + + {showFallbackFrom !== 'any' && ( + <> + + {i18n.WEBHOOK_URL_FALLBACK_TEXT(showFallbackFrom)} + + + + )} + + { + editSubActionParams({ webhookUrl: ev.target.value }); + }} + fullWidth + data-test-subj="tines-webhookUrlInput" + /> + + + )} + {isTest && ( + + { + editSubActionParams({ body: json }); + }} + onBlur={() => { + if (!body) { + editSubActionParams({ body: '' }); + } + }} + data-test-subj="tines-bodyJsonEditor" + /> + + )} + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { TinesParamsFields as default }; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/security/tines/translations.ts b/x-pack/plugins/stack_connectors/public/connector_types/security/tines/translations.ts new file mode 100644 index 0000000000000..9a1f212e7041d --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/security/tines/translations.ts @@ -0,0 +1,262 @@ +/* + * 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'; +import { API_MAX_RESULTS } from '../../../../common/connector_types/security/tines/constants'; + +// config form +export const URL_LABEL = i18n.translate( + 'xpack.stackConnectors.security.tines.config.urlTextFieldLabel', + { + defaultMessage: 'Tines tenant URL', + } +); +export const AUTHENTICATION_TITLE = i18n.translate( + 'xpack.stackConnectors.security.tines.config.authenticationTitle', + { + defaultMessage: 'Authentication', + } +); +export const EMAIL_LABEL = i18n.translate( + 'xpack.stackConnectors.security.tines.config.emailTextFieldLabel', + { + defaultMessage: 'Email', + } +); +export const TOKEN_LABEL = i18n.translate( + 'xpack.stackConnectors.security.tines.config.tokenTextFieldLabel', + { + defaultMessage: 'API token', + } +); + +export const URL_INVALID = i18n.translate( + 'xpack.stackConnectors.security.tines.config.error.invalidUrlTextField', + { + defaultMessage: 'Tenant URL is invalid.', + } +); + +export const EMAIL_REQUIRED = i18n.translate( + 'xpack.stackConnectors.security.tines.config.error.requiredEmailText', + { + defaultMessage: 'Email is required.', + } +); +export const TOKEN_REQUIRED = i18n.translate( + 'xpack.stackConnectors.security.tines.config.error.requiredAuthTokenText', + { + defaultMessage: 'Auth token is required.', + } +); + +// params form +export const STORY_LABEL = i18n.translate( + 'xpack.stackConnectors.security.tines.params.storyFieldLabel', + { + defaultMessage: 'Tines Story', + } +); +export const STORY_HELP = i18n.translate('xpack.stackConnectors.security.tines.params.storyHelp', { + defaultMessage: 'The Tines story to send the events to', +}); +export const STORY_PLACEHOLDER = i18n.translate( + 'xpack.stackConnectors.security.tines.params.storyPlaceholder', + { + defaultMessage: 'Select a story', + } +); +export const STORY_ARIA_LABEL = i18n.translate( + 'xpack.stackConnectors.security.tines.params.storyFieldAriaLabel', + { + defaultMessage: 'Select a Tines story', + } +); +export const STORY_PUBLISHED_BADGE_TEXT = i18n.translate( + 'xpack.stackConnectors.security.tines.params.storyPublishedBadgeText', + { + defaultMessage: 'Published', + } +); + +export const WEBHOOK_LABEL = i18n.translate( + 'xpack.stackConnectors.security.tines.params.webhookFieldLabel', + { + defaultMessage: 'Tines Webhook action', + } +); +export const WEBHOOK_HELP = i18n.translate( + 'xpack.stackConnectors.security.tines.params.webhookHelp', + { + defaultMessage: 'The data entry action in the story', + } +); +export const WEBHOOK_PLACEHOLDER = i18n.translate( + 'xpack.stackConnectors.security.tines.params.webhookPlaceholder', + { + defaultMessage: 'Select a webhook action', + } +); +export const WEBHOOK_DISABLED_PLACEHOLDER = i18n.translate( + 'xpack.stackConnectors.security.tines.params.webhookDisabledPlaceholder', + { + defaultMessage: 'Select a story first', + } +); +export const WEBHOOK_ARIA_LABEL = i18n.translate( + 'xpack.stackConnectors.security.tines.params.webhookFieldAriaLabel', + { + defaultMessage: 'Select a Tines webhook action', + } +); + +export const WEBHOOK_URL_LABEL = i18n.translate( + 'xpack.stackConnectors.security.tines.params.webhookUrlFieldLabel', + { + defaultMessage: 'Webhook URL', + } +); +export const WEBHOOK_URL_FALLBACK_TITLE = i18n.translate( + 'xpack.stackConnectors.security.tines.params.webhookUrlFallbackTitle', + { + defaultMessage: 'Tines API results limit reached', + } +); +export const WEBHOOK_URL_FALLBACK_TEXT = (entity: 'Story' | 'Webhook') => + i18n.translate('xpack.stackConnectors.security.tines.params.webhookUrlFallbackText', { + values: { entity, limit: API_MAX_RESULTS }, + defaultMessage: `Not possible to retrieve more than {limit} results from the Tines {entity} API. If your {entity} does not appear in the list, please fill the Webhook URL below`, + }); +export const WEBHOOK_URL_HELP = i18n.translate( + 'xpack.stackConnectors.security.tines.params.webhookUrlHelp', + { + defaultMessage: 'The Story and Webhook selectors will be ignored if the Webhook URL is defined', + } +); +export const WEBHOOK_URL_PLACEHOLDER = i18n.translate( + 'xpack.stackConnectors.security.tines.params.webhookUrlPlaceholder', + { + defaultMessage: 'Paste the Webhook URL here', + } +); +export const DISABLED_BY_WEBHOOK_URL_PLACEHOLDER = i18n.translate( + 'xpack.stackConnectors.security.tines.params.disabledByWebhookUrlPlaceholder', + { + defaultMessage: 'Remove the Webhook URL to use this selector', + } +); + +export const BODY_LABEL = i18n.translate( + 'xpack.stackConnectors.security.tines.params.bodyFieldLabel', + { + defaultMessage: 'Body', + } +); +export const BODY_ARIA_LABEL = i18n.translate( + 'xpack.stackConnectors.security.tines.params.bodyFieldAriaLabel', + { + defaultMessage: 'Request body payload', + } +); + +export const STORIES_ERROR = i18n.translate( + 'xpack.stackConnectors.security.tines.params.componentError.storiesRequestFailed', + { + defaultMessage: 'Error retrieving stories from Tines', + } +); +export const WEBHOOKS_ERROR = i18n.translate( + 'xpack.stackConnectors.security.tines.params.componentError.webhooksRequestFailed', + { + defaultMessage: 'Error retrieving webhook actions from Tines', + } +); + +export const STORY_NOT_FOUND_WARNING = i18n.translate( + 'xpack.stackConnectors.security.tines.params.componentWarning.storyNotFound', + { + defaultMessage: 'Cannot find the saved story. Please select a valid story from the selector', + } +); +export const WEBHOOK_NOT_FOUND_WARNING = i18n.translate( + 'xpack.stackConnectors.security.tines.params.componentWarning.webhookNotFound', + { + defaultMessage: + 'Cannot find the saved webhook. Please select a valid webhook from the selector', + } +); + +export const ACTION_REQUIRED = i18n.translate( + 'xpack.stackConnectors.security.tines.params.error.requiredActionText', + { + defaultMessage: 'Action is required.', + } +); + +export const INVALID_ACTION = i18n.translate( + 'xpack.stackConnectors.security.tines.params.error.invalidActionText', + { + defaultMessage: 'Invalid action name.', + } +); + +export const BODY_REQUIRED = i18n.translate( + 'xpack.stackConnectors.security.tines.params.error.requiredBodyText', + { + defaultMessage: 'Body is required.', + } +); + +export const BODY_INVALID = i18n.translate( + 'xpack.stackConnectors.security.tines.params.error.invalidBodyText', + { + defaultMessage: 'Body does not have a valid JSON format.', + } +); + +export const STORY_REQUIRED = i18n.translate( + 'xpack.stackConnectors.security.tines.params.error.requiredStoryText', + { + defaultMessage: 'Story is required.', + } +); +export const WEBHOOK_REQUIRED = i18n.translate( + 'xpack.stackConnectors.security.tines.params.error.requiredWebhookText', + { + defaultMessage: 'Webhook is required.', + } +); +export const WEBHOOK_PATH_REQUIRED = i18n.translate( + 'xpack.stackConnectors.security.tines.params.error.requiredWebhookPathText', + { + defaultMessage: 'Webhook action path is missing.', + } +); +export const WEBHOOK_SECRET_REQUIRED = i18n.translate( + 'xpack.stackConnectors.security.tines.params.error.requiredWebhookSecretText', + { + defaultMessage: 'Webhook action secret is missing.', + } +); +export const INVALID_WEBHOOK_URL = i18n.translate( + 'xpack.stackConnectors.security.tines.params.error.invalidWebhookUrlText', + { + defaultMessage: 'Webhook URL is invalid.', + } +); +export const INVALID_HOSTNAME_WEBHOOK_URL = i18n.translate( + 'xpack.stackConnectors.security.tines.params.error.invalidHostnameWebhookUrlText', + { + defaultMessage: 'Webhook URL does not have a valid ".tines.com" domain.', + } +); +export const INVALID_PROTOCOL_WEBHOOK_URL = i18n.translate( + 'xpack.stackConnectors.security.tines.params.error.invalidProtocolWebhookUrlText', + { + defaultMessage: 'Webhook URL does not have a valid "https" protocol.', + } +); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/security/tines/types.ts b/x-pack/plugins/stack_connectors/public/connector_types/security/tines/types.ts new file mode 100644 index 0000000000000..420b80254bdf0 --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/security/tines/types.ts @@ -0,0 +1,18 @@ +/* + * 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 { TinesRunActionParams } from '../../../../common/connector_types/security/tines/types'; +import type { SUB_ACTION } from '../../../../common/connector_types/security/tines/constants'; + +export type TinesExecuteSubActionParams = Omit, 'webhook'> & { + webhook?: Partial; +}; + +export interface TinesExecuteActionParams { + subAction: SUB_ACTION.RUN | SUB_ACTION.TEST; + subActionParams: TinesExecuteSubActionParams; +} diff --git a/x-pack/plugins/stack_connectors/public/connector_types/security/types.ts b/x-pack/plugins/stack_connectors/public/connector_types/security/types.ts new file mode 100644 index 0000000000000..02855620c814d --- /dev/null +++ b/x-pack/plugins/stack_connectors/public/connector_types/security/types.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { EuiIconProps } from '@elastic/eui'; + +export type LogoProps = Omit; diff --git a/x-pack/plugins/stack_connectors/server/connector_types/index.ts b/x-pack/plugins/stack_connectors/server/connector_types/index.ts index 2440bbc9a28e0..0db1a9da0bd31 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/index.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/index.ts @@ -26,6 +26,8 @@ import { getServiceNowSIRConnectorType, getSwimlaneConnectorType, } from './cases'; +import { getTinesConnectorType } from './security'; + export type { EmailActionParams, IndexActionParams, @@ -83,5 +85,7 @@ export function registerConnectorTypes({ actions.registerType(getJiraConnectorType()); actions.registerType(getResilientConnectorType()); actions.registerType(getTeamsConnectorType()); + actions.registerSubActionConnectorType(getOpsgenieConnectorType()); + actions.registerSubActionConnectorType(getTinesConnectorType()); } diff --git a/x-pack/plugins/stack_connectors/server/connector_types/security/index.ts b/x-pack/plugins/stack_connectors/server/connector_types/security/index.ts index 1fec1c76430eb..bba063c7ecae3 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/security/index.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/security/index.ts @@ -4,3 +4,5 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ + +export { getTinesConnectorType } from './tines'; diff --git a/x-pack/plugins/stack_connectors/server/connector_types/security/tines/api_schema.ts b/x-pack/plugins/stack_connectors/server/connector_types/security/tines/api_schema.ts new file mode 100644 index 0000000000000..5f7f1c1005edd --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/security/tines/api_schema.ts @@ -0,0 +1,62 @@ +/* + * 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, TypeOf } from '@kbn/config-schema'; +import { TinesStoryObjectSchema } from '../../../../common/connector_types/security/tines/schema'; + +// Tines response base schema +export const TinesBaseApiResponseSchema = schema.object( + { + meta: schema.object( + { + pages: schema.number(), + }, + { unknowns: 'ignore' } + ), + }, + { unknowns: 'ignore' } +); + +// Stories action schema +export const TinesStoriesApiResponseSchema = TinesBaseApiResponseSchema.extends( + { + stories: schema.arrayOf(TinesStoryObjectSchema.extends({}, { unknowns: 'ignore' })), + }, + { unknowns: 'ignore' } +); + +// Webhooks action schema +export const TinesWebhooksApiResponseSchema = TinesBaseApiResponseSchema.extends( + { + agents: schema.arrayOf( + schema.object( + { + id: schema.number(), + name: schema.string(), + type: schema.string(), + story_id: schema.number(), + options: schema.object( + { + path: schema.maybe(schema.string()), + secret: schema.maybe(schema.string()), + }, + { unknowns: 'ignore' } + ), + }, + { unknowns: 'ignore' } + ) + ), + }, + { unknowns: 'ignore' } +); + +export const TinesRunApiResponseSchema = schema.object({}, { unknowns: 'ignore' }); + +export type TinesBaseApiResponse = TypeOf; +export type TinesStoriesApiResponse = TypeOf; +export type TinesWebhooksApiResponse = TypeOf; +export type TinesRunApiResponse = TypeOf; diff --git a/x-pack/plugins/stack_connectors/server/connector_types/security/tines/index.ts b/x-pack/plugins/stack_connectors/server/connector_types/security/tines/index.ts new file mode 100644 index 0000000000000..175c5b09b3ec2 --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/security/tines/index.ts @@ -0,0 +1,38 @@ +/* + * 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 { + SubActionConnectorType, + ValidatorType, +} from '@kbn/actions-plugin/server/sub_action_framework/types'; +import { SecurityConnectorFeatureId } from '@kbn/actions-plugin/common'; +import { urlAllowListValidator } from '@kbn/actions-plugin/server'; +import { + TINES_CONNECTOR_ID, + TINES_TITLE, +} from '../../../../common/connector_types/security/tines/constants'; +import { + TinesConfigSchema, + TinesSecretsSchema, +} from '../../../../common/connector_types/security/tines/schema'; +import { TinesConfig, TinesSecrets } from '../../../../common/connector_types/security/tines/types'; +import { TinesConnector } from './tines'; +import { renderParameterTemplates } from './render'; + +export const getTinesConnectorType = (): SubActionConnectorType => ({ + id: TINES_CONNECTOR_ID, + name: TINES_TITLE, + Service: TinesConnector, + schema: { + config: TinesConfigSchema, + secrets: TinesSecretsSchema, + }, + validators: [{ type: ValidatorType.CONFIG, validator: urlAllowListValidator('url') }], + supportedFeatureIds: [SecurityConnectorFeatureId], + minimumLicenseRequired: 'gold' as const, + renderParameterTemplates, +}); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/security/tines/render.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/security/tines/render.test.ts new file mode 100644 index 0000000000000..6abed325bbc45 --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/security/tines/render.test.ts @@ -0,0 +1,105 @@ +/* + * 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 { set } from 'lodash/fp'; +import { renderParameterTemplates } from './render'; + +const params = { + subAction: 'run', + subActionParams: { + webhook: {}, + }, +}; + +const alert1Expected = { + _id: 'l8jb2e55f2740ab15ba4e2717a96c93396c4ce323ac7a486c7dc179d67rg3ft', + _index: '.internal.alerts-security.alerts-default-000001', + host: { ip: ['10.252.97.126'], name: 'Host-dbzugdlqdn' }, + user: { domain: 'm0zepcuuu2', name: 'gyd31qs02v' }, + '@timestamp': '2022-09-30T13:56:38.314Z', +}; +const alert2Expected = { + ...alert1Expected, + _id: 'b02bc8e55f2740ab15ba4e2717a96c93396ccce323ac7a486c7dc179d67b3a2f', +}; + +const alert1 = { + ...alert1Expected, + kibana: { + version: '8.5.0', + space_ids: ['default'], + alert: { + workflow_status: 'open', + }, + }, +}; +const alert2 = { + ...alert1, + _id: alert2Expected._id, +}; + +const variables = { + alertId: 'b02e31f0-336e-11ed-9f07-a9a06b00ec20', + alertName: 'testRule', + spaceId: 'default', + context: { + alerts: [alert1, alert2], + rule: { + description: 'test rule', + rule_id: '27eca842-d8c2-48f3-a1de-3173310b3d90', + }, + }, +}; + +describe('Tines body render', () => { + describe('renderParameterTemplates', () => { + it('should not render body on test action', () => { + const testParams = { subAction: 'test', subActionParams: { body: 'test_json' } }; + const result = renderParameterTemplates(testParams, variables); + expect(result).toEqual(testParams); + }); + + it('should rendered body from variables with cleaned alerts on run action', () => { + const result = renderParameterTemplates(params, variables); + + expect(result.subActionParams.body).toEqual( + JSON.stringify({ + ...variables, + context: { + ...variables.context, + alerts: [alert1Expected, alert2Expected], + }, + }) + ); + }); + + it('should rendered body from variables on run action without context.alerts', () => { + const variablesWithoutAlerts = set('context.alerts', undefined, variables); + const result = renderParameterTemplates(params, variablesWithoutAlerts); + + expect(result.subActionParams.body).toEqual(JSON.stringify(variablesWithoutAlerts)); + }); + + it('should rendered body from variables on run action without context', () => { + const variablesWithoutContext = set('context', undefined, variables); + const result = renderParameterTemplates(params, variablesWithoutContext); + + expect(result.subActionParams.body).toEqual(JSON.stringify(variablesWithoutContext)); + }); + + it('should render error body', () => { + const errorMessage = 'test error'; + jest.spyOn(JSON, 'stringify').mockImplementationOnce(() => { + throw new Error(errorMessage); + }); + const result = renderParameterTemplates(params, variables); + expect(result.subActionParams.body).toEqual( + JSON.stringify({ error: { message: errorMessage } }) + ); + }); + }); +}); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/security/tines/render.ts b/x-pack/plugins/stack_connectors/server/connector_types/security/tines/render.ts new file mode 100644 index 0000000000000..eb99eb3747d2d --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/security/tines/render.ts @@ -0,0 +1,42 @@ +/* + * 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 { set } from 'lodash/fp'; +import { ExecutorParams } from '@kbn/actions-plugin/server/sub_action_framework/types'; +import { RenderParameterTemplates } from '@kbn/actions-plugin/server/types'; +import { SUB_ACTION } from '../../../../common/connector_types/security/tines/constants'; + +interface Context { + alerts: Array>; +} + +export const renderParameterTemplates: RenderParameterTemplates = ( + params, + variables +) => { + if (params?.subAction !== SUB_ACTION.RUN) return params; + + let body: string; + try { + let bodyObject; + const alerts = (variables?.context as Context)?.alerts; + if (alerts) { + // Remove the "kibana" entry from all alerts to reduce weight, the same data can be found in other parts of the alert object. + bodyObject = set( + 'context.alerts', + alerts.map(({ kibana, ...alert }) => alert), + variables + ); + } else { + bodyObject = variables; + } + body = JSON.stringify(bodyObject); + } catch (err) { + body = JSON.stringify({ error: { message: err.message } }); + } + return set('subActionParams.body', body, params); +}; diff --git a/x-pack/plugins/stack_connectors/server/connector_types/security/tines/tines.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/security/tines/tines.test.ts new file mode 100644 index 0000000000000..249b213303260 --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/security/tines/tines.test.ts @@ -0,0 +1,221 @@ +/* + * 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, { AxiosInstance } from 'axios'; +import { actionsConfigMock } from '@kbn/actions-plugin/server/actions_config.mock'; +import { actionsMock } from '@kbn/actions-plugin/server/mocks'; +import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; +import { TinesConnector } from './tines'; +import { request } from '@kbn/actions-plugin/server/lib/axios_utils'; +import { + API_MAX_RESULTS, + TINES_CONNECTOR_ID, +} from '../../../../common/connector_types/security/tines/constants'; + +jest.mock('axios'); +(axios as jest.Mocked).create.mockImplementation( + () => jest.fn() as unknown as AxiosInstance +); + +jest.mock('@kbn/actions-plugin/server/lib/axios_utils'); +const mockRequest = request as jest.Mock; + +const url = 'https://example.com'; +const email = 'some.email@test.com'; +const token = '123'; + +const story = { + id: 97469, + name: 'Test story', + published: true, + team_id: 1234, // just to make sure it is cleaned +}; +const storyResult = { + id: story.id, + name: story.name, + published: story.published, +}; + +const otherAgent = { + id: 941613, + name: 'HTTP Req. Action', + type: 'Agents::HTTPRequestAgent', + story_id: 97469, + options: {}, +}; +const webhookAgent = { + ...otherAgent, + name: 'Elastic Security Webhook', + type: 'Agents::WebhookAgent', + options: { + path: '18f15eaaae93111d3187af42d236c8b2', + secret: 'eb80106acb3ee1521985f5cec3dd224c', + }, +}; +const webhookResult = { + id: webhookAgent.id, + name: webhookAgent.name, + storyId: webhookAgent.story_id, + path: webhookAgent.options.path, + secret: webhookAgent.options.secret, +}; +const webhookUrl = `${url}/webhook/${webhookAgent.options.path}/${webhookAgent.options.secret}`; + +const ignoredRequestFields = { + axios: expect.anything(), + configurationUtilities: expect.anything(), + logger: expect.anything(), +}; +const storiesGetRequestExpected = { + ...ignoredRequestFields, + method: 'get', + data: {}, + url: `${url}/api/v1/stories`, + headers: { + 'x-user-email': email, + 'x-user-token': token, + 'Content-Type': 'application/json', + }, + params: { per_page: API_MAX_RESULTS }, +}; + +const agentsGetRequestExpected = { + ...ignoredRequestFields, + method: 'get', + data: {}, + url: `${url}/api/v1/agents`, + headers: { + 'x-user-email': email, + 'x-user-token': token, + 'Content-Type': 'application/json', + }, + params: { story_id: story.id, per_page: API_MAX_RESULTS }, +}; + +describe('TinesConnector', () => { + const connector = new TinesConnector({ + configurationUtilities: actionsConfigMock.create(), + config: { url }, + connector: { id: '1', type: TINES_CONNECTOR_ID }, + secrets: { email, token }, + logger: loggingSystemMock.createLogger(), + services: actionsMock.createServices(), + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getStories', () => { + beforeAll(() => { + mockRequest.mockReturnValue({ data: { stories: [story], meta: { pages: 1 } } }); + }); + + it('should request Tines stories', async () => { + await connector.getStories(); + expect(mockRequest).toBeCalledTimes(1); + expect(mockRequest).toHaveBeenCalledWith(storiesGetRequestExpected); + }); + + it('should return the Tines stories reduced array', async () => { + const { stories } = await connector.getStories(); + expect(stories).toEqual([storyResult]); + }); + + it('should request the Tines stories complete response', async () => { + mockRequest.mockReturnValueOnce({ + data: { stories: [story], meta: { pages: 1 } }, + }); + const response = await connector.getStories(); + expect(response.incompleteResponse).toEqual(false); + }); + + it('should request the Tines stories incomplete response', async () => { + mockRequest.mockReturnValueOnce({ + data: { stories: [story], meta: { pages: 2 } }, + }); + const response = await connector.getStories(); + expect(response.incompleteResponse).toEqual(true); + }); + }); + + describe('getWebhooks', () => { + beforeAll(() => { + mockRequest.mockReturnValue({ data: { agents: [webhookAgent], meta: { pages: 1 } } }); + }); + + it('should request Tines webhook actions', async () => { + await connector.getWebhooks({ storyId: story.id }); + + expect(mockRequest).toBeCalledTimes(1); + expect(mockRequest).toHaveBeenCalledWith(agentsGetRequestExpected); + }); + + it('should return the Tines webhooks reduced array', async () => { + const { webhooks } = await connector.getWebhooks({ storyId: story.id }); + expect(webhooks).toEqual([webhookResult]); + }); + + it('should request the Tines webhook complete response', async () => { + mockRequest.mockReturnValueOnce({ + data: { agents: [webhookAgent], meta: { pages: 1 } }, + }); + const response = await connector.getWebhooks({ storyId: story.id }); + expect(response.incompleteResponse).toEqual(false); + }); + + it('should request the Tines webhook incomplete response', async () => { + mockRequest.mockReturnValueOnce({ + data: { agents: [webhookAgent], meta: { pages: 2 } }, + }); + const response = await connector.getWebhooks({ storyId: story.id }); + expect(response.incompleteResponse).toEqual(true); + }); + }); + + describe('runWebhook', () => { + beforeAll(() => { + mockRequest.mockReturnValue({ data: { took: 5, requestId: '123', status: 'ok' } }); + }); + + it('should send data to Tines webhook using selected webhook parameter', async () => { + await connector.runWebhook({ + webhook: webhookResult, + body: '[]', + }); + + expect(mockRequest).toBeCalledTimes(1); + expect(mockRequest).toHaveBeenCalledWith({ + ...ignoredRequestFields, + method: 'post', + data: '[]', + url: webhookUrl, + headers: { + 'Content-Type': 'application/json', + }, + }); + }); + + it('should send data to Tines webhook using webhook url parameter', async () => { + await connector.runWebhook({ + webhookUrl, + body: '[]', + }); + + expect(mockRequest).toBeCalledTimes(1); + expect(mockRequest).toHaveBeenCalledWith({ + ...ignoredRequestFields, + method: 'post', + data: '[]', + url: webhookUrl, + headers: { + 'Content-Type': 'application/json', + }, + }); + }); + }); +}); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/security/tines/tines.ts b/x-pack/plugins/stack_connectors/server/connector_types/security/tines/tines.ts new file mode 100644 index 0000000000000..77604294015ac --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/security/tines/tines.ts @@ -0,0 +1,177 @@ +/* + * 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 { ServiceParams, SubActionConnector } from '@kbn/actions-plugin/server'; +import type { AxiosError } from 'axios'; +import { SubActionRequestParams } from '@kbn/actions-plugin/server/sub_action_framework/types'; +import { + TinesStoriesActionParamsSchema, + TinesWebhooksActionParamsSchema, + TinesRunActionParamsSchema, +} from '../../../../common/connector_types/security/tines/schema'; +import type { + TinesConfig, + TinesSecrets, + TinesRunActionParams, + TinesRunActionResponse, + TinesStoriesActionResponse, + TinesWebhooksActionParams, + TinesWebhooksActionResponse, + TinesWebhookObject, + TinesStoryObject, +} from '../../../../common/connector_types/security/tines/types'; +import { + TinesStoriesApiResponseSchema, + TinesWebhooksApiResponseSchema, + TinesRunApiResponseSchema, +} from './api_schema'; +import type { + TinesBaseApiResponse, + TinesStoriesApiResponse, + TinesWebhooksApiResponse, +} from './api_schema'; +import { + API_MAX_RESULTS, + SUB_ACTION, +} from '../../../../common/connector_types/security/tines/constants'; + +export const API_PATH = '/api/v1'; +export const WEBHOOK_PATH = '/webhook'; +export const WEBHOOK_AGENT_TYPE = 'Agents::WebhookAgent'; + +const storiesReducer = ({ stories }: TinesStoriesApiResponse) => ({ + stories: stories.map(({ id, name, published }) => ({ id, name, published })), +}); + +const webhooksReducer = ({ agents }: TinesWebhooksApiResponse) => ({ + webhooks: agents.reduce( + (webhooks, { id, type, name, story_id: storyId, options: { path = '', secret = '' } }) => { + if (type === WEBHOOK_AGENT_TYPE) { + webhooks.push({ id, name, path, secret, storyId }); + } + return webhooks; + }, + [] + ), +}); + +export class TinesConnector extends SubActionConnector { + private urls: { + stories: string; + agents: string; + getRunWebhookURL: (webhook: TinesWebhookObject) => string; + }; + + constructor(params: ServiceParams) { + super(params); + + this.urls = { + stories: `${this.config.url}${API_PATH}/stories`, + agents: `${this.config.url}${API_PATH}/agents`, + getRunWebhookURL: (webhook) => + `${this.config.url}${WEBHOOK_PATH}/${webhook.path}/${webhook.secret}`, + }; + + this.registerSubActions(); + } + + private registerSubActions() { + this.registerSubAction({ + name: SUB_ACTION.STORIES, + method: 'getStories', + schema: TinesStoriesActionParamsSchema, + }); + + this.registerSubAction({ + name: SUB_ACTION.WEBHOOKS, + method: 'getWebhooks', + schema: TinesWebhooksActionParamsSchema, + }); + + this.registerSubAction({ + name: SUB_ACTION.RUN, + method: 'runWebhook', + schema: TinesRunActionParamsSchema, + }); + + this.registerSubAction({ + name: SUB_ACTION.TEST, + method: 'runWebhook', + schema: TinesRunActionParamsSchema, + }); + } + + private getAuthHeaders() { + return { 'x-user-email': this.secrets.email, 'x-user-token': this.secrets.token }; + } + + private async tinesApiRequest( + req: SubActionRequestParams, + reducer: (response: R) => T + ): Promise { + const response = await this.request({ + ...req, + params: { ...req.params, per_page: API_MAX_RESULTS }, + }); + return { + ...reducer(response.data), + incompleteResponse: response.data.meta.pages > 1, + }; + } + + protected getResponseErrorMessage(error: AxiosError): string { + if (!error.response?.status) { + return 'Unknown API Error'; + } + if (error.response.status === 401) { + return 'Unauthorized API Error'; + } + return `API Error: ${error.response?.statusText}`; + } + + public async getStories(): Promise { + return this.tinesApiRequest( + { + url: this.urls.stories, + headers: this.getAuthHeaders(), + responseSchema: TinesStoriesApiResponseSchema, + }, + storiesReducer + ); + } + + public async getWebhooks({ + storyId, + }: TinesWebhooksActionParams): Promise { + return this.tinesApiRequest( + { + url: this.urls.agents, + params: { story_id: storyId }, + headers: this.getAuthHeaders(), + responseSchema: TinesWebhooksApiResponseSchema, + }, + webhooksReducer + ); + } + + public async runWebhook({ + webhook, + webhookUrl, + body, + }: TinesRunActionParams): Promise { + if (!webhook && !webhookUrl) { + throw Error('Invalid subActionsParams: [webhook] or [webhookUrl] expected but got none'); + } + const response = await this.request({ + url: webhookUrl ? webhookUrl : this.urls.getRunWebhookURL(webhook!), + method: 'post', + responseSchema: TinesRunApiResponseSchema, + data: body, + }); + return response.data; + } +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/hooks/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/hooks/index.ts new file mode 100644 index 0000000000000..bc3d1c35c09ed --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/hooks/index.ts @@ -0,0 +1,9 @@ +/* + * 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 { useSubAction } from './use_sub_action'; +export { useLoadRuleTypes } from './use_load_rule_types'; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_sub_action.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_sub_action.test.tsx index 82102402a982f..1f2552511de00 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_sub_action.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_sub_action.test.tsx @@ -7,21 +7,21 @@ import { act, renderHook } from '@testing-library/react-hooks'; import { useKibana } from '../../common/lib/kibana'; -import { useSubAction } from './use_sub_action'; +import { useSubAction, UseSubActionParams } from './use_sub_action'; jest.mock('../../common/lib/kibana'); const useKibanaMock = useKibana as jest.Mocked; describe('useSubAction', () => { - const params = { + const params: UseSubActionParams = { connectorId: 'test-id', subAction: 'test', - subActionParams: {}, + subActionParams: { foo: 'bar' }, }; - useKibanaMock().services.http.post = jest + const mockHttpPost = (useKibanaMock().services.http.post = jest .fn() - .mockImplementation(() => Promise.resolve({ status: 'ok', data: {} })); + .mockImplementation(() => Promise.resolve({ status: 'ok', data: {} }))); let abortSpy = jest.spyOn(window, 'AbortController'); beforeEach(() => { @@ -44,41 +44,38 @@ describe('useSubAction', () => { const { waitForNextUpdate } = renderHook(() => useSubAction(params)); await waitForNextUpdate(); - expect(useKibanaMock().services.http.post).toHaveBeenCalledWith( - '/api/actions/connector/test-id/_execute', - { - body: '{"params":{"subAction":"test","subActionParams":{}}}', - signal: new AbortController().signal, - } - ); + expect(mockHttpPost).toHaveBeenCalledWith('/api/actions/connector/test-id/_execute', { + body: '{"params":{"subAction":"test","subActionParams":{"foo":"bar"}}}', + signal: new AbortController().signal, + }); }); it('executes sub action if subAction parameter changes', async () => { const { rerender, waitForNextUpdate } = renderHook(useSubAction, { initialProps: params }); await waitForNextUpdate(); - expect(useKibanaMock().services.http.post).toHaveBeenCalledTimes(1); + expect(mockHttpPost).toHaveBeenCalledTimes(1); await act(async () => { rerender({ ...params, subAction: 'test-2' }); await waitForNextUpdate(); }); - expect(useKibanaMock().services.http.post).toHaveBeenCalledTimes(2); + expect(mockHttpPost).toHaveBeenCalledTimes(2); }); it('executes sub action if connectorId parameter changes', async () => { const { rerender, waitForNextUpdate } = renderHook(useSubAction, { initialProps: params }); await waitForNextUpdate(); - expect(useKibanaMock().services.http.post).toHaveBeenCalledTimes(1); + expect(mockHttpPost).toHaveBeenCalledTimes(1); await act(async () => { rerender({ ...params, connectorId: 'test-id-2' }); await waitForNextUpdate(); }); - expect(useKibanaMock().services.http.post).toHaveBeenCalledTimes(2); + expect(mockHttpPost).toHaveBeenCalledTimes(2); }); it('returns memoized response if subActionParams changes but values are equal', async () => { @@ -87,7 +84,7 @@ describe('useSubAction', () => { }); await waitForNextUpdate(); - expect(useKibanaMock().services.http.post).toHaveBeenCalledTimes(1); + expect(mockHttpPost).toHaveBeenCalledTimes(1); const previous = result.current; await act(async () => { @@ -96,7 +93,7 @@ describe('useSubAction', () => { }); expect(result.current.response).toBe(previous.response); - expect(useKibanaMock().services.http.post).toHaveBeenCalledTimes(1); + expect(mockHttpPost).toHaveBeenCalledTimes(1); }); it('executes sub action if subActionParams changes and values are not equal', async () => { @@ -105,7 +102,7 @@ describe('useSubAction', () => { }); await waitForNextUpdate(); - expect(useKibanaMock().services.http.post).toHaveBeenCalledTimes(1); + expect(mockHttpPost).toHaveBeenCalledTimes(1); const previous = result.current; await act(async () => { @@ -114,12 +111,12 @@ describe('useSubAction', () => { }); expect(result.current.response).not.toBe(previous.response); - expect(useKibanaMock().services.http.post).toHaveBeenCalledTimes(2); + expect(mockHttpPost).toHaveBeenCalledTimes(2); }); it('returns an error correctly', async () => { const error = new Error('error executing'); - useKibanaMock().services.http.post = jest.fn().mockRejectedValueOnce(error); + mockHttpPost.mockRejectedValueOnce(error); const { result, waitForNextUpdate } = renderHook(() => useSubAction(params)); await waitForNextUpdate(); @@ -131,27 +128,37 @@ describe('useSubAction', () => { }); }); - it('should not set error if aborted', async () => { + it('should abort on unmount', async () => { const firstAbortCtrl = new AbortController(); - firstAbortCtrl.abort(); abortSpy = jest.spyOn(window, 'AbortController').mockReturnValueOnce(firstAbortCtrl); - const error = new Error('error executing'); - useKibanaMock().services.http.post = jest.fn().mockRejectedValueOnce(error); + const { unmount, result } = renderHook(useSubAction, { initialProps: params }); - const { result } = renderHook(() => useSubAction(params)); + unmount(); - expect(result.current.error).toBe(null); + expect(result.current.error).toEqual(null); + expect(firstAbortCtrl.signal.aborted).toEqual(true); }); - it('should abort on unmount', async () => { + it('should abort on disabled change', async () => { const firstAbortCtrl = new AbortController(); - abortSpy = jest.spyOn(window, 'AbortController').mockReturnValueOnce(firstAbortCtrl); + abortSpy = jest.spyOn(window, 'AbortController').mockImplementation(() => { + abortSpy.mockRestore(); + return firstAbortCtrl; + }); + mockHttpPost.mockImplementationOnce( + () => new Promise((resolve) => setTimeout(() => resolve({ status: 'ok', data: {} }), 0)) + ); - const { unmount } = renderHook(useSubAction, { initialProps: params }); + const { result, rerender } = renderHook(useSubAction, { + initialProps: params, + }); + expect(result.current.isLoading).toEqual(true); - unmount(); + rerender({ ...params, disabled: true }); + expect(result.current.isLoading).toEqual(false); + expect(result.current.error).toEqual(null); expect(firstAbortCtrl.signal.aborted).toEqual(true); }); @@ -161,20 +168,30 @@ describe('useSubAction', () => { abortSpy.mockRestore(); return firstAbortCtrl; }); + mockHttpPost.mockImplementationOnce( + () => new Promise((resolve) => setTimeout(() => resolve({ status: 'ok', data: {} }), 1)) + ); + const { result, rerender } = renderHook(useSubAction, { + initialProps: params, + }); - const { rerender } = renderHook(useSubAction, { initialProps: params }); + expect(result.current.isLoading).toEqual(true); + expect(result.current.error).toEqual(null); - await act(async () => { - rerender({ ...params, connectorId: 'test-id-2' }); - }); + mockHttpPost.mockImplementationOnce( + () => new Promise((resolve) => setTimeout(() => resolve({ status: 'ok', data: {} }), 1)) + ); + rerender({ ...params, connectorId: 'test-id-2' }); + expect(result.current.isLoading).toEqual(true); + expect(result.current.error).toEqual(null); expect(firstAbortCtrl.signal.aborted).toEqual(true); }); it('does not execute if disabled', async () => { const { result } = renderHook(() => useSubAction({ ...params, disabled: true })); - expect(useKibanaMock().services.http.post).not.toHaveBeenCalled(); + expect(mockHttpPost).not.toHaveBeenCalled(); expect(result.current).toEqual({ isLoading: false, response: undefined, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_sub_action.tsx b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_sub_action.tsx index fd727e7325814..4deab3689fb38 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_sub_action.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/hooks/use_sub_action.tsx @@ -10,7 +10,7 @@ import { Reducer, useEffect, useReducer, useRef } from 'react'; import { useKibana } from '../../common/lib/kibana'; import { executeAction } from '../lib/action_connector_api'; -interface UseSubActionParams

{ +export interface UseSubActionParams

{ connectorId?: string; subAction?: string; subActionParams?: P; @@ -25,12 +25,14 @@ interface SubActionsState { const enum SubActionsActionsList { START, + STOP, SUCCESS, ERROR, } type Action = | { type: SubActionsActionsList.START } + | { type: SubActionsActionsList.STOP } | { type: SubActionsActionsList.SUCCESS; payload: R | undefined } | { type: SubActionsActionsList.ERROR; payload: Error | null }; @@ -42,6 +44,12 @@ const dataFetchReducer = (state: SubActionsState, action: Action): Sub isLoading: true, error: null, }; + case SubActionsActionsList.STOP: + return { + ...state, + isLoading: false, + error: null, + }; case SubActionsActionsList.SUCCESS: return { ...state, @@ -86,11 +94,12 @@ export const useSubAction = ({ useEffect(() => { if (disabled || !connectorId || !subAction) { + dispatch({ type: SubActionsActionsList.STOP }); return; } const abortCtrl = new AbortController(); - let isMounted = true; + let isActive = true; const executeSubAction = async () => { try { @@ -106,7 +115,7 @@ export const useSubAction = ({ signal: abortCtrl.signal, }); - if (isMounted) { + if (isActive) { if (res.status && res.status === 'ok') { dispatch({ type: SubActionsActionsList.SUCCESS, payload: res.data }); } else { @@ -117,11 +126,11 @@ export const useSubAction = ({ } } return res.data; - } catch (e) { - if (isMounted && !abortCtrl.signal.aborted) { + } catch (err) { + if (isActive) { dispatch({ type: SubActionsActionsList.ERROR, - payload: e, + payload: err, }); } } @@ -130,7 +139,7 @@ export const useSubAction = ({ executeSubAction(); return () => { - isMounted = false; + isActive = false; abortCtrl.abort(); }; }, [memoParams, disabled, connectorId, subAction, http]); diff --git a/x-pack/plugins/triggers_actions_ui/public/index.ts b/x-pack/plugins/triggers_actions_ui/public/index.ts index 0228ac4dec11b..2f1cf263a9a35 100644 --- a/x-pack/plugins/triggers_actions_ui/public/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/index.ts @@ -120,11 +120,14 @@ export { deprecatedMessage, } from './common'; +export { useLoadRuleTypes, useSubAction } from './application/hooks'; + export type { TriggersAndActionsUIPublicPluginSetup, TriggersAndActionsUIPublicPluginStart, } from './plugin'; export { Plugin } from './plugin'; + // TODO remove this import when we expose the Rules tables as a component export { loadRules } from './application/lib/rule_api/rules'; export { loadExecutionLogAggregations } from './application/lib/rule_api/load_execution_log_aggregations'; @@ -139,7 +142,6 @@ export { unmuteRule } from './application/lib/rule_api/unmute'; export { snoozeRule } from './application/lib/rule_api/snooze'; export { unsnoozeRule } from './application/lib/rule_api/unsnooze'; export { loadRuleAggregations, loadRuleTags } from './application/lib/rule_api/aggregate'; -export { useLoadRuleTypes } from './application/hooks/use_load_rule_types'; export { loadRule } from './application/lib/rule_api/get_rule'; export { loadAllActions } from './application/lib/action_connector_api'; export { suspendedComponentWithProps } from './application/lib/suspended_component_with_props'; diff --git a/x-pack/test/alerting_api_integration/common/config.ts b/x-pack/test/alerting_api_integration/common/config.ts index df1e3de142ca8..81784f9fa9e5b 100644 --- a/x-pack/test/alerting_api_integration/common/config.ts +++ b/x-pack/test/alerting_api_integration/common/config.ts @@ -44,6 +44,7 @@ const enabledActionTypes = [ '.jira', '.resilient', '.slack', + '.tines', '.webhook', '.xmatters', 'test.sub-action-connector', diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts index 316d1916b4af2..e629b4f4a0309 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin.ts @@ -26,6 +26,7 @@ import { initPlugin as initWebhook } from './webhook_simulation'; import { initPlugin as initMSExchange } from './ms_exchage_server_simulation'; import { initPlugin as initXmatters } from './xmatters_simulation'; import { initPlugin as initUnsecuredAction } from './unsecured_actions_simulation'; +import { initPlugin as initTines } from './tines_simulation'; export const NAME = 'actions-FTS-external-service-simulators'; @@ -40,12 +41,14 @@ export enum ExternalServiceSimulator { WEBHOOK = 'webhook', MS_EXCHANGE = 'exchange', XMATTERS = 'xmatters', + TINES = 'tines', } export function getExternalServiceSimulatorPath(service: ExternalServiceSimulator): string { return `/api/_${NAME}/${service}`; } +// list all urls for server.xsrf.allowlist config export function getAllExternalServiceSimulatorPaths(): string[] { const allPaths = Object.values(ExternalServiceSimulator).map((service) => getExternalServiceSimulatorPath(service) @@ -56,6 +59,7 @@ export function getAllExternalServiceSimulatorPaths(): string[] { allPaths.push(`/api/_${NAME}/${ExternalServiceSimulator.MS_EXCHANGE}/users/test@/sendMail`); allPaths.push(`/api/_${NAME}/${ExternalServiceSimulator.MS_EXCHANGE}/1234567/oauth2/v2.0/token`); allPaths.push(`/api/_${NAME}/${ExternalServiceSimulator.SERVICENOW}/oauth_token.do`); + allPaths.push(`/api/_${NAME}/${ExternalServiceSimulator.TINES}/webhook/path/secret`); return allPaths; } @@ -142,6 +146,7 @@ export class FixturePlugin implements Plugin + ) { + if (this.returnError) { + return TinesSimulator.sendErrorResponse(response); + } + return TinesSimulator.sendResponse(request, response); + } + + private static sendResponse(request: http.IncomingMessage, response: http.ServerResponse) { + response.statusCode = 202; + response.setHeader('Content-Type', 'application/json'); + let body; + if (request.url?.match('/stories')) { + body = tinesStoriesResponse; + } else if (request.url?.match('/agents')) { + body = tinesAgentsResponse; + } else if (request.url?.match('/webhook')) { + body = tinesWebhookSuccessResponse; + } + response.end(JSON.stringify(body, null, 4)); + } + + private static sendErrorResponse(response: http.ServerResponse) { + response.statusCode = 422; + response.setHeader('Content-Type', 'application/json;charset=UTF-8'); + response.end(JSON.stringify(tinesFailedResponse, null, 4)); + } +} + +export function initPlugin(router: IRouter, path: string) { + router.get( + { + path: `${path}/api/v1/stories`, + options: { + authRequired: false, + }, + validate: {}, + }, + async function ( + context: RequestHandlerContext, + req: KibanaRequest, + res: KibanaResponseFactory + ): Promise> { + return res.ok({ body: tinesStoriesResponse }); + } + ); + + router.get( + { + path: `${path}/api/v1/agents`, + options: { + authRequired: false, + }, + validate: {}, + }, + async function ( + context: RequestHandlerContext, + req: KibanaRequest, + res: KibanaResponseFactory + ): Promise> { + return res.ok({ body: tinesAgentsResponse }); + } + ); + + router.post( + { + path: `${path}/webhook/path/secret`, + options: { + authRequired: false, + }, + validate: {}, + }, + async function ( + context: RequestHandlerContext, + req: KibanaRequest, + res: KibanaResponseFactory + ): Promise> { + return res.ok({ body: tinesWebhookSuccessResponse }); + } + ); +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/security/tines.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/security/tines.ts new file mode 100644 index 0000000000000..03164ed80ea79 --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/security/tines.ts @@ -0,0 +1,557 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../../../../../common/ftr_provider_context'; +import { + TinesSimulator, + tinesStory1, + tinesStory2, + tinesAgentWebhook, + tinesWebhookSuccessResponse, +} from '../../../../../../common/fixtures/plugins/actions_simulators/server/tines_simulation'; + +const connectorTypeId = '.tines'; +const name = 'A tines action'; +const secrets = { + email: 'some@email.com', + token: 'tinesToken', +}; +const webhook = { + name: 'webhook 1', + id: 1, + storyId: 1, + path: 'path', + secret: 'secret', +}; + +// eslint-disable-next-line import/no-default-export +export default function tinesTest({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const configService = getService('config'); + + const createConnector = async (url: string) => { + const { body } = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name, + connector_type_id: connectorTypeId, + config: { url }, + secrets, + }) + .expect(200); + + return body.id; + }; + + describe('Tines', () => { + describe('action creation', () => { + const simulator = new TinesSimulator({ + returnError: false, + proxy: { + config: configService.get('kbnTestServer.serverArgs'), + }, + }); + const config = { url: '' }; + + before(async () => { + config.url = await simulator.start(); + }); + + after(() => { + simulator.close(); + }); + + it('should return 200 when creating the connector', async () => { + const { body: createdAction } = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name, + connector_type_id: connectorTypeId, + config, + secrets, + }) + .expect(200); + + expect(createdAction).to.eql({ + id: createdAction.id, + is_preconfigured: false, + is_deprecated: false, + name, + connector_type_id: connectorTypeId, + is_missing_secrets: false, + config, + }); + }); + + it('should return 400 Bad Request when creating the connector without the url', async () => { + await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name, + connector_type_id: connectorTypeId, + config: {}, + secrets, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type config: [url]: expected value of type [string] but got [undefined]', + }); + }); + }); + + it('should return 400 Bad Request when creating the connector with a url that is not allowed', async () => { + await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name, + connector_type_id: connectorTypeId, + config: { + url: 'http://tines.mynonexistent.com', + }, + secrets, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type config: error validating url: target url "http://tines.mynonexistent.com" is not added to the Kibana config xpack.actions.allowedHosts', + }); + }); + }); + + it('should return 400 Bad Request when creating the connector without secrets', async () => { + await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name, + connector_type_id: connectorTypeId, + config, + }) + .expect(400) + .then((resp: any) => { + expect(resp.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: + 'error validating action type secrets: [email]: expected value of type [string] but got [undefined]', + }); + }); + }); + }); + + describe('executor', () => { + describe('validation', () => { + const simulator = new TinesSimulator({ + proxy: { + config: configService.get('kbnTestServer.serverArgs'), + }, + }); + let tinesActionId: string; + + before(async () => { + const url = await simulator.start(); + tinesActionId = await createConnector(url); + }); + + after(() => { + simulator.close(); + }); + + it('should fail when the params is empty', async () => { + const { body } = await supertest + .post(`/api/actions/connector/${tinesActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: {}, + }); + expect(200); + + expect(Object.keys(body)).to.eql(['status', 'message', 'retry', 'connector_id']); + expect(body.connector_id).to.eql(tinesActionId); + expect(body.status).to.eql('error'); + }); + + it('should fail when the subAction is invalid', async () => { + const { body } = await supertest + .post(`/api/actions/connector/${tinesActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { subAction: 'invalidAction' }, + }) + .expect(200); + + expect(body).to.eql({ + connector_id: tinesActionId, + status: 'error', + retry: true, + message: 'an error occurred while running the action', + service_message: `Sub action "invalidAction" is not registered. Connector id: ${tinesActionId}. Connector name: Tines. Connector type: .tines`, + }); + }); + + it("should fail to get webhooks when the storyId parameter isn't included", async () => { + const { body } = await supertest + .post(`/api/actions/connector/${tinesActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { subAction: 'webhooks', subActionParams: {} }, + }) + .expect(200); + + expect(body).to.eql({ + connector_id: tinesActionId, + status: 'error', + retry: true, + message: 'an error occurred while running the action', + service_message: + 'Request validation failed (Error: [storyId]: expected value of type [number] but got [undefined])', + }); + }); + + it("should fail to run when the webhook parameter isn't included", async () => { + const { body } = await supertest + .post(`/api/actions/connector/${tinesActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { subAction: 'run', subActionParams: { body: '' } }, + }) + .expect(200); + + expect(body).to.eql({ + connector_id: tinesActionId, + status: 'error', + retry: true, + message: 'an error occurred while running the action', + service_message: + 'Invalid subActionsParams: [webhook] or [webhookUrl] expected but got none', + }); + }); + + it("should fail to run when the webhook.story_id parameter isn't included", async () => { + const { storyId, ...wrongWebhook } = webhook; + const { body } = await supertest + .post(`/api/actions/connector/${tinesActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + subAction: 'run', + subActionParams: { body: '', webhook: wrongWebhook }, + }, + }) + .expect(200); + + expect(body).to.eql({ + connector_id: tinesActionId, + status: 'error', + retry: true, + message: 'an error occurred while running the action', + service_message: + 'Request validation failed (Error: [webhook.storyId]: expected value of type [number] but got [undefined])', + }); + }); + + it("should fail to run when the webhook.name parameter isn't included", async () => { + const { name: _, ...wrongWebhook } = webhook; + const { body } = await supertest + .post(`/api/actions/connector/${tinesActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + subAction: 'run', + subActionParams: { body: '', webhook: wrongWebhook }, + }, + }) + .expect(200); + + expect(body).to.eql({ + connector_id: tinesActionId, + status: 'error', + retry: true, + message: 'an error occurred while running the action', + service_message: + 'Request validation failed (Error: [webhook.name]: expected value of type [string] but got [undefined])', + }); + }); + + it("should fail to run when the webhook.path parameter isn't included", async () => { + const { path, ...wrongWebhook } = webhook; + const { body } = await supertest + .post(`/api/actions/connector/${tinesActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + subAction: 'run', + subActionParams: { body: '', webhook: wrongWebhook }, + }, + }) + .expect(200); + + expect(body).to.eql({ + connector_id: tinesActionId, + status: 'error', + retry: true, + message: 'an error occurred while running the action', + service_message: + 'Request validation failed (Error: [webhook.path]: expected value of type [string] but got [undefined])', + }); + }); + + it("should fail to run when the webhook.secret parameter isn't included", async () => { + const { secret, ...wrongWebhook } = webhook; + const { body } = await supertest + .post(`/api/actions/connector/${tinesActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + subAction: 'run', + subActionParams: { body: '', webhook: wrongWebhook }, + }, + }) + .expect(200); + + expect(body).to.eql({ + connector_id: tinesActionId, + status: 'error', + retry: true, + message: 'an error occurred while running the action', + service_message: + 'Request validation failed (Error: [webhook.secret]: expected value of type [string] but got [undefined])', + }); + }); + }); + + describe('execution', () => { + describe('successful response simulator', () => { + const simulator = new TinesSimulator({ + proxy: { + config: configService.get('kbnTestServer.serverArgs'), + }, + }); + let url: string; + let tinesActionId: string; + + before(async () => { + url = await simulator.start(); + tinesActionId = await createConnector(url); + }); + + after(() => { + simulator.close(); + }); + + it('should get stories', async () => { + const { body } = await supertest + .post(`/api/actions/connector/${tinesActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { subAction: 'stories', subActionParams: {} }, + }) + .expect(200); + + expect(simulator.requestUrl).to.eql(getTinesStoriesUrl(url, { per_page: '500' })); + expect(body).to.eql({ + status: 'ok', + connector_id: tinesActionId, + data: { + stories: [ + { id: tinesStory1.id, name: tinesStory1.name, published: true }, + { id: tinesStory2.id, name: tinesStory2.name, published: true }, + ], + incompleteResponse: false, + }, + }); + }); + + it('should get webhooks', async () => { + const { body } = await supertest + .post(`/api/actions/connector/${tinesActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { subAction: 'webhooks', subActionParams: { storyId: 1 } }, + }) + .expect(200); + + expect(simulator.requestUrl).to.eql( + getTinesAgentsUrl(url, { story_id: '1', per_page: '500' }) + ); + expect(body).to.eql({ + status: 'ok', + connector_id: tinesActionId, + data: { + webhooks: [ + { + id: tinesAgentWebhook.id, + name: tinesAgentWebhook.name, + storyId: tinesAgentWebhook.story_id, + path: tinesAgentWebhook.options.path, + secret: tinesAgentWebhook.options.secret, + }, + ], + incompleteResponse: false, + }, + }); + }); + + it('should run the webhook', async () => { + const { body } = await supertest + .post(`/api/actions/connector/${tinesActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { subAction: 'run', subActionParams: { body: '["test"]', webhook } }, + }) + .expect(200); + + expect(simulator.requestData).to.eql(['test']); + expect(simulator.requestUrl).to.eql(getTinesWebhookPostUrl(url, webhook)); + expect(body).to.eql({ + status: 'ok', + connector_id: tinesActionId, + data: tinesWebhookSuccessResponse, + }); + }); + + it('should run the webhook url', async () => { + const { body } = await supertest + .post(`/api/actions/connector/${tinesActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + subAction: 'run', + subActionParams: { + body: '["test"]', + webhookUrl: getTinesWebhookPostUrl(url, webhook), + }, + }, + }) + .expect(200); + + expect(simulator.requestData).to.eql(['test']); + expect(simulator.requestUrl).to.eql(getTinesWebhookPostUrl(url, webhook)); + expect(body).to.eql({ + status: 'ok', + connector_id: tinesActionId, + data: tinesWebhookSuccessResponse, + }); + }); + }); + + describe('error response simulator', () => { + const simulator = new TinesSimulator({ + returnError: true, + proxy: { + config: configService.get('kbnTestServer.serverArgs'), + }, + }); + + let tinesActionId: string; + + before(async () => { + const url = await simulator.start(); + tinesActionId = await createConnector(url); + }); + + after(() => { + simulator.close(); + }); + + it('should return a failure when attempting to get stories', async () => { + const { body } = await supertest + .post(`/api/actions/connector/${tinesActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + subAction: 'stories', + subActionParams: {}, + }, + }) + .expect(200); + + expect(body).to.eql({ + status: 'error', + message: 'an error occurred while running the action', + retry: true, + connector_id: tinesActionId, + service_message: 'Status code: 422. Message: API Error: Unprocessable Entity', + }); + }); + + it('should return a failure when attempting to get webhooks', async () => { + const { body } = await supertest + .post(`/api/actions/connector/${tinesActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + subAction: 'webhooks', + subActionParams: { storyId: 1 }, + }, + }) + .expect(200); + + expect(body).to.eql({ + status: 'error', + message: 'an error occurred while running the action', + retry: true, + connector_id: tinesActionId, + service_message: 'Status code: 422. Message: API Error: Unprocessable Entity', + }); + }); + + it('should return a failure when attempting to run', async () => { + const { body } = await supertest + .post(`/api/actions/connector/${tinesActionId}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { subAction: 'run', subActionParams: { body: '["test"]', webhook } }, + }) + .expect(200); + + expect(simulator.requestData).to.eql(['test']); + expect(body).to.eql({ + status: 'error', + message: 'an error occurred while running the action', + retry: true, + connector_id: tinesActionId, + service_message: 'Status code: 422. Message: API Error: Unprocessable Entity', + }); + }); + }); + }); + }); + }); +} + +const createTinesUrlString = ( + baseUrl: string, + path: string, + queryParams?: Record +) => { + const fullURL = new URL(path, baseUrl); + for (const [key, value] of Object.entries(queryParams ?? {})) { + fullURL.searchParams.set(key, value); + } + return fullURL.toString(); +}; + +const getTinesStoriesUrl = (baseUrl: string, queryParams: Record) => + createTinesUrlString(baseUrl, 'api/v1/stories', queryParams); +const getTinesAgentsUrl = (baseUrl: string, queryParams: Record) => + createTinesUrlString(baseUrl, 'api/v1/agents', queryParams); +const getTinesWebhookPostUrl = (baseUrl: string, { path, secret }: typeof webhook) => + createTinesUrlString(baseUrl, `webhook/${path}/${secret}`); diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/index.ts index 34fd7d16b26ff..6f1f5b5e2e818 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/index.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/index.ts @@ -35,6 +35,7 @@ export default function connectorsTests({ loadTestFile, getService }: FtrProvide loadTestFile(require.resolve('./connector_types/stack/slack')); loadTestFile(require.resolve('./connector_types/stack/webhook')); loadTestFile(require.resolve('./connector_types/stack/xmatters')); + loadTestFile(require.resolve('./connector_types/security/tines')); loadTestFile(require.resolve('./create')); loadTestFile(require.resolve('./delete')); loadTestFile(require.resolve('./execute')); diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/check_registered_connector_types.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/check_registered_connector_types.ts index 158f6067a53a1..3de2f5165d03e 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/actions/check_registered_connector_types.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/actions/check_registered_connector_types.ts @@ -23,27 +23,30 @@ export default function createRegisteredConnectorTypeTests({ getService }: FtrPr .then((response) => response.body); expect( - registeredConnectorTypes.filter( - (connectorType: string) => !connectorType.startsWith('test.') - ) - ).to.eql([ - '.email', - '.index', - '.pagerduty', - '.swimlane', - '.server-log', - '.slack', - '.webhook', - '.cases-webhook', - '.xmatters', - '.servicenow', - '.servicenow-sir', - '.servicenow-itom', - '.jira', - '.resilient', - '.teams', - '.opsgenie', - ]); + registeredConnectorTypes + .filter((connectorType: string) => !connectorType.startsWith('test.')) + .sort() + ).to.eql( + [ + '.email', + '.index', + '.pagerduty', + '.swimlane', + '.server-log', + '.slack', + '.webhook', + '.cases-webhook', + '.xmatters', + '.servicenow', + '.servicenow-sir', + '.servicenow-itom', + '.jira', + '.resilient', + '.teams', + '.tines', + '.opsgenie', + ].sort() + ); }); }); } diff --git a/x-pack/test/functional/services/actions/index.ts b/x-pack/test/functional/services/actions/index.ts index 86388ed8e76f0..852a74d1d544d 100644 --- a/x-pack/test/functional/services/actions/index.ts +++ b/x-pack/test/functional/services/actions/index.ts @@ -8,12 +8,14 @@ import { FtrProviderContext } from '../../ftr_provider_context'; import { ActionsCommonServiceProvider } from './common'; import { ActionsOpsgenieServiceProvider } from './opsgenie'; +import { ActionsTinesServiceProvider } from './tines'; export function ActionsServiceProvider(context: FtrProviderContext) { const common = ActionsCommonServiceProvider(context); return { - opsgenie: ActionsOpsgenieServiceProvider(context, common), common: ActionsCommonServiceProvider(context), + opsgenie: ActionsOpsgenieServiceProvider(context, common), + tines: ActionsTinesServiceProvider(context, common), }; } diff --git a/x-pack/test/functional/services/actions/tines.ts b/x-pack/test/functional/services/actions/tines.ts new file mode 100644 index 0000000000000..183506752c22d --- /dev/null +++ b/x-pack/test/functional/services/actions/tines.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; +import type { ActionsCommon } from './common'; + +export interface ConnectorFormFields { + name: string; + url: string; + email: string; + token: string; +} + +export function ActionsTinesServiceProvider( + { getService, getPageObject }: FtrProviderContext, + common: ActionsCommon +) { + const testSubjects = getService('testSubjects'); + const find = getService('find'); + + return { + async createNewConnector(fields: ConnectorFormFields) { + await common.openNewConnectorForm('tines'); + await this.setConnectorFields(fields); + + const flyOutSaveButton = await testSubjects.find('create-connector-flyout-save-btn'); + expect(await flyOutSaveButton.isEnabled()).to.be(true); + await flyOutSaveButton.click(); + }, + + async setConnectorFields({ name, url, email, token }: ConnectorFormFields) { + await testSubjects.setValue('nameInput', name); + await testSubjects.setValue('config.url-input', url); + await testSubjects.setValue('secrets.email-input', email); + await testSubjects.setValue('secrets.token-input', token); + }, + + async updateConnectorFields(fields: ConnectorFormFields) { + await this.setConnectorFields(fields); + + const editFlyOutSaveButton = await testSubjects.find('edit-connector-flyout-save-btn'); + expect(await editFlyOutSaveButton.isEnabled()).to.be(true); + await editFlyOutSaveButton.click(); + }, + + async setJsonEditor(value: object) { + const stringified = JSON.stringify(value); + + await find.clickByCssSelector('.monaco-editor'); + const input = await find.activeElement(); + await input.clearValueWithKeyboard({ charByChar: true }); + await input.type(stringified); + }, + }; +} diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/connectors/index.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/connectors/index.ts index bda1247767b74..1d6420004c0cd 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/connectors/index.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/connectors/index.ts @@ -11,5 +11,6 @@ export default ({ loadTestFile }: FtrProviderContext) => { describe('Connectors', function () { loadTestFile(require.resolve('./general')); loadTestFile(require.resolve('./opsgenie')); + loadTestFile(require.resolve('./tines')); }); }; diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/connectors/tines.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/connectors/tines.ts new file mode 100644 index 0000000000000..5335b24a725b9 --- /dev/null +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/connectors/tines.ts @@ -0,0 +1,279 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../ftr_provider_context'; +import { ObjectRemover } from '../../../lib/object_remover'; +import { generateUniqueKey } from '../../../lib/get_test_data'; +import { createConnector, getConnectorByName } from './utils'; +import { + tinesAgentWebhook, + tinesStory1, +} from '../../../../alerting_api_integration/common/fixtures/plugins/actions_simulators/server/tines_simulation'; +import { + ExternalServiceSimulator, + getExternalServiceSimulatorPath, +} from '../../../../alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin'; + +export default ({ getPageObjects, getService }: FtrProviderContext) => { + const testSubjects = getService('testSubjects'); + const pageObjects = getPageObjects(['common', 'triggersActionsUI', 'header']); + const find = getService('find'); + const retry = getService('retry'); + const supertest = getService('supertest'); + const kibanaServer = getService('kibanaServer'); + const actions = getService('actions'); + const browser = getService('browser'); + const comboBox = getService('comboBox'); + let objectRemover: ObjectRemover; + let simulatorUrl: string; + + // isEnabled helper uses "disabled" attribute, testSubjects.isEnabled() gives inconsistent results for comboBoxes. + const isEnabled = async (selector: string) => + testSubjects.getAttribute(selector, 'disabled').then((disabled) => disabled !== 'true'); + + describe('Tines', () => { + before(async () => { + objectRemover = new ObjectRemover(supertest); + simulatorUrl = kibanaServer.resolveUrl( + getExternalServiceSimulatorPath(ExternalServiceSimulator.TINES) + ); + }); + + after(async () => { + await objectRemover.removeAll(); + }); + + describe('connector page', () => { + beforeEach(async () => { + await pageObjects.common.navigateToApp('triggersActionsConnectors'); + }); + + it('should create the connector', async () => { + const connectorName = generateUniqueKey(); + + await actions.tines.createNewConnector({ + name: connectorName, + url: 'https://test.tines.com', + email: 'test@foo.com', + token: 'apiToken', + }); + + const toastTitle = await pageObjects.common.closeToast(); + expect(toastTitle).to.eql(`Created '${connectorName}'`); + + await pageObjects.triggersActionsUI.searchConnectors(connectorName); + + const searchResults = await pageObjects.triggersActionsUI.getConnectorsList(); + expect(searchResults).to.eql([ + { + name: connectorName, + actionType: 'Tines', + }, + ]); + const connector = await getConnectorByName(connectorName, supertest); + objectRemover.add(connector.id, 'action', 'actions'); + }); + + it('should edit the connector', async () => { + const connectorName = generateUniqueKey(); + const updatedConnectorName = `${connectorName}updated`; + const createdAction = await createTinesConnector(connectorName); + objectRemover.add(createdAction.id, 'action', 'actions'); + browser.refresh(); + + await pageObjects.triggersActionsUI.searchConnectors(connectorName); + + const searchResultsBeforeEdit = await pageObjects.triggersActionsUI.getConnectorsList(); + expect(searchResultsBeforeEdit.length).to.eql(1); + + await find.clickByCssSelector('[data-test-subj="connectorsTableCell-name"] button'); + await actions.tines.updateConnectorFields({ + name: updatedConnectorName, + url: 'https://test.tines.com', + email: 'test@foo.com', + token: 'apiToken', + }); + + const toastTitle = await pageObjects.common.closeToast(); + expect(toastTitle).to.eql(`Updated '${updatedConnectorName}'`); + + await testSubjects.click('euiFlyoutCloseButton'); + await pageObjects.triggersActionsUI.searchConnectors(updatedConnectorName); + + const searchResultsAfterEdit = await pageObjects.triggersActionsUI.getConnectorsList(); + expect(searchResultsAfterEdit).to.eql([ + { + name: updatedConnectorName, + actionType: 'Tines', + }, + ]); + }); + + it('should reset connector when canceling an edit', async () => { + const connectorName = generateUniqueKey(); + const createdAction = await createTinesConnector(connectorName); + objectRemover.add(createdAction.id, 'action', 'actions'); + browser.refresh(); + + await pageObjects.triggersActionsUI.searchConnectors(connectorName); + + const searchResultsBeforeEdit = await pageObjects.triggersActionsUI.getConnectorsList(); + expect(searchResultsBeforeEdit.length).to.eql(1); + + await find.clickByCssSelector('[data-test-subj="connectorsTableCell-name"] button'); + + await testSubjects.setValue('nameInput', 'some test name to cancel'); + await testSubjects.click('edit-connector-flyout-close-btn'); + await testSubjects.click('confirmModalConfirmButton'); + + await find.waitForDeletedByCssSelector( + '[data-test-subj="edit-connector-flyout-close-btn"]' + ); + + await pageObjects.triggersActionsUI.searchConnectors(connectorName); + + await find.clickByCssSelector('[data-test-subj="connectorsTableCell-name"] button'); + expect(await testSubjects.getAttribute('nameInput', 'value')).to.eql(connectorName); + await testSubjects.click('euiFlyoutCloseButton'); + }); + + it('should disable the run button when the fields are not filled', async () => { + const connectorName = generateUniqueKey(); + const createdAction = await createTinesConnector(connectorName); + objectRemover.add(createdAction.id, 'action', 'actions'); + browser.refresh(); + + await pageObjects.triggersActionsUI.searchConnectors(connectorName); + + const searchResultsBeforeEdit = await pageObjects.triggersActionsUI.getConnectorsList(); + expect(searchResultsBeforeEdit.length).to.eql(1); + + await find.clickByCssSelector('[data-test-subj="connectorsTableCell-name"] button'); + + await find.clickByCssSelector('[data-test-subj="testConnectorTab"]'); + + expect(await isEnabled('executeActionButton')).to.be(false); + }); + + describe('test page', () => { + let connectorId = ''; + + before(async () => { + const connectorName = generateUniqueKey(); + const createdAction = await createTinesConnector(connectorName); + connectorId = createdAction.id; + objectRemover.add(createdAction.id, 'action', 'actions'); + }); + + beforeEach(async () => { + await testSubjects.click(`edit${connectorId}`); + await testSubjects.click('testConnectorTab'); + }); + + afterEach(async () => { + await actions.common.cancelConnectorForm(); + }); + + it('should show the selectors and json editor when in test mode', async () => { + await testSubjects.existOrFail('tines-storySelector'); + await testSubjects.existOrFail('tines-webhookSelector'); + await find.existsByCssSelector('.monaco-editor'); + }); + + it('should enable story selector when it is loaded', async () => { + await retry.waitFor('stories to load values', async () => + isEnabled('tines-storySelector') + ); + expect(await isEnabled('tines-storySelector')).to.be(true); + expect(await isEnabled('tines-webhookSelector')).to.be(false); + }); + + it('should enable webhook selector when story selected', async () => { + await retry.waitFor('stories to load values', async () => + isEnabled('tines-storySelector') + ); + await comboBox.set('tines-storySelector', tinesStory1.name); + + await retry.waitFor('webhooks to load values', async () => + isEnabled('tines-webhookSelector') + ); + }); + + it('should reset webhook selector when selected story changed', async () => { + await retry.waitFor('stories to load values', async () => + isEnabled('tines-storySelector') + ); + await comboBox.set('tines-storySelector', tinesStory1.name); + + await retry.waitFor('webhooks to load values', async () => + isEnabled('tines-webhookSelector') + ); + await comboBox.set('tines-webhookSelector', tinesAgentWebhook.name); + + expect(await comboBox.getComboBoxSelectedOptions('tines-webhookSelector')).to.contain( + tinesAgentWebhook.name + ); + + await comboBox.clear('tines-storySelector'); + + await retry.waitFor('webhooks to be disabled', async () => + isEnabled('tines-webhookSelector').then((enabled) => !enabled) + ); + expect(await comboBox.getComboBoxSelectedOptions('tines-webhookSelector')).to.be.empty(); + }); + + it('should have run button disabled if selectors have value but JSON missing', async () => { + await retry.waitFor('stories to load values', async () => + isEnabled('tines-storySelector') + ); + await comboBox.set('tines-storySelector', tinesStory1.name); + + await retry.waitFor('webhooks to load values', async () => + isEnabled('tines-webhookSelector') + ); + await comboBox.set('tines-webhookSelector', tinesAgentWebhook.name); + + expect(await isEnabled('executeActionButton')).to.be(false); + }); + + it('should run successfully if selectors and JSON have value', async () => { + await retry.waitFor('stories to load values', async () => + isEnabled('tines-storySelector') + ); + await comboBox.set('tines-storySelector', tinesStory1.name); + + await retry.waitFor('webhooks to load values', async () => + testSubjects.isEnabled('tines-webhookSelector') + ); + await comboBox.set('tines-webhookSelector', tinesAgentWebhook.name); + + await actions.tines.setJsonEditor({ + hello: 'tines', + }); + + expect(await isEnabled('executeActionButton')).to.be(true); + await testSubjects.click('executeActionButton'); + + await retry.waitFor('success message', async () => + testSubjects.exists('executionSuccessfulResult') + ); + }); + }); + }); + + const createTinesConnector = async (name: string) => { + return createConnector({ + name, + config: { url: simulatorUrl }, + secrets: { email: 'test@foo.com', token: 'apiToken' }, + connectorTypeId: '.tines', + supertest, + }); + }; + }); +}; diff --git a/x-pack/test/functional_with_es_ssl/config.ts b/x-pack/test/functional_with_es_ssl/config.ts index 50641faa1e91e..6ca556876d0e7 100644 --- a/x-pack/test/functional_with_es_ssl/config.ts +++ b/x-pack/test/functional_with_es_ssl/config.ts @@ -10,6 +10,7 @@ import { resolve, join } from 'path'; import { CA_CERT_PATH } from '@kbn/dev-utils'; import { FtrConfigProviderContext } from '@kbn/test'; import { pageObjects } from './page_objects'; +import { getAllExternalServiceSimulatorPaths } from '../alerting_api_integration/common/fixtures/plugins/actions_simulators/server/plugin'; // .server-log is specifically not enabled const enabledActionTypes = [ @@ -23,6 +24,7 @@ const enabledActionTypes = [ '.servicenow', '.servicenow-sir', '.slack', + '.tines', '.webhook', 'test.authorization', 'test.failing', @@ -117,6 +119,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { }, }, })}`, + `--server.xsrf.allowlist=${JSON.stringify(getAllExternalServiceSimulatorPaths())}`, ], }, security: { diff --git a/x-pack/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts b/x-pack/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts index 1bdfda2f63863..91295ad7ceaf6 100644 --- a/x-pack/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts +++ b/x-pack/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts @@ -59,6 +59,7 @@ export default function ({ getService }: FtrProviderContext) { 'actions:.slack', 'actions:.swimlane', 'actions:.teams', + 'actions:.tines', 'actions:.webhook', 'actions:.xmatters', 'actions_telemetry',