diff --git a/x-pack/plugins/actions/server/integration_tests/__snapshots__/connector_types.test.ts.snap b/x-pack/plugins/actions/server/integration_tests/__snapshots__/connector_types.test.ts.snap index ffe337fdcde4d7..e16eb6d24a12e8 100644 --- a/x-pack/plugins/actions/server/integration_tests/__snapshots__/connector_types.test.ts.snap +++ b/x-pack/plugins/actions/server/integration_tests/__snapshots__/connector_types.test.ts.snap @@ -6077,111 +6077,72 @@ Object { "presence": "optional", }, "keys": Object { - "apiUrl": Object { - "flags": Object { - "error": [Function], - }, - "rules": Array [ - Object { - "args": Object { - "method": [Function], - }, - "name": "custom", - }, - ], - "type": "string", - }, - "orgId": Object { - "flags": Object { - "error": [Function], - }, - "rules": Array [ - Object { - "args": Object { - "method": [Function], - }, - "name": "custom", - }, - ], - "type": "string", - }, - }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, - "type": "object", -} -`; - -exports[`Connector type config checks detect connector type changes for: .resilient 2`] = ` -Object { - "flags": Object { - "default": Object { - "special": "deep", - }, - "error": [Function], - "presence": "optional", - }, - "keys": Object { - "apiKeyId": Object { + "comments": Object { "flags": Object { + "default": null, "error": [Function], + "presence": "optional", }, - "rules": Array [ + "matches": Array [ Object { - "args": Object { - "method": [Function], + "schema": Object { + "flags": Object { + "error": [Function], + }, + "items": Array [ + Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "comment": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + "commentId": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + ], + "type": "array", }, - "name": "custom", }, - ], - "type": "string", - }, - "apiKeySecret": Object { - "flags": Object { - "error": [Function], - }, - "rules": Array [ Object { - "args": Object { - "method": [Function], - }, - "name": "custom", - }, - ], - "type": "string", - }, - }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, - "type": "object", -} -`; - -exports[`Connector type config checks detect connector type changes for: .resilient 3`] = ` -Object { - "flags": Object { - "error": [Function], - }, - "matches": Array [ - Object { - "schema": Object { - "flags": Object { - "default": Object { - "special": "deep", - }, - "error": [Function], - "presence": "optional", - }, - "keys": Object { - "subAction": Object { + "schema": Object { "allow": Array [ - "getFields", + null, ], "flags": Object { "error": [Function], @@ -6189,61 +6150,28 @@ Object { }, "type": "any", }, - "subActionParams": Object { - "flags": Object { - "default": Object { - "special": "deep", - }, - "error": [Function], - "presence": "optional", - }, - "keys": Object {}, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, - "type": "object", - }, - }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, }, - "type": "object", - }, + ], + "type": "alternatives", }, - Object { - "schema": Object { - "flags": Object { - "default": Object { - "special": "deep", - }, - "error": [Function], - "presence": "optional", + "incident": Object { + "flags": Object { + "default": Object { + "special": "deep", }, - "keys": Object { - "subAction": Object { - "allow": Array [ - "getIncident", - ], - "flags": Object { - "error": [Function], - "only": true, - }, - "type": "any", + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "description": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", }, - "subActionParams": Object { - "flags": Object { - "default": Object { - "special": "deep", - }, - "error": [Function], - "presence": "optional", - }, - "keys": Object { - "externalId": Object { + "matches": Array [ + Object { + "schema": Object { "flags": Object { "error": [Function], }, @@ -6258,458 +6186,361 @@ Object { "type": "string", }, }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", }, }, - "type": "object", - }, - }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, + ], + "type": "alternatives", }, - "type": "object", - }, - }, - Object { - "schema": Object { - "flags": Object { - "default": Object { - "special": "deep", + "externalId": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", }, - "error": [Function], - "presence": "optional", - }, - "keys": Object { - "subAction": Object { - "allow": Array [ - "handshake", - ], - "flags": Object { - "error": [Function], - "only": true, - }, - "type": "any", - }, - "subActionParams": Object { - "flags": Object { - "default": Object { - "special": "deep", + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", }, - "error": [Function], - "presence": "optional", }, - "keys": Object {}, - "preferences": Object { - "stripUnknown": Object { - "objects": false, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", }, }, - "type": "object", - }, - }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, - "type": "object", - }, - }, - Object { - "schema": Object { - "flags": Object { - "default": Object { - "special": "deep", - }, - "error": [Function], - "presence": "optional", + ], + "type": "alternatives", }, - "keys": Object { - "subAction": Object { - "allow": Array [ - "pushToService", - ], - "flags": Object { - "error": [Function], - "only": true, - }, - "type": "any", + "incidentTypes": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", }, - "subActionParams": Object { - "flags": Object { - "default": Object { - "special": "deep", - }, - "error": [Function], - "presence": "optional", - }, - "keys": Object { - "comments": Object { + "matches": Array [ + Object { + "schema": Object { "flags": Object { - "default": null, "error": [Function], - "presence": "optional", }, - "matches": Array [ - Object { - "schema": Object { - "flags": Object { - "error": [Function], - }, - "items": Array [ - Object { - "flags": Object { - "default": Object { - "special": "deep", - }, - "error": [Function], - "presence": "optional", - }, - "keys": Object { - "comment": Object { - "flags": Object { - "error": [Function], - }, - "rules": Array [ - Object { - "args": Object { - "method": [Function], - }, - "name": "custom", - }, - ], - "type": "string", - }, - "commentId": Object { - "flags": Object { - "error": [Function], - }, - "rules": Array [ - Object { - "args": Object { - "method": [Function], - }, - "name": "custom", - }, - ], - "type": "string", - }, - }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, - "type": "object", - }, - ], - "type": "array", - }, - }, + "items": Array [ Object { - "schema": Object { - "allow": Array [ - null, - ], - "flags": Object { - "error": [Function], - "only": true, - }, - "type": "any", + "flags": Object { + "error": [Function], + "presence": "optional", }, + "type": "number", }, ], - "type": "alternatives", + "type": "array", }, - "incident": Object { + }, + Object { + "schema": Object { + "allow": Array [ + null, + ], "flags": Object { - "default": Object { - "special": "deep", - }, "error": [Function], - "presence": "optional", + "only": true, }, - "keys": Object { - "description": Object { - "flags": Object { - "default": null, - "error": [Function], - "presence": "optional", - }, - "matches": Array [ - Object { - "schema": Object { - "flags": Object { - "error": [Function], - }, - "rules": Array [ - Object { - "args": Object { - "method": [Function], - }, - "name": "custom", - }, - ], - "type": "string", - }, - }, - Object { - "schema": Object { - "allow": Array [ - null, - ], - "flags": Object { - "error": [Function], - "only": true, - }, - "type": "any", - }, - }, - ], - "type": "alternatives", - }, - "externalId": Object { - "flags": Object { - "default": null, - "error": [Function], - "presence": "optional", - }, - "matches": Array [ - Object { - "schema": Object { - "flags": Object { - "error": [Function], - }, - "rules": Array [ - Object { - "args": Object { - "method": [Function], - }, - "name": "custom", - }, - ], - "type": "string", - }, - }, - Object { - "schema": Object { - "allow": Array [ - null, - ], - "flags": Object { - "error": [Function], - "only": true, - }, - "type": "any", - }, - }, - ], - "type": "alternatives", - }, - "incidentTypes": Object { - "flags": Object { - "default": null, - "error": [Function], - "presence": "optional", - }, - "matches": Array [ - Object { - "schema": Object { - "flags": Object { - "error": [Function], - }, - "items": Array [ - Object { - "flags": Object { - "error": [Function], - "presence": "optional", - }, - "type": "number", - }, - ], - "type": "array", - }, - }, - Object { - "schema": Object { - "allow": Array [ - null, - ], - "flags": Object { - "error": [Function], - "only": true, - }, - "type": "any", - }, - }, - ], - "type": "alternatives", - }, - "name": Object { - "flags": Object { - "error": [Function], - }, - "rules": Array [ - Object { - "args": Object { - "method": [Function], - }, - "name": "custom", - }, - ], - "type": "string", - }, - "severityCode": Object { - "flags": Object { - "default": null, - "error": [Function], - "presence": "optional", - }, - "matches": Array [ - Object { - "schema": Object { - "flags": Object { - "error": [Function], - }, - "type": "number", - }, - }, - Object { - "schema": Object { - "allow": Array [ - null, - ], - "flags": Object { - "error": [Function], - "only": true, - }, - "type": "any", - }, - }, - ], - "type": "alternatives", - }, - }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, - "type": "object", - }, - }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, + "type": "any", }, }, - "type": "object", - }, - }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, + ], + "type": "alternatives", }, - "type": "object", - }, - }, - Object { - "schema": Object { - "flags": Object { - "default": Object { - "special": "deep", + "name": Object { + "flags": Object { + "error": [Function], }, - "error": [Function], - "presence": "optional", - }, - "keys": Object { - "subAction": Object { - "allow": Array [ - "incidentTypes", - ], - "flags": Object { - "error": [Function], - "only": true, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", }, - "type": "any", + ], + "type": "string", + }, + "severityCode": Object { + "flags": Object { + "default": null, + "error": [Function], + "presence": "optional", }, - "subActionParams": Object { - "flags": Object { - "default": Object { - "special": "deep", + "matches": Array [ + Object { + "schema": Object { + "flags": Object { + "error": [Function], + }, + "type": "number", }, - "error": [Function], - "presence": "optional", }, - "keys": Object {}, - "preferences": Object { - "stripUnknown": Object { - "objects": false, + Object { + "schema": Object { + "allow": Array [ + null, + ], + "flags": Object { + "error": [Function], + "only": true, + }, + "type": "any", }, }, - "type": "object", - }, + ], + "type": "alternatives", }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, }, - "type": "object", }, + "type": "object", }, - Object { - "schema": Object { - "flags": Object { - "default": Object { - "special": "deep", + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .resilient 2`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object {}, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .resilient 3`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object {}, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .resilient 4`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object {}, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .resilient 5`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "apiUrl": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], }, - "error": [Function], - "presence": "optional", + "name": "custom", }, - "keys": Object { - "subAction": Object { - "allow": Array [ - "severity", - ], - "flags": Object { - "error": [Function], - "only": true, - }, - "type": "any", + ], + "type": "string", + }, + "orgId": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], }, - "subActionParams": Object { - "flags": Object { - "default": Object { - "special": "deep", - }, - "error": [Function], - "presence": "optional", - }, - "keys": Object {}, - "preferences": Object { - "stripUnknown": Object { - "objects": false, - }, - }, - "type": "object", + "name": "custom", + }, + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .resilient 6`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "apiKeyId": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], }, + "name": "custom", }, - "preferences": Object { - "stripUnknown": Object { - "objects": false, + ], + "type": "string", + }, + "apiKeySecret": Object { + "flags": Object { + "error": [Function], + }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], }, + "name": "custom", }, - "type": "object", + ], + "type": "string", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", +} +`; + +exports[`Connector type config checks detect connector type changes for: .resilient 7`] = ` +Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + }, + "keys": Object { + "subAction": Object { + "flags": Object { + "error": [Function], }, + "rules": Array [ + Object { + "args": Object { + "method": [Function], + }, + "name": "custom", + }, + ], + "type": "string", }, - ], - "type": "alternatives", + "subActionParams": Object { + "flags": Object { + "default": Object { + "special": "deep", + }, + "error": [Function], + "presence": "optional", + "unknown": true, + }, + "keys": Object {}, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", + }, + }, + "preferences": Object { + "stripUnknown": Object { + "objects": false, + }, + }, + "type": "object", } `; diff --git a/x-pack/plugins/actions/server/integration_tests/mocks/connector_types.ts b/x-pack/plugins/actions/server/integration_tests/mocks/connector_types.ts index 2886aa1babcef0..d90756d1bb5943 100644 --- a/x-pack/plugins/actions/server/integration_tests/mocks/connector_types.ts +++ b/x-pack/plugins/actions/server/integration_tests/mocks/connector_types.ts @@ -20,7 +20,6 @@ export const connectorTypes: string[] = [ '.servicenow-sir', '.servicenow-itom', '.jira', - '.resilient', '.teams', '.torq', '.opsgenie', @@ -28,6 +27,7 @@ export const connectorTypes: string[] = [ '.gen-ai', '.bedrock', '.d3security', + '.resilient', '.sentinelone', '.cases', '.observability-ai-assistant', diff --git a/x-pack/plugins/observability_solution/observability_onboarding/public/application/footer/footer.tsx b/x-pack/plugins/observability_solution/observability_onboarding/public/application/footer/footer.tsx index 7cb5979ad0b4b2..0f62a713487590 100644 --- a/x-pack/plugins/observability_solution/observability_onboarding/public/application/footer/footer.tsx +++ b/x-pack/plugins/observability_solution/observability_onboarding/public/application/footer/footer.tsx @@ -101,7 +101,7 @@ export const Footer: FunctionComponent = () => { ]; return ( - + {sections.map((section, index) => ( diff --git a/x-pack/plugins/observability_solution/observability_onboarding/public/application/header/header.tsx b/x-pack/plugins/observability_solution/observability_onboarding/public/application/header/header.tsx index a9e5ee915fb258..a8abbfc84884c5 100644 --- a/x-pack/plugins/observability_solution/observability_onboarding/public/application/header/header.tsx +++ b/x-pack/plugins/observability_solution/observability_onboarding/public/application/header/header.tsx @@ -6,53 +6,13 @@ */ import { i18n } from '@kbn/i18n'; +import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui'; import React from 'react'; -import { - EuiText, - EuiTitle, - EuiSpacer, - EuiTextColor, - EuiFlexGroup, - EuiFlexItem, - EuiSkeletonTitle, -} from '@elastic/eui'; -import { useKibana } from '@kbn/kibana-react-plugin/public'; -import useAsync from 'react-use/lib/useAsync'; -import { CoreStart } from '@kbn/core/public'; export const Header = () => { - const { services } = useKibana(); - - const currentUser = useAsync(services.security.authc.getCurrentUser); - return ( - - {currentUser.value && ( - - - - {i18n.translate( - 'xpack.observability_onboarding.experimentalOnboardingFlow.h1.hiJohnLabel', - { - defaultMessage: 'Hi {username}!', - values: { - username: currentUser.value.full_name ?? currentUser.value.username, - }, - } - )} - - - - )} - -

{i18n.translate( diff --git a/x-pack/plugins/observability_solution/observability_onboarding/public/application/onboarding_flow_form/onboarding_flow_form.tsx b/x-pack/plugins/observability_solution/observability_onboarding/public/application/onboarding_flow_form/onboarding_flow_form.tsx index dde4e5b1b108d1..8714917f21a321 100644 --- a/x-pack/plugins/observability_solution/observability_onboarding/public/application/onboarding_flow_form/onboarding_flow_form.tsx +++ b/x-pack/plugins/observability_solution/observability_onboarding/public/application/onboarding_flow_form/onboarding_flow_form.tsx @@ -39,12 +39,13 @@ export const OnboardingFlowForm: FunctionComponent = () => { id: 'logs', label: i18n.translate( 'xpack.observability_onboarding.experimentalOnboardingFlow.euiCheckableCard.collectAndAnalyzeMyLabel', - { defaultMessage: 'Collect and analyze my logs' } + { defaultMessage: 'Collect and analyze logs' } ), description: i18n.translate( 'xpack.observability_onboarding.onboardingFlowForm.detectPatternsAndOutliersLabel', { - defaultMessage: 'Detect patterns, troubleshoot in real time, gain insights from logs.', + defaultMessage: + 'Detect patterns, gain insights from logs, get alerted when surpassing error thresholds', } ), }, @@ -57,7 +58,8 @@ export const OnboardingFlowForm: FunctionComponent = () => { description: i18n.translate( 'xpack.observability_onboarding.onboardingFlowForm.captureAndAnalyzeDistributedLabel', { - defaultMessage: 'Collect distributed traces and catch application performance problems.', + defaultMessage: + 'Catch application problems, get alerted on performance issues or SLO breaches, expedite root cause analysis and remediation', } ), }, @@ -65,13 +67,13 @@ export const OnboardingFlowForm: FunctionComponent = () => { id: 'infra', label: i18n.translate( 'xpack.observability_onboarding.experimentalOnboardingFlow.euiCheckableCard.monitorMyInfrastructureLabel', - { defaultMessage: 'Monitor my infrastructure' } + { defaultMessage: 'Monitor infrastructure' } ), description: i18n.translate( 'xpack.observability_onboarding.onboardingFlowForm.builtOnPowerfulElasticsearchLabel', { defaultMessage: - 'Stream infrastructure metrics and accelerate root cause detection by breaking down silos.', + 'Check my system’s health, get alerted on performance issues or SLO breaches, expedite root cause analysis and remediation', } ), }, @@ -126,7 +128,7 @@ export const OnboardingFlowForm: FunctionComponent = () => { new Promise((r) => setTimeout(r, 10)).then(() => packageListRef.current?.scrollIntoView({ behavior: 'smooth', - block: 'center', + block: 'start', }) ); } @@ -140,7 +142,7 @@ export const OnboardingFlowForm: FunctionComponent = () => { ); return ( - + { )} /> - + {options.map((option) => ( @@ -168,7 +171,10 @@ export const OnboardingFlowForm: FunctionComponent = () => { } checked={option.id === searchParams.get('category')} - onChange={() => setSearchParams({ category: option.id }, { replace: true })} + onChange={() => { + setIntegrationSearch(''); + setSearchParams({ category: option.id }, { replace: true }); + }} /> ))} @@ -185,7 +191,7 @@ export const OnboardingFlowForm: FunctionComponent = () => { } )} /> - + {Array.isArray(customCards) && ( = ({ title, iconType }) => ( - + - + {title} diff --git a/x-pack/plugins/observability_solution/observability_onboarding/public/application/onboarding_flow_form/use_custom_cards_for_category.ts b/x-pack/plugins/observability_solution/observability_onboarding/public/application/onboarding_flow_form/use_custom_cards_for_category.ts index ccf974cea897b7..df6764e652b5a5 100644 --- a/x-pack/plugins/observability_solution/observability_onboarding/public/application/onboarding_flow_form/use_custom_cards_for_category.ts +++ b/x-pack/plugins/observability_solution/observability_onboarding/public/application/onboarding_flow_form/use_custom_cards_for_category.ts @@ -141,7 +141,7 @@ export function useCustomCardsForCategory( id: 'system-logs', type: 'virtual', title: 'Stream host system logs', - description: 'The quickest path to onboard log data from your own machine or server', + description: 'Collect system logs from your machine or server', name: 'system-logs-virtual', categories: ['observability'], icons: [ diff --git a/x-pack/plugins/observability_solution/observability_onboarding/public/application/packages_list/index.tsx b/x-pack/plugins/observability_solution/observability_onboarding/public/application/packages_list/index.tsx index 967cf1c5834e88..0e043d113423c9 100644 --- a/x-pack/plugins/observability_solution/observability_onboarding/public/application/packages_list/index.tsx +++ b/x-pack/plugins/observability_solution/observability_onboarding/public/application/packages_list/index.tsx @@ -62,7 +62,7 @@ const PackageListGridWrapper = ({ }: WrapperProps) => { const customMargin = useCustomMargin(); const { filteredCards, isLoading } = useAvailablePackages({ - prereleaseIntegrationsEnabled: false, + prereleaseIntegrationsEnabled: true, }); const list: IntegrationCardItem[] = useIntegrationCardList( diff --git a/x-pack/plugins/observability_solution/observability_onboarding/public/application/packages_list/utils.ts b/x-pack/plugins/observability_solution/observability_onboarding/public/application/packages_list/utils.ts index aa7927f5620d28..08f8e2654d52f4 100644 --- a/x-pack/plugins/observability_solution/observability_onboarding/public/application/packages_list/utils.ts +++ b/x-pack/plugins/observability_solution/observability_onboarding/public/application/packages_list/utils.ts @@ -7,11 +7,12 @@ import { IntegrationCardItem } from '@kbn/fleet-plugin/public'; -export const QUICKSTART_FLOWS = ['system-logs-virtual', 'custom-logs-virtual']; +export const QUICKSTART_FLOWS = ['system-logs-virtual']; export const toCustomCard = (card: IntegrationCardItem) => ({ ...card, isQuickstart: QUICKSTART_FLOWS.includes(card.name), + showCardLabels: false, }); export const isQuickstart = (cardName: string) => QUICKSTART_FLOWS.includes(cardName); diff --git a/x-pack/plugins/search_playground/public/components/message_list/assistant_message.tsx b/x-pack/plugins/search_playground/public/components/message_list/assistant_message.tsx index 736c08a6fc0e09..2d4cf7e0a9b246 100644 --- a/x-pack/plugins/search_playground/public/components/message_list/assistant_message.tsx +++ b/x-pack/plugins/search_playground/public/components/message_list/assistant_message.tsx @@ -20,6 +20,7 @@ import { import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; +import { css } from '@emotion/react'; import { RetrievalDocsFlyout } from './retrieval_docs_flyout'; import type { AIMessage as AIMessageType } from '../../types'; @@ -31,6 +32,10 @@ type AssistantMessageProps = Pick< 'content' | 'createdAt' | 'citations' | 'retrievalDocs' >; +const AIMessageCSS = css` + white-space: break-spaces; +`; + export const AssistantMessage: React.FC = ({ content, createdAt, @@ -121,7 +126,7 @@ export const AssistantMessage: React.FC = ({ - +

{content}

{!!citations?.length && ( diff --git a/x-pack/plugins/search_playground/public/components/message_list/user_message.tsx b/x-pack/plugins/search_playground/public/components/message_list/user_message.tsx index 73521bcdb493ea..b2ad7d6e1b647b 100644 --- a/x-pack/plugins/search_playground/public/components/message_list/user_message.tsx +++ b/x-pack/plugins/search_playground/public/components/message_list/user_message.tsx @@ -13,6 +13,7 @@ import { EuiComment, EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { UserAvatar } from '@kbn/user-profile-components'; +import { css } from '@emotion/react'; import { useUserProfile } from '../../hooks/use_user_profile'; import type { Message as MessageType } from '../../types'; @@ -20,6 +21,10 @@ import { CopyActionButton } from './copy_action_button'; type UserMessageProps = Pick; +const UserMessageCSS = css` + white-space: break-spaces; +`; + export const UserMessage: React.FC = ({ content, createdAt }) => { const currentUserProfile = useUserProfile(); @@ -53,7 +58,7 @@ export const UserMessage: React.FC = ({ content, createdAt }) /> } > - +

{content}

diff --git a/x-pack/plugins/security_solution/scripts/run_cypress/parallel.ts b/x-pack/plugins/security_solution/scripts/run_cypress/parallel.ts index 8522210a96478d..67cc7c743be114 100644 --- a/x-pack/plugins/security_solution/scripts/run_cypress/parallel.ts +++ b/x-pack/plugins/security_solution/scripts/run_cypress/parallel.ts @@ -343,8 +343,6 @@ ${JSON.stringify( fleetServer = await startFleetServer({ kbnClient, - // TODO TC: https://github.com/elastic/kibana/pull/180879 - there was an issue with 8.14.0, this should be removed when it's fixed - version: '8.13.0-SNAPSHOT', logger: log, port: fleetServerPort ?? config.has('servers.fleetserver.port') diff --git a/x-pack/plugins/serverless_observability/public/navigation_tree.ts b/x-pack/plugins/serverless_observability/public/navigation_tree.ts index a3421c65bda387..41efe7e4cadea9 100644 --- a/x-pack/plugins/serverless_observability/public/navigation_tree.ts +++ b/x-pack/plugins/serverless_observability/public/navigation_tree.ts @@ -216,7 +216,7 @@ export const navigationTree: NavigationTreeDefinition = { { type: 'navItem', title: i18n.translate('xpack.serverlessObservability.nav.getStarted', { - defaultMessage: 'Get started', + defaultMessage: 'Add data', }), link: 'observabilityOnboarding', icon: 'launch', 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 232a5034d8c210..992a76556272b2 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/index.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/index.ts @@ -9,11 +9,11 @@ import { PluginSetupContract as ActionsPluginSetupContract } from '@kbn/actions- import { getConnectorType as getCasesWebhookConnectorType } from './cases_webhook'; import { getConnectorType as getJiraConnectorType } from './jira'; -import { getConnectorType as getResilientConnectorType } from './resilient'; import { getServiceNowITSMConnectorType } from './servicenow_itsm'; import { getServiceNowSIRConnectorType } from './servicenow_sir'; import { getServiceNowITOMConnectorType } from './servicenow_itom'; import { getTinesConnectorType } from './tines'; +import { getResilientConnectorType } from './resilient'; import { getActionType as getTorqConnectorType } from './torq'; import { getConnectorType as getEmailConnectorType } from './email'; import { getConnectorType as getIndexConnectorType } from './es_index'; @@ -39,8 +39,6 @@ export { ConnectorTypeId as CasesWebhookConnectorTypeId } from './cases_webhook' export type { ActionParamsType as CasesWebhookActionParams } from './cases_webhook'; export { ConnectorTypeId as JiraConnectorTypeId } from './jira'; export type { ActionParamsType as JiraActionParams } from './jira'; -export { ConnectorTypeId as ResilientConnectorTypeId } from './resilient'; -export type { ActionParamsType as ResilientActionParams } from './resilient'; export { ServiceNowITSMConnectorTypeId } from './servicenow_itsm'; export { ServiceNowSIRConnectorTypeId } from './servicenow_sir'; export { ConnectorTypeId as EmailConnectorTypeId } from './email'; @@ -100,7 +98,6 @@ export function registerConnectorTypes({ actions.registerType(getServiceNowSIRConnectorType()); actions.registerType(getServiceNowITOMConnectorType()); actions.registerType(getJiraConnectorType()); - actions.registerType(getResilientConnectorType()); actions.registerType(getTeamsConnectorType()); actions.registerType(getTorqConnectorType()); @@ -109,6 +106,7 @@ export function registerConnectorTypes({ actions.registerSubActionConnectorType(getOpenAIConnectorType()); actions.registerSubActionConnectorType(getBedrockConnectorType()); actions.registerSubActionConnectorType(getD3SecurityConnectorType()); + actions.registerSubActionConnectorType(getResilientConnectorType()); if (experimentalFeatures.sentinelOneConnectorOn) { actions.registerSubActionConnectorType(getSentinelOneConnectorType()); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/resilient/api.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/resilient/api.test.ts deleted file mode 100644 index c92148b26319ac..00000000000000 --- a/x-pack/plugins/stack_connectors/server/connector_types/resilient/api.test.ts +++ /dev/null @@ -1,214 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { Logger } from '@kbn/core/server'; -import { api } from './api'; -import { externalServiceMock, apiParams } from './mocks'; -import { ExternalService } from './types'; - -let mockedLogger: jest.Mocked; - -describe('api', () => { - let externalService: jest.Mocked; - - beforeEach(() => { - externalService = externalServiceMock.create(); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - describe('pushToService', () => { - describe('create incident', () => { - test('it creates an incident', async () => { - const params = { ...apiParams, externalId: null }; - const res = await api.pushToService({ - externalService, - params, - logger: mockedLogger, - }); - - expect(res).toEqual({ - id: '1', - title: '1', - pushedDate: '2020-06-03T15:09:13.606Z', - url: 'https://resilient.elastic.co/#incidents/1', - comments: [ - { - commentId: 'case-comment-1', - pushedDate: '2020-06-03T15:09:13.606Z', - }, - { - commentId: 'case-comment-2', - pushedDate: '2020-06-03T15:09:13.606Z', - }, - ], - }); - }); - - test('it creates an incident without comments', async () => { - const params = { ...apiParams, externalId: null, comments: [] }; - const res = await api.pushToService({ - externalService, - params, - logger: mockedLogger, - }); - - expect(res).toEqual({ - id: '1', - title: '1', - pushedDate: '2020-06-03T15:09:13.606Z', - url: 'https://resilient.elastic.co/#incidents/1', - }); - }); - - test('it calls createIncident correctly', async () => { - const params = { ...apiParams, incident: { ...apiParams.incident, externalId: null } }; - await api.pushToService({ externalService, params, logger: mockedLogger }); - - expect(externalService.createIncident).toHaveBeenCalledWith({ - incident: { - incidentTypes: [1001], - severityCode: 6, - description: 'Incident description', - name: 'Incident title', - }, - }); - expect(externalService.updateIncident).not.toHaveBeenCalled(); - }); - - test('it calls createComment correctly', async () => { - const params = { ...apiParams, externalId: null }; - await api.pushToService({ externalService, params, logger: mockedLogger }); - expect(externalService.createComment).toHaveBeenCalledTimes(2); - expect(externalService.createComment).toHaveBeenNthCalledWith(1, { - incidentId: '1', - comment: { - commentId: 'case-comment-1', - comment: 'A comment', - }, - }); - - expect(externalService.createComment).toHaveBeenNthCalledWith(2, { - incidentId: '1', - comment: { - commentId: 'case-comment-2', - comment: 'Another comment', - }, - }); - }); - }); - - describe('update incident', () => { - test('it updates an incident', async () => { - const res = await api.pushToService({ - externalService, - params: apiParams, - logger: mockedLogger, - }); - - expect(res).toEqual({ - id: '1', - title: '1', - pushedDate: '2020-06-03T15:09:13.606Z', - url: 'https://resilient.elastic.co/#incidents/1', - comments: [ - { - commentId: 'case-comment-1', - pushedDate: '2020-06-03T15:09:13.606Z', - }, - { - commentId: 'case-comment-2', - pushedDate: '2020-06-03T15:09:13.606Z', - }, - ], - }); - }); - - test('it updates an incident without comments', async () => { - const params = { ...apiParams, comments: [] }; - const res = await api.pushToService({ - externalService, - params, - logger: mockedLogger, - }); - - expect(res).toEqual({ - id: '1', - title: '1', - pushedDate: '2020-06-03T15:09:13.606Z', - url: 'https://resilient.elastic.co/#incidents/1', - }); - }); - - test('it calls updateIncident correctly', async () => { - const params = { ...apiParams }; - await api.pushToService({ externalService, params, logger: mockedLogger }); - - expect(externalService.updateIncident).toHaveBeenCalledWith({ - incidentId: 'incident-3', - incident: { - incidentTypes: [1001], - severityCode: 6, - description: 'Incident description', - name: 'Incident title', - }, - }); - expect(externalService.createIncident).not.toHaveBeenCalled(); - }); - - test('it calls createComment correctly', async () => { - const params = { ...apiParams }; - await api.pushToService({ externalService, params, logger: mockedLogger }); - expect(externalService.createComment).toHaveBeenCalledTimes(2); - expect(externalService.createComment).toHaveBeenNthCalledWith(1, { - incidentId: '1', - comment: { - commentId: 'case-comment-1', - comment: 'A comment', - }, - }); - - expect(externalService.createComment).toHaveBeenNthCalledWith(2, { - incidentId: '1', - comment: { - commentId: 'case-comment-2', - comment: 'Another comment', - }, - }); - }); - }); - - describe('incidentTypes', () => { - test('it returns the incident types correctly', async () => { - const res = await api.incidentTypes({ - externalService, - params: {}, - }); - expect(res).toEqual([ - { id: 17, name: 'Communication error (fax; email)' }, - { id: 1001, name: 'Custom type' }, - ]); - }); - }); - - describe('severity', () => { - test('it returns the severity correctly', async () => { - const res = await api.severity({ - externalService, - params: { id: '10006' }, - }); - expect(res).toEqual([ - { id: 4, name: 'Low' }, - { id: 5, name: 'Medium' }, - { id: 6, name: 'High' }, - ]); - }); - }); - }); -}); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/resilient/api.ts b/x-pack/plugins/stack_connectors/server/connector_types/resilient/api.ts deleted file mode 100644 index da7147b21d0fdd..00000000000000 --- a/x-pack/plugins/stack_connectors/server/connector_types/resilient/api.ts +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { - PushToServiceApiHandlerArgs, - HandshakeApiHandlerArgs, - GetIncidentApiHandlerArgs, - ExternalServiceApi, - Incident, - GetIncidentTypesHandlerArgs, - GetSeverityHandlerArgs, - PushToServiceResponse, - GetCommonFieldsHandlerArgs, -} from './types'; - -const handshakeHandler = async ({ externalService, params }: HandshakeApiHandlerArgs) => {}; - -const getIncidentHandler = async ({ externalService, params }: GetIncidentApiHandlerArgs) => {}; - -const getFieldsHandler = async ({ externalService }: GetCommonFieldsHandlerArgs) => { - const res = await externalService.getFields(); - return res; -}; -const getIncidentTypesHandler = async ({ externalService }: GetIncidentTypesHandlerArgs) => { - const res = await externalService.getIncidentTypes(); - return res; -}; - -const getSeverityHandler = async ({ externalService }: GetSeverityHandlerArgs) => { - const res = await externalService.getSeverity(); - return res; -}; - -const pushToServiceHandler = async ({ - externalService, - params, -}: PushToServiceApiHandlerArgs): Promise => { - const { comments } = params; - let res: PushToServiceResponse; - const { externalId, ...rest } = params.incident; - const incident: Incident = rest; - - if (externalId != null) { - res = await externalService.updateIncident({ - incidentId: externalId, - incident, - }); - } else { - res = await externalService.createIncident({ - incident, - }); - } - - if (comments && Array.isArray(comments) && comments.length > 0) { - res.comments = []; - for (const currentComment of comments) { - const comment = await externalService.createComment({ - incidentId: res.id, - comment: currentComment, - }); - res.comments = [ - ...(res.comments ?? []), - { - commentId: comment.commentId, - pushedDate: comment.pushedDate, - }, - ]; - } - } - - return res; -}; - -export const api: ExternalServiceApi = { - getFields: getFieldsHandler, - getIncident: getIncidentHandler, - handshake: handshakeHandler, - incidentTypes: getIncidentTypesHandler, - pushToService: pushToServiceHandler, - severity: getSeverityHandler, -}; diff --git a/x-pack/plugins/stack_connectors/server/connector_types/resilient/constants.ts b/x-pack/plugins/stack_connectors/server/connector_types/resilient/constants.ts new file mode 100644 index 00000000000000..6558397027963a --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/resilient/constants.ts @@ -0,0 +1,14 @@ +/* + * 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 RESILIENT_CONNECTOR_ID = '.resilient'; + +export enum SUB_ACTION { + FIELDS = 'getFields', + SEVERITY = 'severity', + INCIDENT_TYPES = 'incidentTypes', +} diff --git a/x-pack/plugins/stack_connectors/server/connector_types/resilient/index.ts b/x-pack/plugins/stack_connectors/server/connector_types/resilient/index.ts index 7ef85b84bfb86f..141ee9e64ba8ac 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/resilient/index.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/resilient/index.ts @@ -5,144 +5,43 @@ * 2.0. */ -import { TypeOf } from '@kbn/config-schema'; - -import type { - ActionType as ConnectorType, - ActionTypeExecutorOptions as ConnectorTypeExecutorOptions, - ActionTypeExecutorResult as ConnectorTypeExecutorResult, -} from '@kbn/actions-plugin/server/types'; +import { + SubActionConnectorType, + ValidatorType, +} from '@kbn/actions-plugin/server/sub_action_framework/types'; import { AlertingConnectorFeatureId, CasesConnectorFeatureId, SecurityConnectorFeatureId, -} from '@kbn/actions-plugin/common/types'; -import { validate } from './validators'; +} from '@kbn/actions-plugin/common'; +import { urlAllowListValidator } from '@kbn/actions-plugin/server'; + +import { ResilientConfig, ResilientSecrets } from './types'; +import { RESILIENT_CONNECTOR_ID } from './constants'; +import * as i18n from './translations'; import { ExternalIncidentServiceConfigurationSchema, ExternalIncidentServiceSecretConfigurationSchema, - ExecutorParamsSchema, + PushToServiceIncidentSchema, } from './schema'; -import { createExternalService } from './service'; -import { api } from './api'; -import { - ExecutorParams, - ExecutorSubActionPushParams, - ResilientPublicConfigurationType, - ResilientSecretConfigurationType, - ResilientExecutorResultData, - ExecutorSubActionGetIncidentTypesParams, - ExecutorSubActionGetSeverityParams, - ExecutorSubActionCommonFieldsParams, -} from './types'; -import * as i18n from './translations'; - -export type ActionParamsType = TypeOf; - -const supportedSubActions: string[] = ['getFields', 'pushToService', 'incidentTypes', 'severity']; - -export const ConnectorTypeId = '.resilient'; -// connector type definition -export function getConnectorType(): ConnectorType< - ResilientPublicConfigurationType, - ResilientSecretConfigurationType, - ExecutorParams, - ResilientExecutorResultData | {} -> { - return { - id: ConnectorTypeId, - minimumLicenseRequired: 'platinum', - name: i18n.NAME, - supportedFeatureIds: [ - AlertingConnectorFeatureId, - CasesConnectorFeatureId, - SecurityConnectorFeatureId, - ], - validate: { - config: { - schema: ExternalIncidentServiceConfigurationSchema, - customValidator: validate.config, - }, - secrets: { - schema: ExternalIncidentServiceSecretConfigurationSchema, - customValidator: validate.secrets, - }, - params: { - schema: ExecutorParamsSchema, - }, - }, - executor, - }; -} - -// action executor -async function executor( - execOptions: ConnectorTypeExecutorOptions< - ResilientPublicConfigurationType, - ResilientSecretConfigurationType, - ExecutorParams - > -): Promise> { - const { actionId, config, params, secrets, configurationUtilities, logger } = execOptions; - const { subAction, subActionParams } = params as ExecutorParams; - let data: ResilientExecutorResultData | null = null; - - const externalService = createExternalService( - { - config, - secrets, - }, - logger, - configurationUtilities - ); - - if (!api[subAction]) { - const errorMessage = `[Action][ExternalService] Unsupported subAction type ${subAction}.`; - logger.error(errorMessage); - throw new Error(errorMessage); - } - - if (!supportedSubActions.includes(subAction)) { - const errorMessage = `[Action][ExternalService] subAction ${subAction} not implemented.`; - logger.error(errorMessage); - throw new Error(errorMessage); - } - - if (subAction === 'pushToService') { - const pushToServiceParams = subActionParams as ExecutorSubActionPushParams; - - data = await api.pushToService({ - externalService, - params: pushToServiceParams, - logger, - }); - - logger.debug(`response push to service for incident id: ${data.id}`); - } - - if (subAction === 'getFields') { - const getFieldsParams = subActionParams as ExecutorSubActionCommonFieldsParams; - data = await api.getFields({ - externalService, - params: getFieldsParams, - }); - } - - if (subAction === 'incidentTypes') { - const incidentTypesParams = subActionParams as ExecutorSubActionGetIncidentTypesParams; - data = await api.incidentTypes({ - externalService, - params: incidentTypesParams, - }); - } - - if (subAction === 'severity') { - const severityParams = subActionParams as ExecutorSubActionGetSeverityParams; - data = await api.severity({ - externalService, - params: severityParams, - }); - } - - return { status: 'ok', data: data ?? {}, actionId }; -} +import { ResilientConnector } from './resilient'; + +export const getResilientConnectorType = (): SubActionConnectorType< + ResilientConfig, + ResilientSecrets +> => ({ + id: RESILIENT_CONNECTOR_ID, + minimumLicenseRequired: 'platinum', + name: i18n.NAME, + getService: (params) => new ResilientConnector(params, PushToServiceIncidentSchema), + schema: { + config: ExternalIncidentServiceConfigurationSchema, + secrets: ExternalIncidentServiceSecretConfigurationSchema, + }, + supportedFeatureIds: [ + AlertingConnectorFeatureId, + CasesConnectorFeatureId, + SecurityConnectorFeatureId, + ], + validators: [{ type: ValidatorType.CONFIG, validator: urlAllowListValidator('apiUrl') }], +}); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/resilient/mocks.ts b/x-pack/plugins/stack_connectors/server/connector_types/resilient/mocks.ts index ce423df5f974a7..9ca94c700ef77f 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/resilient/mocks.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/resilient/mocks.ts @@ -5,394 +5,55 @@ * 2.0. */ -import { ExternalService, PushToServiceApiParams, ExecutorSubActionPushParams } from './types'; - export const resilientFields = [ { - id: 17, name: 'name', - text: 'Name', - prefix: null, - type_id: 0, - tooltip: 'A unique name to identify this particular incident.', + text: 'name', input_type: 'text', required: 'always', - hide_notification: false, - chosen: false, - default_chosen_by_server: false, - blank_option: false, - internal: true, - uuid: 'ad6ed4f2-8d87-4ba2-81fa-03568a9326cc', - operations: [ - 'equals', - 'not_equals', - 'contains', - 'not_contains', - 'changed', - 'changed_to', - 'not_changed_to', - 'has_a_value', - 'not_has_a_value', - ], - operation_perms: { - changed_to: { - show_in_manual_actions: false, - show_in_auto_actions: true, - show_in_notifications: true, - }, - has_a_value: { - show_in_manual_actions: true, - show_in_auto_actions: true, - show_in_notifications: true, - }, - not_changed_to: { - show_in_manual_actions: false, - show_in_auto_actions: true, - show_in_notifications: true, - }, - equals: { - show_in_manual_actions: true, - show_in_auto_actions: true, - show_in_notifications: true, - }, - changed: { - show_in_manual_actions: false, - show_in_auto_actions: true, - show_in_notifications: true, - }, - contains: { - show_in_manual_actions: true, - show_in_auto_actions: true, - show_in_notifications: true, - }, - not_contains: { - show_in_manual_actions: true, - show_in_auto_actions: true, - show_in_notifications: true, - }, - not_equals: { - show_in_manual_actions: true, - show_in_auto_actions: true, - show_in_notifications: true, - }, - not_has_a_value: { - show_in_manual_actions: true, - show_in_auto_actions: true, - show_in_notifications: true, - }, - }, - values: [], - perms: { - delete: false, - modify_name: false, - modify_values: false, - modify_blank: false, - modify_required: false, - modify_operations: false, - modify_chosen: false, - modify_default: false, - show_in_manual_actions: true, - show_in_auto_actions: true, - show_in_notifications: true, - show_in_scripts: true, - modify_type: ['text'], - sort: true, - }, read_only: false, - changeable: true, - rich_text: false, - templates: [], - deprecated: false, - tags: [], - calculated: false, - is_tracked: false, - allow_default_value: false, }, { - id: 15, name: 'description', text: 'Description', - prefix: null, - type_id: 0, - tooltip: 'A free form text description of the incident.', input_type: 'textarea', - hide_notification: false, - chosen: false, - default_chosen_by_server: false, - blank_option: false, - internal: true, - uuid: '420d70b1-98f9-4681-a20b-84f36a9e5e48', - operations: [ - 'equals', - 'not_equals', - 'contains', - 'not_contains', - 'changed', - 'changed_to', - 'not_changed_to', - 'has_a_value', - 'not_has_a_value', - ], - operation_perms: { - changed_to: { - show_in_manual_actions: false, - show_in_auto_actions: true, - show_in_notifications: true, - }, - has_a_value: { - show_in_manual_actions: true, - show_in_auto_actions: true, - show_in_notifications: true, - }, - not_changed_to: { - show_in_manual_actions: false, - show_in_auto_actions: true, - show_in_notifications: true, - }, - equals: { - show_in_manual_actions: true, - show_in_auto_actions: true, - show_in_notifications: true, - }, - changed: { - show_in_manual_actions: false, - show_in_auto_actions: true, - show_in_notifications: true, - }, - contains: { - show_in_manual_actions: true, - show_in_auto_actions: true, - show_in_notifications: true, - }, - not_contains: { - show_in_manual_actions: true, - show_in_auto_actions: true, - show_in_notifications: true, - }, - not_equals: { - show_in_manual_actions: true, - show_in_auto_actions: true, - show_in_notifications: true, - }, - not_has_a_value: { - show_in_manual_actions: true, - show_in_auto_actions: true, - show_in_notifications: true, - }, - }, - values: [], - perms: { - delete: false, - modify_name: false, - modify_values: false, - modify_blank: false, - modify_required: false, - modify_operations: false, - modify_chosen: false, - modify_default: false, - show_in_manual_actions: true, - show_in_auto_actions: true, - show_in_notifications: true, - show_in_scripts: true, - modify_type: ['textarea'], - sort: true, - }, read_only: false, - changeable: true, - rich_text: true, - templates: [], - deprecated: false, - tags: [], - calculated: false, - is_tracked: false, - allow_default_value: false, }, { - id: 65, name: 'create_date', text: 'Date Created', - prefix: null, - type_id: 0, - tooltip: 'The date the incident was created. This field is read-only.', input_type: 'datetimepicker', - hide_notification: false, - chosen: false, - default_chosen_by_server: false, - blank_option: false, - internal: true, - uuid: 'b4faf728-881a-4e8b-bf0b-d39b720392a1', - operations: ['due_within', 'overdue_by', 'has_a_value', 'not_has_a_value'], - operation_perms: { - has_a_value: { - show_in_manual_actions: true, - show_in_auto_actions: true, - show_in_notifications: true, - }, - not_has_a_value: { - show_in_manual_actions: true, - show_in_auto_actions: true, - show_in_notifications: true, - }, - due_within: { - show_in_manual_actions: true, - show_in_auto_actions: true, - show_in_notifications: true, - }, - overdue_by: { - show_in_manual_actions: true, - show_in_auto_actions: true, - show_in_notifications: true, - }, - }, - values: [], - perms: { - delete: false, - modify_name: false, - modify_values: false, - modify_blank: false, - modify_required: false, - modify_operations: false, - modify_chosen: false, - modify_default: false, - show_in_manual_actions: true, - show_in_auto_actions: true, - show_in_notifications: true, - show_in_scripts: true, - modify_type: ['datetimepicker'], - sort: true, - }, read_only: true, - changeable: false, - rich_text: false, - templates: [], - deprecated: false, - tags: [], - calculated: false, - is_tracked: false, - allow_default_value: false, }, ]; -const createMock = (): jest.Mocked => { - const service = { - getFields: jest.fn().mockImplementation(() => Promise.resolve(resilientFields)), - getIncident: jest.fn().mockImplementation(() => - Promise.resolve({ - id: '1', - name: 'title from ibm resilient', - description: 'description from ibm resilient', - discovered_date: 1589391874472, - create_date: 1591192608323, - inc_last_modified_date: 1591192650372, - }) - ), - createIncident: jest.fn().mockImplementation(() => - Promise.resolve({ - id: '1', - title: '1', - pushedDate: '2020-06-03T15:09:13.606Z', - url: 'https://resilient.elastic.co/#incidents/1', - }) - ), - updateIncident: jest.fn().mockImplementation(() => - Promise.resolve({ - id: '1', - title: '1', - pushedDate: '2020-06-03T15:09:13.606Z', - url: 'https://resilient.elastic.co/#incidents/1', - }) - ), - createComment: jest.fn(), - findIncidents: jest.fn(), - getIncidentTypes: jest.fn().mockImplementation(() => [ - { id: 17, name: 'Communication error (fax; email)' }, - { id: 1001, name: 'Custom type' }, - ]), - getSeverity: jest.fn().mockImplementation(() => [ - { - id: 4, - name: 'Low', - }, - { - id: 5, - name: 'Medium', - }, - { - id: 6, - name: 'High', - }, - ]), - }; - - service.createComment.mockImplementationOnce(() => - Promise.resolve({ - commentId: 'case-comment-1', - pushedDate: '2020-06-03T15:09:13.606Z', - externalCommentId: '1', - }) - ); - - service.createComment.mockImplementationOnce(() => - Promise.resolve({ - commentId: 'case-comment-2', - pushedDate: '2020-06-03T15:09:13.606Z', - externalCommentId: '2', - }) - ); - - return service; -}; - -const externalServiceMock = { - create: createMock, -}; - -const executorParams: ExecutorSubActionPushParams = { - incident: { - externalId: 'incident-3', - name: 'Incident title', - description: 'Incident description', - incidentTypes: [1001], - severityCode: 6, - }, - comments: [ +export const incidentTypes = { + id: 16, + name: 'incident_type_ids', + text: 'Incident Type', + values: [ { - commentId: 'case-comment-1', - comment: 'A comment', + value: 17, + label: 'Communication error (fax; email)', + enabled: true, + properties: null, + uuid: '4a8d22f7-d89e-4403-85c7-2bafe3b7f2ae', + hidden: false, + default: false, }, { - commentId: 'case-comment-2', - comment: 'Another comment', + value: 1001, + label: 'Custom type', + enabled: true, + properties: null, + uuid: '3b51c8c2-9758-48f8-b013-bd141f1d2ec9', + hidden: false, + default: false, }, ], }; -const apiParams: PushToServiceApiParams = { - ...executorParams, -}; - -const incidentTypes = [ - { - value: 17, - label: 'Communication error (fax; email)', - enabled: true, - properties: null, - uuid: '4a8d22f7-d89e-4403-85c7-2bafe3b7f2ae', - hidden: false, - default: false, - }, - { - value: 1001, - label: 'Custom type', - enabled: true, - properties: null, - uuid: '3b51c8c2-9758-48f8-b013-bd141f1d2ec9', - hidden: false, - default: false, - }, -]; - -const severity = [ +export const severity = [ { value: 4, label: 'Low', @@ -421,5 +82,3 @@ const severity = [ default: false, }, ]; - -export { externalServiceMock, executorParams, apiParams, incidentTypes, severity }; diff --git a/x-pack/plugins/stack_connectors/server/connector_types/resilient/resilient.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/resilient/resilient.test.ts new file mode 100644 index 00000000000000..4e031bdaafeea3 --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/resilient/resilient.test.ts @@ -0,0 +1,587 @@ +/* + * 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 { request, createAxiosResponse } from '@kbn/actions-plugin/server/lib/axios_utils'; +import { loggingSystemMock } from '@kbn/core/server/mocks'; +import { resilientFields, incidentTypes, severity } from './mocks'; +import { actionsConfigMock } from '@kbn/actions-plugin/server/actions_config.mock'; +import { ResilientConnector } from './resilient'; +import { actionsMock } from '@kbn/actions-plugin/server/mocks'; +import { RESILIENT_CONNECTOR_ID } from './constants'; +import { PushToServiceIncidentSchema } from './schema'; + +jest.mock('axios'); +jest.mock('@kbn/actions-plugin/server/lib/axios_utils', () => { + const originalUtils = jest.requireActual('@kbn/actions-plugin/server/lib/axios_utils'); + return { + ...originalUtils, + request: jest.fn(), + }; +}); + +const requestMock = request as jest.Mock; +const TIMESTAMP = 1589391874472; +const apiUrl = 'https://resilient.elastic.co/'; +const orgId = '201'; +const apiKeyId = 'keyId'; +const apiKeySecret = 'secret'; +const ignoredRequestFields = { + axios: undefined, + timeout: undefined, + configurationUtilities: expect.anything(), + logger: expect.anything(), +}; +const token = Buffer.from(apiKeyId + ':' + apiKeySecret, 'utf8').toString('base64'); +const mockIncidentUpdate = (withUpdateError = false) => { + requestMock.mockImplementationOnce(() => + createAxiosResponse({ + data: { + id: '1', + name: 'title', + description: { + format: 'html', + content: 'description', + }, + incident_type_ids: [1001, 16, 12], + severity_code: 6, + inc_last_modified_date: 1589391874472, + }, + }) + ); + + if (withUpdateError) { + requestMock.mockImplementationOnce(() => { + throw new Error('An error has occurred'); + }); + } else { + requestMock.mockImplementationOnce(() => + createAxiosResponse({ + data: { + success: true, + id: '1', + inc_last_modified_date: 1589391874472, + }, + }) + ); + } + + requestMock.mockImplementationOnce(() => + createAxiosResponse({ + data: { + id: '1', + name: 'title_updated', + description: { + format: 'html', + content: 'desc_updated', + }, + inc_last_modified_date: 1589391874472, + }, + }) + ); +}; + +describe('IBM Resilient connector', () => { + const connector = new ResilientConnector( + { + connector: { id: '1', type: RESILIENT_CONNECTOR_ID }, + configurationUtilities: actionsConfigMock.create(), + logger: loggingSystemMock.createLogger(), + services: actionsMock.createServices(), + config: { orgId, apiUrl }, + secrets: { apiKeyId, apiKeySecret }, + }, + PushToServiceIncidentSchema + ); + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + beforeEach(() => { + jest.resetAllMocks(); + jest.setSystemTime(TIMESTAMP); + }); + + describe('getIncident', () => { + const incidentMock = { + id: '1', + name: '1', + description: { + format: 'html', + content: 'description', + }, + inc_last_modified_date: TIMESTAMP, + }; + + beforeEach(() => { + requestMock.mockImplementation(() => + createAxiosResponse({ + data: incidentMock, + }) + ); + }); + + it('returns the incident correctly', async () => { + const res = await connector.getIncident({ id: '1' }); + expect(res).toEqual(incidentMock); + }); + + it('should call request with correct arguments', async () => { + await connector.getIncident({ id: '1' }); + expect(requestMock).toHaveBeenCalledWith({ + ...ignoredRequestFields, + method: 'GET', + data: {}, + url: `${apiUrl}rest/orgs/${orgId}/incidents/1`, + headers: { + Authorization: `Basic ${token}`, + 'Content-Type': 'application/json', + }, + params: { + text_content_output_format: 'objects_convert', + }, + }); + }); + + it('it should throw an error', async () => { + requestMock.mockImplementation(() => { + throw new Error('An error has occurred'); + }); + await expect(connector.getIncident({ id: '1' })).rejects.toThrow( + 'Unable to get incident with id 1. Error: An error has occurred' + ); + }); + }); + + describe('createIncident', () => { + const incidentMock = { + name: 'title', + description: 'desc', + incidentTypes: [1001], + severityCode: 6, + }; + + beforeEach(() => { + requestMock.mockImplementation(() => + createAxiosResponse({ + data: { + id: '1', + name: 'title', + description: 'description', + discovered_date: 1589391874472, + create_date: 1589391874472, + }, + }) + ); + }); + + it('creates the incident correctly', async () => { + const res = await connector.createIncident(incidentMock); + + expect(res).toEqual({ + title: '1', + id: '1', + pushedDate: '2020-05-13T17:44:34.472Z', + url: 'https://resilient.elastic.co/#incidents/1', + }); + }); + + it('should call request with correct arguments', async () => { + await connector.createIncident(incidentMock); + + expect(requestMock).toHaveBeenCalledWith({ + ...ignoredRequestFields, + method: 'POST', + data: { + name: 'title', + description: { + format: 'html', + content: 'desc', + }, + discovered_date: TIMESTAMP, + incident_type_ids: [{ id: 1001 }], + severity_code: { id: 6 }, + }, + url: `${apiUrl}rest/orgs/${orgId}/incidents?text_content_output_format=objects_convert`, + headers: { + Authorization: `Basic ${token}`, + 'Content-Type': 'application/json', + }, + }); + }); + + it('should throw an error', async () => { + requestMock.mockImplementation(() => { + throw new Error('An error has occurred'); + }); + + await expect( + connector.createIncident({ + name: 'title', + description: 'desc', + incidentTypes: [1001], + severityCode: 6, + }) + ).rejects.toThrow( + '[Action][IBM Resilient]: Unable to create incident. Error: An error has occurred' + ); + }); + + it('should throw if the required attributes are not received in response', async () => { + requestMock.mockImplementation(() => createAxiosResponse({ data: { notRequired: 'test' } })); + + await expect(connector.createIncident(incidentMock)).rejects.toThrow( + '[Action][IBM Resilient]: Unable to create incident. Error: Response validation failed (Error: [id]: expected value of type [number] but got [undefined]).' + ); + }); + }); + + describe('updateIncident', () => { + const req = { + incidentId: '1', + incident: { + name: 'title', + description: 'desc', + incidentTypes: [1001], + severityCode: 6, + }, + }; + it('updates the incident correctly', async () => { + mockIncidentUpdate(); + const res = await connector.updateIncident(req); + + expect(res).toEqual({ + title: '1', + id: '1', + pushedDate: '2020-05-13T17:44:34.472Z', + url: 'https://resilient.elastic.co/#incidents/1', + }); + }); + + it('should call request with correct arguments', async () => { + mockIncidentUpdate(); + + await connector.updateIncident({ + incidentId: '1', + incident: { + name: 'title_updated', + description: 'desc_updated', + incidentTypes: [1001], + severityCode: 5, + }, + }); + + expect(requestMock.mock.calls[1][0]).toEqual({ + ...ignoredRequestFields, + url: `${apiUrl}rest/orgs/${orgId}/incidents/1`, + headers: { + Authorization: `Basic ${token}`, + 'Content-Type': 'application/json', + }, + method: 'PATCH', + data: { + changes: [ + { + field: { name: 'name' }, + old_value: { text: 'title' }, + new_value: { text: 'title_updated' }, + }, + { + field: { name: 'description' }, + old_value: { + textarea: { + content: 'description', + format: 'html', + }, + }, + new_value: { + textarea: { + content: 'desc_updated', + format: 'html', + }, + }, + }, + { + field: { + name: 'incident_type_ids', + }, + old_value: { + ids: [1001, 16, 12], + }, + new_value: { + ids: [1001], + }, + }, + { + field: { + name: 'severity_code', + }, + old_value: { + id: 6, + }, + new_value: { + id: 5, + }, + }, + ], + }, + }); + }); + + it('it should throw an error', async () => { + mockIncidentUpdate(true); + + await expect(connector.updateIncident(req)).rejects.toThrow( + '[Action][IBM Resilient]: Unable to update incident with id 1. Error: An error has occurred' + ); + }); + + it('should throw if the required attributes are not received in response', async () => { + requestMock.mockImplementationOnce(() => + createAxiosResponse({ + data: { + id: '1', + name: 'title', + description: { + format: 'html', + content: 'description', + }, + incident_type_ids: [1001, 16, 12], + severity_code: 6, + inc_last_modified_date: 1589391874472, + }, + }) + ); + requestMock.mockImplementation(() => createAxiosResponse({ data: { notRequired: 'test' } })); + + await expect(connector.updateIncident(req)).rejects.toThrow( + '[Action][IBM Resilient]: Unable to update incident with id 1. Error: Response validation failed (Error: [success]: expected value of type [boolean] but got [undefined]).' + ); + }); + }); + + describe('createComment', () => { + const req = { + incidentId: '1', + comment: 'comment', + }; + + beforeEach(() => { + requestMock.mockImplementation(() => + createAxiosResponse({ + data: { + id: '1', + create_date: 1589391874472, + comment: { + id: '5', + }, + }, + }) + ); + }); + + it('should call request with correct arguments', async () => { + await connector.addComment(req); + + expect(requestMock).toHaveBeenCalledWith({ + ...ignoredRequestFields, + url: `${apiUrl}rest/orgs/${orgId}/incidents/1/comments`, + headers: { + Authorization: `Basic ${token}`, + 'Content-Type': 'application/json', + }, + method: 'POST', + data: { + text: { + content: 'comment', + format: 'text', + }, + }, + }); + }); + + it('it should throw an error', async () => { + requestMock.mockImplementation(() => { + throw new Error('An error has occurred'); + }); + + await expect(connector.addComment(req)).rejects.toThrow( + '[Action][IBM Resilient]: Unable to create comment at incident with id 1. Error: An error has occurred.' + ); + }); + }); + + describe('getIncidentTypes', () => { + beforeEach(() => { + requestMock.mockImplementation(() => + createAxiosResponse({ + data: incidentTypes, + }) + ); + }); + + it('should call request with correct arguments', async () => { + await connector.getIncidentTypes(); + expect(requestMock).toBeCalledTimes(1); + expect(requestMock).toHaveBeenCalledWith({ + ...ignoredRequestFields, + method: 'GET', + data: {}, + url: `${apiUrl}rest/orgs/${orgId}/types/incident/fields/incident_type_ids`, + headers: { + Authorization: `Basic ${token}`, + 'Content-Type': 'application/json', + }, + }); + }); + + it('returns incident types correctly', async () => { + const res = await connector.getIncidentTypes(); + + expect(res).toEqual([ + { id: '17', name: 'Communication error (fax; email)' }, + { id: '1001', name: 'Custom type' }, + ]); + }); + + it('should throw an error', async () => { + requestMock.mockImplementation(() => { + throw new Error('An error has occurred'); + }); + + await expect(connector.getIncidentTypes()).rejects.toThrow( + '[Action][IBM Resilient]: Unable to get incident types. Error: An error has occurred.' + ); + }); + + it('should throw if the required attributes are not received in response', async () => { + requestMock.mockImplementation(() => + createAxiosResponse({ data: { id: '1001', name: 'Custom type' } }) + ); + + await expect(connector.getIncidentTypes()).rejects.toThrow( + '[Action][IBM Resilient]: Unable to get incident types. Error: Response validation failed (Error: [values]: expected value of type [array] but got [undefined]).' + ); + }); + }); + + describe('getSeverity', () => { + beforeEach(() => { + requestMock.mockImplementation(() => + createAxiosResponse({ + data: { + values: severity, + }, + }) + ); + }); + + it('should call request with correct arguments', async () => { + await connector.getSeverity(); + expect(requestMock).toBeCalledTimes(1); + expect(requestMock).toHaveBeenCalledWith({ + ...ignoredRequestFields, + method: 'GET', + data: {}, + url: `${apiUrl}rest/orgs/${orgId}/types/incident/fields/severity_code`, + headers: { + Authorization: `Basic ${token}`, + 'Content-Type': 'application/json', + }, + }); + }); + + it('returns severity correctly', async () => { + const res = await connector.getSeverity(); + + expect(res).toEqual([ + { + id: '4', + name: 'Low', + }, + { + id: '5', + name: 'Medium', + }, + { + id: '6', + name: 'High', + }, + ]); + }); + + it('should throw an error', async () => { + requestMock.mockImplementation(() => { + throw new Error('An error has occurred'); + }); + + await expect(connector.getSeverity()).rejects.toThrow( + '[Action][IBM Resilient]: Unable to get severity. Error: An error has occurred.' + ); + }); + + it('should throw if the required attributes are not received in response', async () => { + requestMock.mockImplementation(() => + createAxiosResponse({ data: { id: '10', name: 'Critical' } }) + ); + + await expect(connector.getSeverity()).rejects.toThrow( + '[Action][IBM Resilient]: Unable to get severity. Error: Response validation failed (Error: [values]: expected value of type [array] but got [undefined]).' + ); + }); + }); + + describe('getFields', () => { + beforeEach(() => { + requestMock.mockImplementation(() => + createAxiosResponse({ + data: resilientFields, + }) + ); + }); + it('should call request with correct arguments', async () => { + await connector.getFields(); + + expect(requestMock).toBeCalledTimes(1); + expect(requestMock).toHaveBeenCalledWith({ + ...ignoredRequestFields, + method: 'GET', + data: {}, + url: `${apiUrl}rest/orgs/${orgId}/types/incident/fields`, + headers: { + Authorization: `Basic ${token}`, + 'Content-Type': 'application/json', + }, + }); + }); + + it('returns common fields correctly', async () => { + const res = await connector.getFields(); + expect(res).toEqual(resilientFields); + }); + + it('should throw an error', async () => { + requestMock.mockImplementation(() => { + throw new Error('An error has occurred'); + }); + await expect(connector.getFields()).rejects.toThrow( + 'Unable to get fields. Error: An error has occurred' + ); + }); + + it('should throw if the required attributes are not received in response', async () => { + requestMock.mockImplementation(() => createAxiosResponse({ data: { someField: 'test' } })); + + await expect(connector.getFields()).rejects.toThrow( + '[Action][IBM Resilient]: Unable to get fields. Error: Response validation failed (Error: expected value of type [array] but got [Object]).' + ); + }); + }); +}); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/resilient/resilient.ts b/x-pack/plugins/stack_connectors/server/connector_types/resilient/resilient.ts new file mode 100644 index 00000000000000..4c8175e52ab8fc --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/resilient/resilient.ts @@ -0,0 +1,331 @@ +/* + * 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 { AxiosError } from 'axios'; +import { omitBy, isNil } from 'lodash/fp'; +import { CaseConnector, ServiceParams } from '@kbn/actions-plugin/server'; +import { schema, Type } from '@kbn/config-schema'; +import { getErrorMessage } from '@kbn/actions-plugin/server/lib/axios_utils'; +import { + CreateIncidentData, + ExternalServiceIncidentResponse, + GetIncidentResponse, + GetIncidentTypesResponse, + GetSeverityResponse, + Incident, + ResilientConfig, + ResilientSecrets, + UpdateIncidentParams, +} from './types'; +import * as i18n from './translations'; +import { SUB_ACTION } from './constants'; +import { + ExecutorSubActionCommonFieldsParamsSchema, + ExecutorSubActionGetIncidentTypesParamsSchema, + ExecutorSubActionGetSeverityParamsSchema, + GetCommonFieldsResponseSchema, + GetIncidentTypesResponseSchema, + GetSeverityResponseSchema, + GetIncidentResponseSchema, +} from './schema'; +import { formatUpdateRequest } from './utils'; + +const VIEW_INCIDENT_URL = `#incidents`; + +export class ResilientConnector extends CaseConnector< + ResilientConfig, + ResilientSecrets, + Incident, + GetIncidentResponse +> { + private urls: { + incidentTypes: string; + incident: string; + comment: string; + severity: string; + }; + + constructor( + params: ServiceParams, + pushToServiceParamsExtendedSchema: Record> + ) { + super(params, pushToServiceParamsExtendedSchema); + + this.urls = { + incidentTypes: `${this.getIncidentFieldsUrl()}/incident_type_ids`, + incident: `${this.getOrgUrl()}/incidents`, + comment: `${this.getOrgUrl()}/incidents/{inc_id}/comments`, + severity: `${this.getIncidentFieldsUrl()}/severity_code`, + }; + + this.registerSubActions(); + } + + protected getResponseErrorMessage(error: AxiosError) { + if (!error.response?.status) { + return i18n.UNKNOWN_API_ERROR; + } + if (error.response.status === 401) { + return i18n.UNAUTHORIZED_API_ERROR; + } + return `API Error: ${error.response?.statusText}`; + } + + private registerSubActions() { + this.registerSubAction({ + name: SUB_ACTION.INCIDENT_TYPES, + method: 'getIncidentTypes', + schema: ExecutorSubActionGetIncidentTypesParamsSchema, + }); + + this.registerSubAction({ + name: SUB_ACTION.SEVERITY, + method: 'getSeverity', + schema: ExecutorSubActionGetSeverityParamsSchema, + }); + + this.registerSubAction({ + name: SUB_ACTION.FIELDS, + method: 'getFields', + schema: ExecutorSubActionCommonFieldsParamsSchema, + }); + } + + private getAuthHeaders() { + const token = Buffer.from( + this.secrets.apiKeyId + ':' + this.secrets.apiKeySecret, + 'utf8' + ).toString('base64'); + + return { Authorization: `Basic ${token}` }; + } + + private getOrgUrl() { + const { apiUrl: url, orgId } = this.config; + + return `${url}/rest/orgs/${orgId}`; + } + + private getIncidentFieldsUrl = () => `${this.getOrgUrl()}/types/incident/fields`; + + private getIncidentViewURL(key: string) { + const url = this.config.apiUrl; + const urlWithoutTrailingSlash = url.endsWith('/') ? url.slice(0, -1) : url; + + return `${urlWithoutTrailingSlash}/${VIEW_INCIDENT_URL}/${key}`; + } + + public async createIncident(incident: Incident): Promise { + try { + let data: CreateIncidentData = { + name: incident.name, + discovered_date: Date.now(), + }; + + if (incident?.description) { + data = { + ...data, + description: { + format: 'html', + content: incident.description ?? '', + }, + }; + } + + if (incident?.incidentTypes) { + data = { + ...data, + incident_type_ids: incident.incidentTypes.map((id: number | string) => ({ + id: Number(id), + })), + }; + } + + if (incident?.severityCode) { + data = { + ...data, + severity_code: { id: Number(incident.severityCode) }, + }; + } + + const res = await this.request({ + url: `${this.urls.incident}?text_content_output_format=objects_convert`, + method: 'POST', + data, + headers: this.getAuthHeaders(), + responseSchema: schema.object( + { + id: schema.number(), + create_date: schema.number(), + }, + { unknowns: 'allow' } + ), + }); + + const { id, create_date: createDate } = res.data; + + return { + title: `${id}`, + id: `${id}`, + pushedDate: new Date(createDate).toISOString(), + url: this.getIncidentViewURL(id.toString()), + }; + } catch (error) { + throw new Error( + getErrorMessage(i18n.NAME, `Unable to create incident. Error: ${error.message}.`) + ); + } + } + + public async updateIncident({ + incidentId, + incident, + }: UpdateIncidentParams): Promise { + try { + const latestIncident = await this.getIncident({ id: incidentId }); + + // Remove null or undefined values. Allowing null values sets the field in IBM Resilient to empty. + const newIncident = omitBy(isNil, incident); + const data = formatUpdateRequest({ oldIncident: latestIncident, newIncident }); + + const res = await this.request({ + method: 'PATCH', + url: `${this.urls.incident}/${incidentId}`, + data, + headers: this.getAuthHeaders(), + responseSchema: schema.object({ success: schema.boolean() }, { unknowns: 'allow' }), + }); + + if (!res.data.success) { + throw new Error('Error while updating incident'); + } + + const updatedIncident = await this.getIncident({ id: incidentId }); + + return { + title: `${updatedIncident.id}`, + id: `${updatedIncident.id}`, + pushedDate: new Date(updatedIncident.inc_last_modified_date).toISOString(), + url: this.getIncidentViewURL(updatedIncident.id.toString()), + }; + } catch (error) { + throw new Error( + getErrorMessage( + i18n.NAME, + `Unable to update incident with id ${incidentId}. Error: ${error.message}.` + ) + ); + } + } + + public async addComment({ incidentId, comment }: { incidentId: string; comment: string }) { + try { + await this.request({ + method: 'POST', + url: this.urls.comment.replace('{inc_id}', incidentId), + data: { text: { format: 'text', content: comment } }, + headers: this.getAuthHeaders(), + responseSchema: schema.object({}, { unknowns: 'allow' }), + }); + } catch (error) { + throw new Error( + getErrorMessage( + i18n.NAME, + `Unable to create comment at incident with id ${incidentId}. Error: ${error.message}.` + ) + ); + } + } + + public async getIncident({ id }: { id: string }): Promise { + try { + const res = await this.request({ + method: 'GET', + url: `${this.urls.incident}/${id}`, + params: { + text_content_output_format: 'objects_convert', + }, + headers: this.getAuthHeaders(), + responseSchema: GetIncidentResponseSchema, + }); + + return res.data; + } catch (error) { + throw new Error( + getErrorMessage(i18n.NAME, `Unable to get incident with id ${id}. Error: ${error.message}.`) + ); + } + } + + public async getIncidentTypes(): Promise { + try { + const res = await this.request({ + method: 'GET', + url: this.urls.incidentTypes, + headers: this.getAuthHeaders(), + responseSchema: GetIncidentTypesResponseSchema, + }); + + const incidentTypes = res.data?.values ?? []; + + return incidentTypes.map((type: { value: number; label: string }) => ({ + id: type.value.toString(), + name: type.label, + })); + } catch (error) { + throw new Error( + getErrorMessage(i18n.NAME, `Unable to get incident types. Error: ${error.message}.`) + ); + } + } + + public async getSeverity(): Promise { + try { + const res = await this.request({ + method: 'GET', + url: this.urls.severity, + headers: this.getAuthHeaders(), + responseSchema: GetSeverityResponseSchema, + }); + + const severities = res.data?.values ?? []; + return severities.map((type: { value: number; label: string }) => ({ + id: type.value.toString(), + name: type.label, + })); + } catch (error) { + throw new Error( + getErrorMessage(i18n.NAME, `Unable to get severity. Error: ${error.message}.`) + ); + } + } + + public async getFields() { + try { + const res = await this.request({ + method: 'GET', + url: this.getIncidentFieldsUrl(), + headers: this.getAuthHeaders(), + responseSchema: GetCommonFieldsResponseSchema, + }); + + const fields = res.data.map((field) => { + return { + name: field.name, + input_type: field.input_type, + read_only: field.read_only, + required: field.required, + text: field.text, + }; + }); + + return fields; + } catch (error) { + throw new Error(getErrorMessage(i18n.NAME, `Unable to get fields. Error: ${error.message}.`)); + } + } +} diff --git a/x-pack/plugins/stack_connectors/server/connector_types/resilient/schema.ts b/x-pack/plugins/stack_connectors/server/connector_types/resilient/schema.ts index 9f76a236cacd5e..96b1d44f8636e4 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/resilient/schema.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/resilient/schema.ts @@ -43,39 +43,66 @@ export const ExecutorSubActionPushParamsSchema = schema.object({ ), }); -export const ExecutorSubActionGetIncidentParamsSchema = schema.object({ - externalId: schema.string(), -}); +export const PushToServiceIncidentSchema = { + name: schema.string(), + description: schema.nullable(schema.string()), + incidentTypes: schema.nullable(schema.arrayOf(schema.number())), + severityCode: schema.nullable(schema.number()), +}; // Reserved for future implementation export const ExecutorSubActionCommonFieldsParamsSchema = schema.object({}); -export const ExecutorSubActionHandshakeParamsSchema = schema.object({}); export const ExecutorSubActionGetIncidentTypesParamsSchema = schema.object({}); export const ExecutorSubActionGetSeverityParamsSchema = schema.object({}); -export const ExecutorParamsSchema = schema.oneOf([ - schema.object({ - subAction: schema.literal('getFields'), - subActionParams: ExecutorSubActionCommonFieldsParamsSchema, - }), - schema.object({ - subAction: schema.literal('getIncident'), - subActionParams: ExecutorSubActionGetIncidentParamsSchema, - }), - schema.object({ - subAction: schema.literal('handshake'), - subActionParams: ExecutorSubActionHandshakeParamsSchema, - }), - schema.object({ - subAction: schema.literal('pushToService'), - subActionParams: ExecutorSubActionPushParamsSchema, - }), - schema.object({ - subAction: schema.literal('incidentTypes'), - subActionParams: ExecutorSubActionGetIncidentTypesParamsSchema, - }), - schema.object({ - subAction: schema.literal('severity'), - subActionParams: ExecutorSubActionGetSeverityParamsSchema, - }), -]); +const ArrayOfValuesSchema = schema.arrayOf( + schema.object( + { + value: schema.number(), + label: schema.string(), + }, + { unknowns: 'allow' } + ) +); + +export const GetIncidentTypesResponseSchema = schema.object( + { + values: ArrayOfValuesSchema, + }, + { unknowns: 'allow' } +); + +export const GetSeverityResponseSchema = schema.object( + { + values: ArrayOfValuesSchema, + }, + { unknowns: 'allow' } +); + +export const ExternalServiceFieldsSchema = schema.object( + { + input_type: schema.string(), + name: schema.string(), + read_only: schema.boolean(), + required: schema.nullable(schema.string()), + text: schema.string(), + }, + { unknowns: 'allow' } +); + +export const GetCommonFieldsResponseSchema = schema.arrayOf(ExternalServiceFieldsSchema); + +export const ExternalServiceIncidentResponseSchema = schema.object({ + id: schema.string(), + title: schema.string(), + url: schema.string(), + pushedDate: schema.string(), +}); + +export const GetIncidentResponseSchema = schema.object( + { + id: schema.number(), + inc_last_modified_date: schema.number(), + }, + { unknowns: 'allow' } +); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/resilient/service.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/resilient/service.test.ts deleted file mode 100644 index b45cf9b1e34fe9..00000000000000 --- a/x-pack/plugins/stack_connectors/server/connector_types/resilient/service.test.ts +++ /dev/null @@ -1,714 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import axios from 'axios'; - -import { createExternalService, getValueTextContent, formatUpdateRequest } from './service'; -import { request, createAxiosResponse } from '@kbn/actions-plugin/server/lib/axios_utils'; -import { ExternalService } from './types'; -import { Logger } from '@kbn/core/server'; -import { loggingSystemMock } from '@kbn/core/server/mocks'; -import { incidentTypes, resilientFields, severity } from './mocks'; -import { actionsConfigMock } from '@kbn/actions-plugin/server/actions_config.mock'; - -const logger = loggingSystemMock.create().get() as jest.Mocked; - -jest.mock('axios'); -jest.mock('@kbn/actions-plugin/server/lib/axios_utils', () => { - const originalUtils = jest.requireActual('@kbn/actions-plugin/server/lib/axios_utils'); - return { - ...originalUtils, - request: jest.fn(), - }; -}); - -axios.create = jest.fn(() => axios); -const requestMock = request as jest.Mock; -const now = Date.now; -const TIMESTAMP = 1589391874472; -const configurationUtilities = actionsConfigMock.create(); - -// Incident update makes three calls to the API. -// The function below mocks this calls. -// a) Get the latest incident -// b) Update the incident -// c) Get the updated incident -const mockIncidentUpdate = (withUpdateError = false) => { - requestMock.mockImplementationOnce(() => - createAxiosResponse({ - data: { - id: '1', - name: 'title', - description: { - format: 'html', - content: 'description', - }, - incident_type_ids: [1001, 16, 12], - severity_code: 6, - }, - }) - ); - - if (withUpdateError) { - requestMock.mockImplementationOnce(() => { - throw new Error('An error has occurred'); - }); - } else { - requestMock.mockImplementationOnce(() => - createAxiosResponse({ - data: { - success: true, - id: '1', - inc_last_modified_date: 1589391874472, - }, - }) - ); - } - - requestMock.mockImplementationOnce(() => - createAxiosResponse({ - data: { - id: '1', - name: 'title_updated', - description: { - format: 'html', - content: 'desc_updated', - }, - inc_last_modified_date: 1589391874472, - }, - }) - ); -}; - -describe('IBM Resilient service', () => { - let service: ExternalService; - - beforeAll(() => { - service = createExternalService( - { - // The trailing slash at the end of the url is intended. - // All API calls need to have the trailing slash removed. - config: { apiUrl: 'https://resilient.elastic.co/', orgId: '201' }, - secrets: { apiKeyId: 'keyId', apiKeySecret: 'secret' }, - }, - logger, - configurationUtilities - ); - }); - - afterAll(() => { - Date.now = now; - }); - - beforeEach(() => { - jest.resetAllMocks(); - Date.now = jest.fn().mockReturnValue(TIMESTAMP); - }); - - describe('getValueTextContent', () => { - test('transforms correctly', () => { - expect(getValueTextContent('name', 'title')).toEqual({ - text: 'title', - }); - }); - - test('transforms correctly the description', () => { - expect(getValueTextContent('description', 'desc')).toEqual({ - textarea: { - format: 'html', - content: 'desc', - }, - }); - }); - }); - - describe('formatUpdateRequest', () => { - test('transforms correctly', () => { - const oldIncident = { name: 'title', description: 'desc' }; - const newIncident = { name: 'title_updated', description: 'desc_updated' }; - expect(formatUpdateRequest({ oldIncident, newIncident })).toEqual({ - changes: [ - { - field: { name: 'name' }, - old_value: { text: 'title' }, - new_value: { text: 'title_updated' }, - }, - { - field: { name: 'description' }, - old_value: { - textarea: { - format: 'html', - content: 'desc', - }, - }, - new_value: { - textarea: { - format: 'html', - content: 'desc_updated', - }, - }, - }, - ], - }); - }); - }); - - describe('createExternalService', () => { - test('throws without url', () => { - expect(() => - createExternalService( - { - config: { apiUrl: null, orgId: '201' }, - secrets: { apiKeyId: 'token', apiKeySecret: 'secret' }, - }, - logger, - configurationUtilities - ) - ).toThrow(); - }); - - test('throws without orgId', () => { - expect(() => - createExternalService( - { - config: { apiUrl: 'test.com', orgId: null }, - secrets: { apiKeyId: 'token', apiKeySecret: 'secret' }, - }, - logger, - configurationUtilities - ) - ).toThrow(); - }); - - test('throws without username', () => { - expect(() => - createExternalService( - { - config: { apiUrl: 'test.com', orgId: '201' }, - secrets: { apiKeyId: '', apiKeySecret: 'secret' }, - }, - logger, - configurationUtilities - ) - ).toThrow(); - }); - - test('throws without password', () => { - expect(() => - createExternalService( - { - config: { apiUrl: 'test.com', orgId: '201' }, - secrets: { apiKeyId: '', apiKeySecret: undefined }, - }, - logger, - configurationUtilities - ) - ).toThrow(); - }); - }); - - describe('getIncident', () => { - test('it returns the incident correctly', async () => { - requestMock.mockImplementation(() => - createAxiosResponse({ - data: { - id: '1', - name: '1', - description: { - format: 'html', - content: 'description', - }, - }, - }) - ); - const res = await service.getIncident('1'); - expect(res).toEqual({ id: '1', name: '1', description: 'description' }); - }); - - test('it should call request with correct arguments', async () => { - requestMock.mockImplementation(() => - createAxiosResponse({ - data: { id: '1' }, - }) - ); - - await service.getIncident('1'); - expect(requestMock).toHaveBeenCalledWith({ - axios, - logger, - url: 'https://resilient.elastic.co/rest/orgs/201/incidents/1', - params: { - text_content_output_format: 'objects_convert', - }, - configurationUtilities, - }); - }); - - test('it should throw an error', async () => { - requestMock.mockImplementation(() => { - throw new Error('An error has occurred'); - }); - await expect(service.getIncident('1')).rejects.toThrow( - 'Unable to get incident with id 1. Error: An error has occurred' - ); - }); - - test('it should throw if the request is not a JSON', async () => { - requestMock.mockImplementation(() => - createAxiosResponse({ data: { id: '1' }, headers: { ['content-type']: 'text/html' } }) - ); - - await expect(service.getIncident('1')).rejects.toThrow( - '[Action][IBM Resilient]: Unable to get incident with id 1. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json.' - ); - }); - }); - - describe('createIncident', () => { - const incident = { - incident: { - name: 'title', - description: 'desc', - incidentTypes: [1001], - severityCode: 6, - }, - }; - - test('it creates the incident correctly', async () => { - requestMock.mockImplementation(() => - createAxiosResponse({ - data: { - id: '1', - name: 'title', - description: 'description', - discovered_date: 1589391874472, - create_date: 1589391874472, - }, - }) - ); - - const res = await service.createIncident(incident); - - expect(res).toEqual({ - title: '1', - id: '1', - pushedDate: '2020-05-13T17:44:34.472Z', - url: 'https://resilient.elastic.co/#incidents/1', - }); - }); - - test('it should call request with correct arguments', async () => { - requestMock.mockImplementation(() => - createAxiosResponse({ - data: { - id: '1', - name: 'title', - description: 'description', - discovered_date: 1589391874472, - create_date: 1589391874472, - }, - }) - ); - - await service.createIncident(incident); - - expect(requestMock).toHaveBeenCalledWith({ - axios, - url: 'https://resilient.elastic.co/rest/orgs/201/incidents?text_content_output_format=objects_convert', - logger, - method: 'post', - configurationUtilities, - data: { - name: 'title', - description: { - format: 'html', - content: 'desc', - }, - discovered_date: TIMESTAMP, - incident_type_ids: [{ id: 1001 }], - severity_code: { id: 6 }, - }, - }); - }); - - test('it should throw an error', async () => { - requestMock.mockImplementation(() => { - throw new Error('An error has occurred'); - }); - - await expect( - service.createIncident({ - incident: { - name: 'title', - description: 'desc', - incidentTypes: [1001], - severityCode: 6, - }, - }) - ).rejects.toThrow( - '[Action][IBM Resilient]: Unable to create incident. Error: An error has occurred' - ); - }); - - test('it should throw if the request is not a JSON', async () => { - requestMock.mockImplementation(() => - createAxiosResponse({ data: { id: '1' }, headers: { ['content-type']: 'text/html' } }) - ); - - await expect(service.createIncident(incident)).rejects.toThrow( - '[Action][IBM Resilient]: Unable to create incident. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json.' - ); - }); - - test('it should throw if the required attributes are not there', async () => { - requestMock.mockImplementation(() => createAxiosResponse({ data: { notRequired: 'test' } })); - - await expect(service.createIncident(incident)).rejects.toThrow( - '[Action][IBM Resilient]: Unable to create incident. Error: Response is missing at least one of the expected fields: id,create_date.' - ); - }); - }); - - describe('updateIncident', () => { - const req = { - incidentId: '1', - incident: { - name: 'title', - description: 'desc', - incidentTypes: [1001], - severityCode: 6, - }, - }; - test('it updates the incident correctly', async () => { - mockIncidentUpdate(); - const res = await service.updateIncident(req); - - expect(res).toEqual({ - title: '1', - id: '1', - pushedDate: '2020-05-13T17:44:34.472Z', - url: 'https://resilient.elastic.co/#incidents/1', - }); - }); - - test('it should call request with correct arguments', async () => { - mockIncidentUpdate(); - - await service.updateIncident({ - incidentId: '1', - incident: { - name: 'title_updated', - description: 'desc_updated', - incidentTypes: [1001], - severityCode: 5, - }, - }); - - // Incident update makes three calls to the API. - // The second call to the API is the update call. - expect(requestMock.mock.calls[1][0]).toEqual({ - axios, - logger, - method: 'patch', - configurationUtilities, - url: 'https://resilient.elastic.co/rest/orgs/201/incidents/1', - data: { - changes: [ - { - field: { name: 'name' }, - old_value: { text: 'title' }, - new_value: { text: 'title_updated' }, - }, - { - field: { name: 'description' }, - old_value: { - textarea: { - content: 'description', - format: 'html', - }, - }, - new_value: { - textarea: { - content: 'desc_updated', - format: 'html', - }, - }, - }, - { - field: { - name: 'incident_type_ids', - }, - old_value: { - ids: [1001, 16, 12], - }, - new_value: { - ids: [1001], - }, - }, - { - field: { - name: 'severity_code', - }, - old_value: { - id: 6, - }, - new_value: { - id: 5, - }, - }, - ], - }, - }); - }); - - test('it should throw an error', async () => { - mockIncidentUpdate(true); - - await expect(service.updateIncident(req)).rejects.toThrow( - '[Action][IBM Resilient]: Unable to update incident with id 1. Error: An error has occurred' - ); - }); - - test('it should throw if the request is not a JSON', async () => { - // get incident request - requestMock.mockImplementationOnce(() => - createAxiosResponse({ - data: { - id: '1', - name: 'title', - description: { - format: 'html', - content: 'description', - }, - incident_type_ids: [1001, 16, 12], - severity_code: 6, - }, - }) - ); - - // update incident request - requestMock.mockImplementation(() => - createAxiosResponse({ data: { id: '1' }, headers: { ['content-type']: 'text/html' } }) - ); - - await expect(service.updateIncident(req)).rejects.toThrow( - '[Action][IBM Resilient]: Unable to update incident with id 1. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json' - ); - }); - }); - - describe('createComment', () => { - const req = { - incidentId: '1', - comment: { - comment: 'comment', - commentId: 'comment-1', - }, - }; - - test('it creates the comment correctly', async () => { - requestMock.mockImplementation(() => - createAxiosResponse({ - data: { - id: '1', - create_date: 1589391874472, - }, - }) - ); - - const res = await service.createComment(req); - - expect(res).toEqual({ - commentId: 'comment-1', - pushedDate: '2020-05-13T17:44:34.472Z', - externalCommentId: '1', - }); - }); - - test('it should call request with correct arguments', async () => { - requestMock.mockImplementation(() => - createAxiosResponse({ - data: { - id: '1', - create_date: 1589391874472, - }, - }) - ); - - await service.createComment(req); - - expect(requestMock).toHaveBeenCalledWith({ - axios, - logger, - method: 'post', - configurationUtilities, - url: 'https://resilient.elastic.co/rest/orgs/201/incidents/1/comments', - data: { - text: { - content: 'comment', - format: 'text', - }, - }, - }); - }); - - test('it should throw an error', async () => { - requestMock.mockImplementation(() => { - throw new Error('An error has occurred'); - }); - - await expect(service.createComment(req)).rejects.toThrow( - '[Action][IBM Resilient]: Unable to create comment at incident with id 1. Error: An error has occurred' - ); - }); - - test('it should throw if the request is not a JSON', async () => { - requestMock.mockImplementation(() => - createAxiosResponse({ data: { id: '1' }, headers: { ['content-type']: 'text/html' } }) - ); - - await expect(service.createComment(req)).rejects.toThrow( - '[Action][IBM Resilient]: Unable to create comment at incident with id 1. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json.' - ); - }); - }); - - describe('getIncidentTypes', () => { - test('it creates the incident correctly', async () => { - requestMock.mockImplementation(() => - createAxiosResponse({ - data: { - values: incidentTypes, - }, - }) - ); - - const res = await service.getIncidentTypes(); - - expect(res).toEqual([ - { id: 17, name: 'Communication error (fax; email)' }, - { id: 1001, name: 'Custom type' }, - ]); - }); - - test('it should throw an error', async () => { - requestMock.mockImplementation(() => { - throw new Error('An error has occurred'); - }); - - await expect(service.getIncidentTypes()).rejects.toThrow( - '[Action][IBM Resilient]: Unable to get incident types. Error: An error has occurred.' - ); - }); - - test('it should throw if the request is not a JSON', async () => { - requestMock.mockImplementation(() => - createAxiosResponse({ data: { id: '1' }, headers: { ['content-type']: 'text/html' } }) - ); - - await expect(service.getIncidentTypes()).rejects.toThrow( - '[Action][IBM Resilient]: Unable to get incident types. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json.' - ); - }); - }); - - describe('getSeverity', () => { - test('it creates the incident correctly', async () => { - requestMock.mockImplementation(() => - createAxiosResponse({ - data: { - values: severity, - }, - }) - ); - - const res = await service.getSeverity(); - - expect(res).toEqual([ - { - id: 4, - name: 'Low', - }, - { - id: 5, - name: 'Medium', - }, - { - id: 6, - name: 'High', - }, - ]); - }); - - test('it should throw an error', async () => { - requestMock.mockImplementation(() => { - throw new Error('An error has occurred'); - }); - - await expect(service.getSeverity()).rejects.toThrow( - '[Action][IBM Resilient]: Unable to get severity. Error: An error has occurred.' - ); - }); - - test('it should throw if the request is not a JSON', async () => { - requestMock.mockImplementation(() => - createAxiosResponse({ data: { id: '1' }, headers: { ['content-type']: 'text/html' } }) - ); - - await expect(service.getSeverity()).rejects.toThrow( - '[Action][IBM Resilient]: Unable to get severity. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json.' - ); - }); - }); - - describe('getFields', () => { - test('it should call request with correct arguments', async () => { - requestMock.mockImplementation(() => - createAxiosResponse({ - data: resilientFields, - }) - ); - await service.getFields(); - - expect(requestMock).toHaveBeenCalledWith({ - axios, - logger, - configurationUtilities, - url: 'https://resilient.elastic.co/rest/orgs/201/types/incident/fields', - }); - }); - - test('it returns common fields correctly', async () => { - requestMock.mockImplementation(() => - createAxiosResponse({ - data: resilientFields, - }) - ); - const res = await service.getFields(); - expect(res).toEqual(resilientFields); - }); - - test('it should throw an error', async () => { - requestMock.mockImplementation(() => { - throw new Error('An error has occurred'); - }); - await expect(service.getFields()).rejects.toThrow( - 'Unable to get fields. Error: An error has occurred' - ); - }); - - test('it should throw if the request is not a JSON', async () => { - requestMock.mockImplementation(() => - createAxiosResponse({ data: { id: '1' }, headers: { ['content-type']: 'text/html' } }) - ); - - await expect(service.getFields()).rejects.toThrow( - '[Action][IBM Resilient]: Unable to get fields. Error: Unsupported content type: text/html in GET https://example.com. Supported content types: application/json.' - ); - }); - }); -}); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/resilient/service.ts b/x-pack/plugins/stack_connectors/server/connector_types/resilient/service.ts deleted file mode 100644 index 1e668348e99750..00000000000000 --- a/x-pack/plugins/stack_connectors/server/connector_types/resilient/service.ts +++ /dev/null @@ -1,364 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import axios from 'axios'; -import { omitBy, isNil } from 'lodash/fp'; - -import { Logger } from '@kbn/core/server'; -import { - getErrorMessage, - request, - throwIfResponseIsNotValid, -} from '@kbn/actions-plugin/server/lib/axios_utils'; -import { ActionsConfigurationUtilities } from '@kbn/actions-plugin/server/actions_config'; -import { - ExternalServiceCredentials, - ExternalService, - ExternalServiceParams, - CreateCommentParams, - UpdateIncidentParams, - CreateIncidentParams, - CreateIncidentData, - ResilientPublicConfigurationType, - ResilientSecretConfigurationType, - UpdateIncidentRequest, - GetValueTextContentResponse, -} from './types'; - -import * as i18n from './translations'; - -const VIEW_INCIDENT_URL = `#incidents`; - -export const getValueTextContent = ( - field: string, - value: string | number | number[] -): GetValueTextContentResponse => { - if (field === 'description') { - return { - textarea: { - format: 'html', - content: value as string, - }, - }; - } - - if (field === 'incidentTypes') { - return { - ids: value as number[], - }; - } - - if (field === 'severityCode') { - return { - id: value as number, - }; - } - - return { - text: value as string, - }; -}; - -export const formatUpdateRequest = ({ - oldIncident, - newIncident, -}: ExternalServiceParams): UpdateIncidentRequest => { - return { - changes: Object.keys(newIncident as Record).map((key) => { - let name = key; - - if (key === 'incidentTypes') { - name = 'incident_type_ids'; - } - - if (key === 'severityCode') { - name = 'severity_code'; - } - - return { - field: { name }, - // TODO: Fix ugly casting - old_value: getValueTextContent( - key, - (oldIncident as Record)[name] as string - ), - new_value: getValueTextContent( - key, - (newIncident as Record)[key] as string - ), - }; - }), - }; -}; - -export const createExternalService = ( - { config, secrets }: ExternalServiceCredentials, - logger: Logger, - configurationUtilities: ActionsConfigurationUtilities -): ExternalService => { - const { apiUrl: url, orgId } = config as ResilientPublicConfigurationType; - const { apiKeyId, apiKeySecret } = secrets as ResilientSecretConfigurationType; - - if (!url || !orgId || !apiKeyId || !apiKeySecret) { - throw Error(`[Action]${i18n.NAME}: Wrong configuration.`); - } - - const urlWithoutTrailingSlash = url.endsWith('/') ? url.slice(0, -1) : url; - const orgUrl = `${urlWithoutTrailingSlash}/rest/orgs/${orgId}`; - const incidentUrl = `${orgUrl}/incidents`; - const commentUrl = `${incidentUrl}/{inc_id}/comments`; - const incidentFieldsUrl = `${orgUrl}/types/incident/fields`; - const incidentTypesUrl = `${incidentFieldsUrl}/incident_type_ids`; - const severityUrl = `${incidentFieldsUrl}/severity_code`; - const axiosInstance = axios.create({ - auth: { username: apiKeyId, password: apiKeySecret }, - }); - - const getIncidentViewURL = (key: string) => { - return `${urlWithoutTrailingSlash}/${VIEW_INCIDENT_URL}/${key}`; - }; - - const getCommentsURL = (incidentId: string) => { - return commentUrl.replace('{inc_id}', incidentId); - }; - - const getIncident = async (id: string) => { - try { - const res = await request({ - axios: axiosInstance, - url: `${incidentUrl}/${id}`, - logger, - params: { - text_content_output_format: 'objects_convert', - }, - configurationUtilities, - }); - - throwIfResponseIsNotValid({ - res, - }); - - return { ...res.data, description: res.data.description?.content ?? '' }; - } catch (error) { - throw new Error( - getErrorMessage(i18n.NAME, `Unable to get incident with id ${id}. Error: ${error.message}.`) - ); - } - }; - - const createIncident = async ({ incident }: CreateIncidentParams) => { - let data: CreateIncidentData = { - name: incident.name, - discovered_date: Date.now(), - }; - - if (incident.description) { - data = { - ...data, - description: { - format: 'html', - content: incident.description ?? '', - }, - }; - } - - if (incident.incidentTypes) { - data = { - ...data, - incident_type_ids: incident.incidentTypes.map((id) => ({ id })), - }; - } - - if (incident.severityCode) { - data = { - ...data, - severity_code: { id: incident.severityCode }, - }; - } - - try { - const res = await request({ - axios: axiosInstance, - url: `${incidentUrl}?text_content_output_format=objects_convert`, - method: 'post', - logger, - data, - configurationUtilities, - }); - - throwIfResponseIsNotValid({ - res, - requiredAttributesToBeInTheResponse: ['id', 'create_date'], - }); - - return { - title: `${res.data.id}`, - id: `${res.data.id}`, - pushedDate: new Date(res.data.create_date).toISOString(), - url: getIncidentViewURL(res.data.id), - }; - } catch (error) { - throw new Error( - getErrorMessage(i18n.NAME, `Unable to create incident. Error: ${error.message}.`) - ); - } - }; - - const updateIncident = async ({ incidentId, incident }: UpdateIncidentParams) => { - try { - const latestIncident = await getIncident(incidentId); - - // Remove null or undefined values. Allowing null values sets the field in IBM Resilient to empty. - const newIncident = omitBy(isNil, incident); - const data = formatUpdateRequest({ oldIncident: latestIncident, newIncident }); - - const res = await request({ - axios: axiosInstance, - method: 'patch', - url: `${incidentUrl}/${incidentId}`, - logger, - data, - configurationUtilities, - }); - - throwIfResponseIsNotValid({ - res, - }); - - if (!res.data.success) { - throw new Error(res.data.message); - } - - const updatedIncident = await getIncident(incidentId); - - return { - title: `${updatedIncident.id}`, - id: `${updatedIncident.id}`, - pushedDate: new Date(updatedIncident.inc_last_modified_date).toISOString(), - url: getIncidentViewURL(updatedIncident.id), - }; - } catch (error) { - throw new Error( - getErrorMessage( - i18n.NAME, - `Unable to update incident with id ${incidentId}. Error: ${error.message}` - ) - ); - } - }; - - const createComment = async ({ incidentId, comment }: CreateCommentParams) => { - try { - const res = await request({ - axios: axiosInstance, - method: 'post', - url: getCommentsURL(incidentId), - logger, - data: { text: { format: 'text', content: comment.comment } }, - configurationUtilities, - }); - - throwIfResponseIsNotValid({ - res, - }); - - return { - commentId: comment.commentId, - externalCommentId: res.data.id, - pushedDate: new Date(res.data.create_date).toISOString(), - }; - } catch (error) { - throw new Error( - getErrorMessage( - i18n.NAME, - `Unable to create comment at incident with id ${incidentId}. Error: ${error.message}.` - ) - ); - } - }; - - const getIncidentTypes = async () => { - try { - const res = await request({ - axios: axiosInstance, - method: 'get', - url: incidentTypesUrl, - logger, - configurationUtilities, - }); - - throwIfResponseIsNotValid({ - res, - }); - - const incidentTypes = res.data?.values ?? []; - return incidentTypes.map((type: { value: string; label: string }) => ({ - id: type.value, - name: type.label, - })); - } catch (error) { - throw new Error( - getErrorMessage(i18n.NAME, `Unable to get incident types. Error: ${error.message}.`) - ); - } - }; - - const getSeverity = async () => { - try { - const res = await request({ - axios: axiosInstance, - method: 'get', - url: severityUrl, - logger, - configurationUtilities, - }); - - throwIfResponseIsNotValid({ - res, - }); - - const incidentTypes = res.data?.values ?? []; - return incidentTypes.map((type: { value: string; label: string }) => ({ - id: type.value, - name: type.label, - })); - } catch (error) { - throw new Error( - getErrorMessage(i18n.NAME, `Unable to get severity. Error: ${error.message}.`) - ); - } - }; - - const getFields = async () => { - try { - const res = await request({ - axios: axiosInstance, - url: incidentFieldsUrl, - logger, - configurationUtilities, - }); - - throwIfResponseIsNotValid({ - res, - }); - - return res.data ?? []; - } catch (error) { - throw new Error(getErrorMessage(i18n.NAME, `Unable to get fields. Error: ${error.message}.`)); - } - }; - - return { - createComment, - createIncident, - getFields, - getIncident, - getIncidentTypes, - getSeverity, - updateIncident, - }; -}; diff --git a/x-pack/plugins/stack_connectors/server/connector_types/resilient/translations.ts b/x-pack/plugins/stack_connectors/server/connector_types/resilient/translations.ts index e6f75e5b9d05f1..c271128e589e00 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/resilient/translations.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/resilient/translations.ts @@ -18,3 +18,14 @@ export const ALLOWED_HOSTS_ERROR = (message: string) => message, }, }); + +export const UNKNOWN_API_ERROR = i18n.translate('xpack.stackConnectors.resilient.unknownError', { + defaultMessage: 'Unknown API Error', +}); + +export const UNAUTHORIZED_API_ERROR = i18n.translate( + 'xpack.stackConnectors.resilient.unauthorizedError', + { + defaultMessage: 'Unauthorized API Error', + } +); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/resilient/types.ts b/x-pack/plugins/stack_connectors/server/connector_types/resilient/types.ts index 336e899380c562..e9770db7a98347 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/resilient/types.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/resilient/types.ts @@ -8,34 +8,15 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { TypeOf } from '@kbn/config-schema'; -import { Logger } from '@kbn/core/server'; import { ValidatorServices } from '@kbn/actions-plugin/server/types'; import { - ExecutorParamsSchema, - ExecutorSubActionCommonFieldsParamsSchema, - ExecutorSubActionGetIncidentParamsSchema, - ExecutorSubActionGetIncidentTypesParamsSchema, - ExecutorSubActionGetSeverityParamsSchema, - ExecutorSubActionHandshakeParamsSchema, ExecutorSubActionPushParamsSchema, ExternalIncidentServiceConfigurationSchema, ExternalIncidentServiceSecretConfigurationSchema, + ExternalServiceIncidentResponseSchema, + GetIncidentResponseSchema, } from './schema'; -export type ResilientPublicConfigurationType = TypeOf< - typeof ExternalIncidentServiceConfigurationSchema ->; -export type ResilientSecretConfigurationType = TypeOf< - typeof ExternalIncidentServiceSecretConfigurationSchema ->; - -export type ExecutorSubActionCommonFieldsParams = TypeOf< - typeof ExecutorSubActionCommonFieldsParamsSchema ->; - -export type ExecutorParams = TypeOf; -export type ExecutorSubActionPushParams = TypeOf; - export interface ExternalServiceCredentials { config: Record; secrets: Record; @@ -46,14 +27,9 @@ export interface ExternalServiceValidation { secrets: (secrets: any, validatorServices: ValidatorServices) => void; } -export interface ExternalServiceIncidentResponse { - id: string; - title: string; - url: string; - pushedDate: string; -} +export type GetIncidentTypesResponse = Array<{ id: string; name: string }>; +export type GetSeverityResponse = Array<{ id: string; name: string }>; -export type ExternalServiceParams = Record; export interface ExternalServiceFields { input_type: string; name: string; @@ -65,104 +41,11 @@ export type GetCommonFieldsResponse = ExternalServiceFields[]; export type Incident = Omit; -export interface CreateIncidentParams { - incident: Incident; -} - export interface UpdateIncidentParams { incidentId: string; incident: Incident; } -export interface CreateCommentParams { - incidentId: string; - comment: SimpleComment; -} - -export type GetIncidentTypesResponse = Array<{ id: string; name: string }>; -export type GetSeverityResponse = Array<{ id: string; name: string }>; - -export interface ExternalService { - createComment: (params: CreateCommentParams) => Promise; - createIncident: (params: CreateIncidentParams) => Promise; - getFields: () => Promise; - getIncident: (id: string) => Promise; - getIncidentTypes: () => Promise; - getSeverity: () => Promise; - updateIncident: (params: UpdateIncidentParams) => Promise; -} - -export type PushToServiceApiParams = ExecutorSubActionPushParams; -export type ExecutorSubActionGetIncidentTypesParams = TypeOf< - typeof ExecutorSubActionGetIncidentTypesParamsSchema ->; - -export type ExecutorSubActionGetSeverityParams = TypeOf< - typeof ExecutorSubActionGetSeverityParamsSchema ->; - -export interface ExternalServiceApiHandlerArgs { - externalService: ExternalService; -} - -export type ExecutorSubActionGetIncidentParams = TypeOf< - typeof ExecutorSubActionGetIncidentParamsSchema ->; - -export type ExecutorSubActionHandshakeParams = TypeOf< - typeof ExecutorSubActionHandshakeParamsSchema ->; - -export interface PushToServiceApiHandlerArgs extends ExternalServiceApiHandlerArgs { - params: PushToServiceApiParams; - logger: Logger; -} - -export interface GetIncidentApiHandlerArgs extends ExternalServiceApiHandlerArgs { - params: ExecutorSubActionGetIncidentParams; -} - -export interface HandshakeApiHandlerArgs extends ExternalServiceApiHandlerArgs { - params: ExecutorSubActionHandshakeParams; -} - -export interface GetCommonFieldsHandlerArgs { - externalService: ExternalService; - params: ExecutorSubActionCommonFieldsParams; -} - -export interface GetIncidentTypesHandlerArgs { - externalService: ExternalService; - params: ExecutorSubActionGetIncidentTypesParams; -} - -export interface GetSeverityHandlerArgs { - externalService: ExternalService; - params: ExecutorSubActionGetSeverityParams; -} - -export interface PushToServiceResponse extends ExternalServiceIncidentResponse { - comments?: ExternalServiceCommentResponse[]; -} - -export interface ExternalServiceApi { - getFields: (args: GetCommonFieldsHandlerArgs) => Promise; - handshake: (args: HandshakeApiHandlerArgs) => Promise; - pushToService: (args: PushToServiceApiHandlerArgs) => Promise; - getIncident: (args: GetIncidentApiHandlerArgs) => Promise; - incidentTypes: (args: GetIncidentTypesHandlerArgs) => Promise; - severity: (args: GetSeverityHandlerArgs) => Promise; -} - -export type ResilientExecutorResultData = - | PushToServiceResponse - | GetCommonFieldsResponse - | GetIncidentTypesResponse - | GetSeverityResponse; - -export interface UpdateFieldText { - text: string; -} export interface UpdateFieldText { text: string; } @@ -170,7 +53,6 @@ export interface UpdateFieldText { export interface UpdateIdsField { ids: number[]; } - export interface UpdateIdField { id: number; } @@ -202,12 +84,11 @@ export interface CreateIncidentData { incident_type_ids?: Array<{ id: number }>; severity_code?: { id: number }; } -export interface SimpleComment { - comment: string; - commentId: string; -} -export interface ExternalServiceCommentResponse { - commentId: string; - pushedDate: string; - externalCommentId?: string; -} + +export type ResilientConfig = TypeOf; +export type ResilientSecrets = TypeOf; + +export type ExecutorSubActionPushParams = TypeOf; + +export type ExternalServiceIncidentResponse = TypeOf; +export type GetIncidentResponse = TypeOf; diff --git a/x-pack/plugins/stack_connectors/server/connector_types/resilient/utils.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/resilient/utils.test.ts new file mode 100644 index 00000000000000..51ce7cc80de7cd --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/resilient/utils.test.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 { formatUpdateRequest, getValueTextContent } from './utils'; + +describe('utils', () => { + describe('getValueTextContent', () => { + test('transforms name correctly', () => { + expect(getValueTextContent('name', 'title')).toEqual({ + text: 'title', + }); + }); + + test('transforms correctly the description', () => { + expect(getValueTextContent('description', 'desc')).toEqual({ + textarea: { + format: 'html', + content: 'desc', + }, + }); + }); + + test('transforms correctly the severityCode', () => { + expect(getValueTextContent('severityCode', 6)).toEqual({ + id: 6, + }); + }); + + test('transforms correctly the severityCode as string', () => { + expect(getValueTextContent('severityCode', '6')).toEqual({ + id: 6, + }); + }); + + test('transforms correctly the incidentTypes', () => { + expect(getValueTextContent('incidentTypes', [1101, 12])).toEqual({ + ids: [1101, 12], + }); + }); + + test('transforms default correctly', () => { + expect(getValueTextContent('randomField', 'this is random')).toEqual({ + text: 'this is random', + }); + }); + }); + + describe('formatUpdateRequest', () => { + test('transforms correctly', () => { + const oldIncident = { + name: 'title', + description: { format: 'html', content: 'desc' }, + severity_code: '5', + incident_type_ids: [12, 16], + }; + const newIncident = { + name: 'title_updated', + description: 'desc_updated', + severityCode: '6', + incidentTypes: [12, 16, 1001], + }; + expect(formatUpdateRequest({ oldIncident, newIncident })).toEqual({ + changes: [ + { + field: { name: 'name' }, + old_value: { text: 'title' }, + new_value: { text: 'title_updated' }, + }, + { + field: { name: 'description' }, + old_value: { + textarea: { + format: 'html', + content: 'desc', + }, + }, + new_value: { + textarea: { + format: 'html', + content: 'desc_updated', + }, + }, + }, + { + field: { name: 'severity_code' }, + old_value: { + id: 5, + }, + new_value: { id: 6 }, + }, + { + field: { name: 'incident_type_ids' }, + old_value: { ids: [12, 16] }, + new_value: { + ids: [12, 16, 1001], + }, + }, + ], + }); + }); + }); +}); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/resilient/utils.ts b/x-pack/plugins/stack_connectors/server/connector_types/resilient/utils.ts new file mode 100644 index 00000000000000..a852789796d7ab --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/connector_types/resilient/utils.ts @@ -0,0 +1,75 @@ +/* + * 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 { isArray } from 'lodash'; +import { GetValueTextContentResponse, UpdateIncidentRequest } from './types'; + +export const getValueTextContent = ( + field: string, + value: string | number | number[] +): GetValueTextContentResponse => { + if (field === 'description') { + return { + textarea: { + format: 'html', + content: value.toString(), + }, + }; + } + + if (field === 'incidentTypes') { + if (isArray(value)) { + return { ids: value.map((item) => Number(item)) }; + } + return { + ids: [Number(value)], + }; + } + + if (field === 'severityCode') { + return { + id: Number(value), + }; + } + + return { + text: value.toString(), + }; +}; + +export const formatUpdateRequest = ({ + oldIncident, + newIncident, +}: { + oldIncident: Record; + newIncident: Record; +}): UpdateIncidentRequest => { + return { + changes: Object.keys(newIncident).map((key) => { + let name = key; + + if (key === 'incidentTypes') { + name = 'incident_type_ids'; + } + + if (key === 'severityCode') { + name = 'severity_code'; + } + + return { + field: { name }, + old_value: getValueTextContent( + key, + name === 'description' + ? (oldIncident as { description: { content: string } }).description.content + : (oldIncident[name] as string | number | number[]) + ), + new_value: getValueTextContent(key, newIncident[key] as string), + }; + }), + }; +}; diff --git a/x-pack/plugins/stack_connectors/server/connector_types/resilient/validators.ts b/x-pack/plugins/stack_connectors/server/connector_types/resilient/validators.ts deleted file mode 100644 index 335b8f6b405ad6..00000000000000 --- a/x-pack/plugins/stack_connectors/server/connector_types/resilient/validators.ts +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { ValidatorServices } from '@kbn/actions-plugin/server/types'; -import { - ResilientPublicConfigurationType, - ResilientSecretConfigurationType, - ExternalServiceValidation, -} from './types'; - -import * as i18n from './translations'; - -export const validateCommonConfig = ( - configObject: ResilientPublicConfigurationType, - validatorServices: ValidatorServices -) => { - const { configurationUtilities } = validatorServices; - try { - configurationUtilities.ensureUriAllowed(configObject.apiUrl); - } catch (allowedListError) { - throw new Error(i18n.ALLOWED_HOSTS_ERROR(allowedListError.message)); - } -}; - -export const validateCommonSecrets = ( - secrets: ResilientSecretConfigurationType, - validatorServices: ValidatorServices -) => {}; - -export const validate: ExternalServiceValidation = { - config: validateCommonConfig, - secrets: validateCommonSecrets, -}; diff --git a/x-pack/plugins/stack_connectors/server/plugin.test.ts b/x-pack/plugins/stack_connectors/server/plugin.test.ts index f0f86aac7f5b7d..826e60ac14af8f 100644 --- a/x-pack/plugins/stack_connectors/server/plugin.test.ts +++ b/x-pack/plugins/stack_connectors/server/plugin.test.ts @@ -25,7 +25,7 @@ describe('Stack Connectors Plugin', () => { it('should register built in connector types', () => { const actionsSetup = actionsMock.createSetup(); plugin.setup(coreSetup, { actions: actionsSetup }); - expect(actionsSetup.registerType).toHaveBeenCalledTimes(17); + expect(actionsSetup.registerType).toHaveBeenCalledTimes(16); expect(actionsSetup.registerType).toHaveBeenNthCalledWith( 1, expect.objectContaining({ @@ -119,26 +119,19 @@ describe('Stack Connectors Plugin', () => { ); expect(actionsSetup.registerType).toHaveBeenNthCalledWith( 15, - expect.objectContaining({ - id: '.resilient', - name: 'IBM Resilient', - }) - ); - expect(actionsSetup.registerType).toHaveBeenNthCalledWith( - 16, expect.objectContaining({ id: '.teams', name: 'Microsoft Teams', }) ); expect(actionsSetup.registerType).toHaveBeenNthCalledWith( - 17, + 16, expect.objectContaining({ id: '.torq', name: 'Torq', }) ); - expect(actionsSetup.registerSubActionConnectorType).toHaveBeenCalledTimes(6); + expect(actionsSetup.registerSubActionConnectorType).toHaveBeenCalledTimes(7); expect(actionsSetup.registerSubActionConnectorType).toHaveBeenNthCalledWith( 1, expect.objectContaining({ @@ -174,6 +167,13 @@ describe('Stack Connectors Plugin', () => { name: 'D3 Security', }) ); + expect(actionsSetup.registerSubActionConnectorType).toHaveBeenNthCalledWith( + 6, + expect.objectContaining({ + id: '.resilient', + name: 'IBM Resilient', + }) + ); }); }); }); diff --git a/x-pack/plugins/stack_connectors/server/types.ts b/x-pack/plugins/stack_connectors/server/types.ts index d9cd9f9b99cad1..cb6021a923d0db 100644 --- a/x-pack/plugins/stack_connectors/server/types.ts +++ b/x-pack/plugins/stack_connectors/server/types.ts @@ -32,8 +32,6 @@ export type { ServiceNowActionParams, JiraConnectorTypeId, JiraActionParams, - ResilientConnectorTypeId, - ResilientActionParams, TeamsConnectorTypeId, TeamsActionParams, } from './connector_types'; diff --git a/x-pack/test/alerting_api_integration/basic/tests/actions/connector_types/resilient.ts b/x-pack/test/alerting_api_integration/basic/tests/actions/connector_types/resilient.ts index 20888a0fbeeb2f..c38defd1c40600 100644 --- a/x-pack/test/alerting_api_integration/basic/tests/actions/connector_types/resilient.ts +++ b/x-pack/test/alerting_api_integration/basic/tests/actions/connector_types/resilient.ts @@ -51,12 +51,13 @@ export default function resilientTest({ getService }: FtrProviderContext) { it('should return 403 when creating a resilient action', async () => { await supertest - .post('/api/actions/action') + .post('/api/actions/connector') .set('kbn-xsrf', 'foo') .send({ name: 'A resilient action', - actionTypeId: '.resilient', + connector_type_id: '.resilient', config: { + ...mockResilient.config, apiUrl: resilientSimulatorURL, }, secrets: mockResilient.secrets, diff --git a/x-pack/test/alerting_api_integration/common/plugins/actions_simulators/server/resilient_simulation.ts b/x-pack/test/alerting_api_integration/common/plugins/actions_simulators/server/resilient_simulation.ts index e84e8fd09dc5d0..a4e49966fafc6e 100644 --- a/x-pack/test/alerting_api_integration/common/plugins/actions_simulators/server/resilient_simulation.ts +++ b/x-pack/test/alerting_api_integration/common/plugins/actions_simulators/server/resilient_simulation.ts @@ -5,6 +5,7 @@ * 2.0. */ +import http from 'http'; import { RequestHandlerContext, KibanaRequest, @@ -12,6 +13,55 @@ import { IKibanaResponse, IRouter, } from '@kbn/core/server'; +import { ProxyArgs, Simulator } from './simulator'; + +export const resilientFailedResponse = { + errors: { + message: 'failed', + }, +}; + +export class ResilientSimulator extends Simulator { + private readonly returnError: boolean; + + constructor({ returnError = false, proxy }: { returnError?: boolean; proxy?: ProxyArgs }) { + super(proxy); + + this.returnError = returnError; + } + + public async handler( + request: http.IncomingMessage, + response: http.ServerResponse, + data: Record + ) { + if (this.returnError) { + return ResilientSimulator.sendErrorResponse(response); + } + return ResilientSimulator.sendResponse(request, response); + } + + private static sendResponse(request: http.IncomingMessage, response: http.ServerResponse) { + response.statusCode = 202; + response.setHeader('Content-Type', 'application/json'); + response.end( + JSON.stringify( + { + id: '123', + create_date: 1589391874472, + }, + null, + 4 + ) + ); + } + + private static sendErrorResponse(response: http.ServerResponse) { + response.statusCode = 422; + response.setHeader('Content-Type', 'application/json;charset=UTF-8'); + response.end(JSON.stringify(resilientFailedResponse, null, 4)); + } +} export function initPlugin(router: IRouter, path: string) { router.post( diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/resilient.ts b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/resilient.ts index 4f3b107581a4dc..a61696934d77b0 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/resilient.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/group2/tests/actions/connector_types/resilient.ts @@ -5,21 +5,15 @@ * 2.0. */ -import httpProxy from 'http-proxy'; import expect from '@kbn/expect'; -import { getHttpProxyServer } from '@kbn/alerting-api-integration-helpers'; -import { - getExternalServiceSimulatorPath, - ExternalServiceSimulator, -} from '@kbn/actions-simulators-plugin/server/plugin'; import { TaskErrorSource } from '@kbn/task-manager-plugin/common'; +import { ResilientSimulator } from '@kbn/actions-simulators-plugin/server/resilient_simulation'; import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; // eslint-disable-next-line import/no-default-export export default function resilientTest({ getService }: FtrProviderContext) { const supertest = getService('supertest'); - const kibanaServer = getService('kibanaServer'); const configService = getService('config'); const mockResilient = { @@ -51,17 +45,25 @@ export default function resilientTest({ getService }: FtrProviderContext) { }, }; - let resilientSimulatorURL: string = ''; - describe('IBM Resilient', () => { - before(() => { - resilientSimulatorURL = kibanaServer.resolveUrl( - getExternalServiceSimulatorPath(ExternalServiceSimulator.RESILIENT) - ); - }); - describe('IBM Resilient - Action Creation', () => { - it('should return 200 when creating a ibm resilient action successfully', async () => { + const simulator = new ResilientSimulator({ + proxy: { + config: configService.get('kbnTestServer.serverArgs'), + }, + }); + + let resilientSimulatorURL: string = ''; + + before(async () => { + resilientSimulatorURL = await simulator.start(); + }); + + after(() => { + simulator.close(); + }); + + it('should return 200 when creating a ibm resilient connector successfully', async () => { const { body: createdAction } = await supertest .post('/api/actions/connector') .set('kbn-xsrf', 'foo') @@ -168,7 +170,7 @@ export default function resilientTest({ getService }: FtrProviderContext) { statusCode: 400, error: 'Bad Request', message: - 'error validating action type config: error configuring connector action: target url "http://resilient.mynonexistent.com" is not added to the Kibana config xpack.actions.allowedHosts', + 'error validating action type config: error validating url: target url "http://resilient.mynonexistent.com" is not added to the Kibana config xpack.actions.allowedHosts', }); }); }); @@ -198,37 +200,28 @@ export default function resilientTest({ getService }: FtrProviderContext) { }); describe('IBM Resilient - Executor', () => { - let simulatedActionId: string; - let proxyServer: httpProxy | undefined; - let proxyHaveBeenCalled = false; - before(async () => { - const { body } = await supertest - .post('/api/actions/connector') - .set('kbn-xsrf', 'foo') - .send({ - name: 'A ibm resilient simulator', - connector_type_id: '.resilient', - config: { - apiUrl: resilientSimulatorURL, - orgId: mockResilient.config.orgId, - }, - secrets: mockResilient.secrets, - }); - simulatedActionId = body.id; - - proxyServer = await getHttpProxyServer( - kibanaServer.resolveUrl('/'), - configService.get('kbnTestServer.serverArgs'), - () => { - proxyHaveBeenCalled = true; - } - ); - }); - describe('Validation', () => { + const simulator = new ResilientSimulator({ + proxy: { + config: configService.get('kbnTestServer.serverArgs'), + }, + }); + + let resilientActionId: string; + let resilientSimulatorURL: string = ''; + + before(async () => { + resilientSimulatorURL = await simulator.start(); + resilientActionId = await createConnector(resilientSimulatorURL); + }); + + after(() => { + simulator.close(); + }); + it('should handle failing with a simulated success without action', async () => { await supertest - .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .post(`/api/actions/connector/${resilientActionId}/_execute`) .set('kbn-xsrf', 'foo') .send({ params: {}, @@ -241,25 +234,25 @@ export default function resilientTest({ getService }: FtrProviderContext) { 'errorSource', 'connector_id', ]); - expect(resp.body.connector_id).to.eql(simulatedActionId); + expect(resp.body.connector_id).to.eql(resilientActionId); expect(resp.body.status).to.eql('error'); }); }); it('should handle failing with a simulated success without unsupported action', async () => { await supertest - .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .post(`/api/actions/connector/${resilientActionId}/_execute`) .set('kbn-xsrf', 'foo') .send({ params: { subAction: 'non-supported' }, }) .then((resp: any) => { expect(resp.body).to.eql({ - connector_id: simulatedActionId, + connector_id: resilientActionId, status: 'error', - retry: false, - message: - 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subAction]: expected value to equal [pushToService]\n- [4.subAction]: expected value to equal [incidentTypes]\n- [5.subAction]: expected value to equal [severity]', + retry: true, + message: 'an error occurred while running the action', + service_message: `Sub action "non-supported" is not registered. Connector id: ${resilientActionId}. Connector name: IBM Resilient. Connector type: .resilient`, errorSource: TaskErrorSource.FRAMEWORK, }); }); @@ -267,18 +260,19 @@ export default function resilientTest({ getService }: FtrProviderContext) { it('should handle failing with a simulated success without subActionParams', async () => { await supertest - .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .post(`/api/actions/connector/${resilientActionId}/_execute`) .set('kbn-xsrf', 'foo') .send({ params: { subAction: 'pushToService' }, }) .then((resp: any) => { expect(resp.body).to.eql({ - connector_id: simulatedActionId, + connector_id: resilientActionId, status: 'error', - retry: false, - message: - 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.incident.name]: expected value of type [string] but got [undefined]\n- [4.subAction]: expected value to equal [incidentTypes]\n- [5.subAction]: expected value to equal [severity]', + retry: true, + message: 'an error occurred while running the action', + service_message: + 'Request validation failed (Error: [incident.name]: expected value of type [string] but got [undefined])', errorSource: TaskErrorSource.FRAMEWORK, }); }); @@ -286,7 +280,7 @@ export default function resilientTest({ getService }: FtrProviderContext) { it('should handle failing with a simulated success without title', async () => { await supertest - .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .post(`/api/actions/connector/${resilientActionId}/_execute`) .set('kbn-xsrf', 'foo') .send({ params: { @@ -301,19 +295,20 @@ export default function resilientTest({ getService }: FtrProviderContext) { }) .then((resp: any) => { expect(resp.body).to.eql({ - connector_id: simulatedActionId, + connector_id: resilientActionId, status: 'error', - retry: false, + retry: true, + message: 'an error occurred while running the action', errorSource: TaskErrorSource.FRAMEWORK, - message: - 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.incident.name]: expected value of type [string] but got [undefined]\n- [4.subAction]: expected value to equal [incidentTypes]\n- [5.subAction]: expected value to equal [severity]', + service_message: + 'Request validation failed (Error: [incident.name]: expected value of type [string] but got [undefined])', }); }); }); it('should handle failing with a simulated success without commentId', async () => { await supertest - .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .post(`/api/actions/connector/${resilientActionId}/_execute`) .set('kbn-xsrf', 'foo') .send({ params: { @@ -329,23 +324,24 @@ export default function resilientTest({ getService }: FtrProviderContext) { }) .then((resp: any) => { expect(resp.body).to.eql({ - connector_id: simulatedActionId, + connector_id: resilientActionId, status: 'error', - retry: false, + retry: true, + message: 'an error occurred while running the action', errorSource: TaskErrorSource.FRAMEWORK, - message: - 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.comments]: types that failed validation:\n - [subActionParams.comments.0.0.commentId]: expected value of type [string] but got [undefined]\n - [subActionParams.comments.1]: expected value to equal [null]\n- [4.subAction]: expected value to equal [incidentTypes]\n- [5.subAction]: expected value to equal [severity]', + service_message: + 'Request validation failed (Error: [comments]: types that failed validation:\n- [comments.0.0.commentId]: expected value of type [string] but got [undefined]\n- [comments.1]: expected value to equal [null])', }); }); }); it('should handle failing with a simulated success without comment message', async () => { await supertest - .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .post(`/api/actions/connector/${resilientActionId}/_execute`) .set('kbn-xsrf', 'foo') .send({ params: { - ...mockResilient.params, + subAction: 'pushToService', subActionParams: { incident: { ...mockResilient.params.subActionParams.incident, @@ -357,21 +353,40 @@ export default function resilientTest({ getService }: FtrProviderContext) { }) .then((resp: any) => { expect(resp.body).to.eql({ - connector_id: simulatedActionId, + connector_id: resilientActionId, status: 'error', - retry: false, + retry: true, + message: 'an error occurred while running the action', errorSource: TaskErrorSource.FRAMEWORK, - message: - 'error validating action params: types that failed validation:\n- [0.subAction]: expected value to equal [getFields]\n- [1.subAction]: expected value to equal [getIncident]\n- [2.subAction]: expected value to equal [handshake]\n- [3.subActionParams.comments]: types that failed validation:\n - [subActionParams.comments.0.0.comment]: expected value of type [string] but got [undefined]\n - [subActionParams.comments.1]: expected value to equal [null]\n- [4.subAction]: expected value to equal [incidentTypes]\n- [5.subAction]: expected value to equal [severity]', + service_message: + 'Request validation failed (Error: [comments]: types that failed validation:\n- [comments.0.0.comment]: expected value of type [string] but got [undefined]\n- [comments.1]: expected value to equal [null])', }); }); }); }); describe('Execution', () => { + const simulator = new ResilientSimulator({ + proxy: { + config: configService.get('kbnTestServer.serverArgs'), + }, + }); + + let simulatorUrl: string; + let resilientActionId: string; + + before(async () => { + simulatorUrl = await simulator.start(); + resilientActionId = await createConnector(simulatorUrl); + }); + + after(() => { + simulator.close(); + }); + it('should handle creating an incident without comments', async () => { const { body } = await supertest - .post(`/api/actions/connector/${simulatedActionId}/_execute`) + .post(`/api/actions/connector/${resilientActionId}/_execute`) .set('kbn-xsrf', 'foo') .send({ params: { @@ -384,25 +399,33 @@ export default function resilientTest({ getService }: FtrProviderContext) { }) .expect(200); - expect(proxyHaveBeenCalled).to.equal(true); expect(body).to.eql({ status: 'ok', - connector_id: simulatedActionId, + connector_id: resilientActionId, data: { id: '123', title: '123', pushedDate: '2020-05-13T17:44:34.472Z', - url: `${resilientSimulatorURL}/#incidents/123`, + url: `${simulatorUrl}/#incidents/123`, }, }); }); }); - after(() => { - if (proxyServer) { - proxyServer.close(); - } - }); + const createConnector = async (url: string) => { + const { body } = await supertest + .post('/api/actions/connector') + .set('kbn-xsrf', 'foo') + .send({ + name: 'A resilient action', + connector_type_id: '.resilient', + config: { ...mockResilient.config, apiUrl: url }, + secrets: mockResilient.secrets, + }) + .expect(200); + + return body.id; + }; }); }); } diff --git a/x-pack/test/osquery_cypress/fleet_server.ts b/x-pack/test/osquery_cypress/fleet_server.ts index 4d606d09e3ee77..264bf9f8698915 100644 --- a/x-pack/test/osquery_cypress/fleet_server.ts +++ b/x-pack/test/osquery_cypress/fleet_server.ts @@ -12,6 +12,7 @@ import { startFleetServer, } from '@kbn/security-solution-plugin/scripts/endpoint/common/fleet_server/fleet_server_services'; import { Manager } from './resource_manager'; +import { getLatestAvailableAgentVersion } from './utils'; export class FleetManager extends Manager { private fleetServer: StartedFleetServer | undefined = undefined; @@ -25,8 +26,7 @@ export class FleetManager extends Manager { } public async setup(): Promise { - // TODO TC: https://github.com/elastic/kibana/pull/180879 - there was an issue with 8.14.0, this should be removed when it's fixed - const version = '8.13.0-SNAPSHOT'; + const version = await getLatestAvailableAgentVersion(this.kbnClient); this.fleetServer = await startFleetServer({ kbnClient: this.kbnClient, logger: this.log,