mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[ResponseOps]: Sub action connectors framework (backend) (#129307)
Co-authored-by: Xavier Mouligneau <xavier.mouligneau@elastic.co> Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
b7866ac7f0
commit
fab11ee537
24 changed files with 2545 additions and 2 deletions
|
@ -245,7 +245,6 @@ export interface ImportSetApiResponseError {
|
|||
|
||||
export type ImportSetApiResponse = ImportSetApiResponseSuccess | ImportSetApiResponseError;
|
||||
export interface GetApplicationInfoResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
scope: string;
|
||||
version: string;
|
||||
|
|
|
@ -55,6 +55,10 @@ export { ACTION_SAVED_OBJECT_TYPE } from './constants/saved_objects';
|
|||
|
||||
export const plugin = (initContext: PluginInitializerContext) => new ActionsPlugin(initContext);
|
||||
|
||||
export { SubActionConnector } from './sub_action_framework/sub_action_connector';
|
||||
export { CaseConnector } from './sub_action_framework/case';
|
||||
export type { ServiceParams } from './sub_action_framework/types';
|
||||
|
||||
export const config: PluginConfigDescriptor<ActionsConfig> = {
|
||||
schema: configSchema,
|
||||
exposeToBrowser: {
|
||||
|
|
|
@ -24,7 +24,10 @@ const logger = loggingSystemMock.create().get() as jest.Mocked<Logger>;
|
|||
const createSetupMock = () => {
|
||||
const mock: jest.Mocked<PluginSetupContract> = {
|
||||
registerType: jest.fn(),
|
||||
registerSubActionConnectorType: jest.fn(),
|
||||
isPreconfiguredConnector: jest.fn(),
|
||||
getSubActionConnectorClass: jest.fn(),
|
||||
getCaseConnectorClass: jest.fn(),
|
||||
};
|
||||
return mock;
|
||||
};
|
||||
|
|
|
@ -97,6 +97,10 @@ import {
|
|||
isConnectorDeprecated,
|
||||
ConnectorWithOptionalDeprecation,
|
||||
} from './lib/is_conector_deprecated';
|
||||
import { createSubActionConnectorFramework } from './sub_action_framework';
|
||||
import { IServiceAbstract, SubActionConnectorType } from './sub_action_framework/types';
|
||||
import { SubActionConnector } from './sub_action_framework/sub_action_connector';
|
||||
import { CaseConnector } from './sub_action_framework/case';
|
||||
|
||||
export interface PluginSetupContract {
|
||||
registerType<
|
||||
|
@ -107,8 +111,15 @@ export interface PluginSetupContract {
|
|||
>(
|
||||
actionType: ActionType<Config, Secrets, Params, ExecutorResultData>
|
||||
): void;
|
||||
|
||||
registerSubActionConnectorType<
|
||||
Config extends ActionTypeConfig = ActionTypeConfig,
|
||||
Secrets extends ActionTypeSecrets = ActionTypeSecrets
|
||||
>(
|
||||
connector: SubActionConnectorType<Config, Secrets>
|
||||
): void;
|
||||
isPreconfiguredConnector(connectorId: string): boolean;
|
||||
getSubActionConnectorClass: <Config, Secrets>() => IServiceAbstract<Config, Secrets>;
|
||||
getCaseConnectorClass: <Config, Secrets>() => IServiceAbstract<Config, Secrets>;
|
||||
}
|
||||
|
||||
export interface PluginStartContract {
|
||||
|
@ -310,6 +321,12 @@ export class ActionsPlugin implements Plugin<PluginSetupContract, PluginStartCon
|
|||
});
|
||||
}
|
||||
|
||||
const subActionFramework = createSubActionConnectorFramework({
|
||||
actionTypeRegistry,
|
||||
logger: this.logger,
|
||||
actionsConfigUtils,
|
||||
});
|
||||
|
||||
// Routes
|
||||
defineRoutes({
|
||||
router: core.http.createRouter<ActionsRequestHandlerContext>(),
|
||||
|
@ -342,11 +359,21 @@ export class ActionsPlugin implements Plugin<PluginSetupContract, PluginStartCon
|
|||
ensureSufficientLicense(actionType);
|
||||
actionTypeRegistry.register(actionType);
|
||||
},
|
||||
registerSubActionConnectorType: <
|
||||
Config extends ActionTypeConfig = ActionTypeConfig,
|
||||
Secrets extends ActionTypeSecrets = ActionTypeSecrets
|
||||
>(
|
||||
connector: SubActionConnectorType<Config, Secrets>
|
||||
) => {
|
||||
subActionFramework.registerConnector(connector);
|
||||
},
|
||||
isPreconfiguredConnector: (connectorId: string): boolean => {
|
||||
return !!this.preconfiguredActions.find(
|
||||
(preconfigured) => preconfigured.id === connectorId
|
||||
);
|
||||
},
|
||||
getSubActionConnectorClass: () => SubActionConnector,
|
||||
getCaseConnectorClass: () => CaseConnector,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
356
x-pack/plugins/actions/server/sub_action_framework/README.md
Normal file
356
x-pack/plugins/actions/server/sub_action_framework/README.md
Normal file
|
@ -0,0 +1,356 @@
|
|||
# Sub actions framework
|
||||
|
||||
## Summary
|
||||
|
||||
The Kibana actions plugin provides a framework to create executable actions that supports sub actions. That means you can execute different flows (sub actions) when you execute an action. The framework provides tools to aid you to focus only on the business logic of your connector. You can:
|
||||
|
||||
- Register a sub action and map it to a function of your choice.
|
||||
- Define a schema for the parameters of your sub action.
|
||||
- Define a response schema for responses from external services.
|
||||
- Create connectors that are supported by the Cases management system.
|
||||
|
||||
The framework is built on top of the current actions framework and it is not a replacement of it. All practices described on the plugin's main [README](../../README.md#developing-new-action-types) applies to this framework also.
|
||||
|
||||
## Classes
|
||||
|
||||
The framework provides two classes. The `SubActionConnector` class and the `CaseConnector` class. When registering your connector you should provide a class that implements the business logic of your connector. The class must extend one of the two classes provided by the framework. The classes provides utility functions to register sub actions and make requests to external services.
|
||||
|
||||
|
||||
If you extend the `SubActionConnector`, you should implement the following abstract methods:
|
||||
- `getResponseErrorMessage(error: AxiosError): string;`
|
||||
|
||||
|
||||
If you extend the `CaseConnector`, you should implement the following abstract methods:
|
||||
|
||||
- `getResponseErrorMessage(error: AxiosError): string;`
|
||||
- `addComment({ incidentId, comment }): Promise<unknown>`
|
||||
- `createIncident(incident): Promise<ExternalServiceIncidentResponse>`
|
||||
- `updateIncident({ incidentId, incident }): Promise<ExternalServiceIncidentResponse>`
|
||||
- `getIncident({ id }): Promise<ExternalServiceIncidentResponse>`
|
||||
|
||||
where
|
||||
|
||||
```
|
||||
interface ExternalServiceIncidentResponse {
|
||||
id: string;
|
||||
title: string;
|
||||
url: string;
|
||||
pushedDate: string;
|
||||
}
|
||||
```
|
||||
|
||||
The `CaseConnector` class registers automatically the `pushToService` sub action and implements the corresponding method that is needed by Cases.
|
||||
|
||||
|
||||
### Class Diagrams
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
SubActionConnector <|-- CaseConnector
|
||||
|
||||
class SubActionConnector{
|
||||
-subActions
|
||||
#config
|
||||
#secrets
|
||||
#registerSubAction(subAction)
|
||||
+getResponseErrorMessage(error)*
|
||||
+getSubActions()
|
||||
+registerSubAction(subAction)
|
||||
}
|
||||
|
||||
class CaseConnector{
|
||||
+addComment(comment)*
|
||||
+createIncident(incident)*
|
||||
+updateIncident(incidentId, incident)*
|
||||
+getIncident(incidentId)*
|
||||
+pushToService(params)
|
||||
}
|
||||
```
|
||||
|
||||
### Examples of extending the classes
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
SubActionConnector <|-- CaseConnector
|
||||
SubActionConnector <|-- Tines
|
||||
CaseConnector <|-- ServiceNow
|
||||
|
||||
class SubActionConnector{
|
||||
-subActions
|
||||
#config
|
||||
#secrets
|
||||
#registerSubAction(subAction)
|
||||
+getSubActions()
|
||||
+register(params)
|
||||
}
|
||||
|
||||
class CaseConnector{
|
||||
+addComment(comment)*
|
||||
+createIncident(incident)*
|
||||
+updateIncident(incidentId, incident)*
|
||||
+getIncident(incidentId)*
|
||||
+pushToService(params)
|
||||
}
|
||||
|
||||
class ServiceNow{
|
||||
+getFields()
|
||||
+getChoices()
|
||||
}
|
||||
|
||||
class Tines{
|
||||
+getStories()
|
||||
+getWebooks(storyId)
|
||||
+runAction(actionId)
|
||||
}
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
This guide assumes that you created a class that extends one of the two classes provided by the framework.
|
||||
|
||||
### Register a sub action
|
||||
|
||||
To register a sub action use the `registerSubAction` method provided by the base classes. It expects the name of the sub action, the name of the method of the class that will be called when the sub action is triggered, and a validation schema for the sub action parameters. Example:
|
||||
|
||||
```
|
||||
this.registerSubAction({ name: 'fields', method: 'getFields', schema: schema.object({ incidentId: schema.string() }) })
|
||||
```
|
||||
|
||||
If your method does not accepts any arguments pass `null` to the schema property. Example:
|
||||
|
||||
```
|
||||
this.registerSubAction({ name: 'noParams', method: 'noParams', schema: null })
|
||||
```
|
||||
|
||||
### Request to an external service
|
||||
|
||||
To make a request to an external you should use the `request` method provided by the base classes. It accepts all attributes of the [request configuration object](https://github.com/axios/axios#request-config) of axios plus the expected response schema. Example:
|
||||
|
||||
```
|
||||
const res = await this.request({
|
||||
auth: this.getBasicAuth(),
|
||||
url: 'https://example/com/api/incident/1',
|
||||
method: 'get',
|
||||
responseSchema: schema.object({ id: schema.string(), name: schema.string() }) },
|
||||
});
|
||||
```
|
||||
|
||||
The message returned by the `getResponseErrorMessage` method will be used by the framework as an argument to the constructor of the `Error` class. Then the framework will thrown the `error`.
|
||||
|
||||
The request method does the following:
|
||||
|
||||
- Logs the request URL and method for debugging purposes.
|
||||
- Asserts the URL.
|
||||
- Normalizes the URL.
|
||||
- Ensures that the URL is in the allow list.
|
||||
- Configures proxies.
|
||||
- Validates the response.
|
||||
|
||||
### Error messages from external services
|
||||
|
||||
Each external service has a different response schema for errors. For that reason, you have to implement the abstract method `getResponseErrorMessage` which returns a string representing the error message of the response. Example:
|
||||
|
||||
```
|
||||
interface ErrorSchema {
|
||||
errorMessage: string;
|
||||
errorCode: number;
|
||||
}
|
||||
|
||||
protected getResponseErrorMessage(error: AxiosError<ErrorSchema>) {
|
||||
return `Message: ${error.response?.data.errorMessage}. Code: ${error.response?.data.errorCode}`;
|
||||
}
|
||||
```
|
||||
|
||||
### Remove null or undefined values from data
|
||||
|
||||
There is a possibility that an external service would throw an error for fields with `null` values. For that reason, the base classes provide the `removeNullOrUndefinedFields` utility function to remove or `null` or `undefined` values from an object. Example:
|
||||
|
||||
```
|
||||
// Returns { foo: 'foo' }
|
||||
this.removeNullOrUndefinedFields({ toBeRemoved: null, foo: 'foo' })
|
||||
```
|
||||
|
||||
## Example: Sub action connector
|
||||
|
||||
```
|
||||
import { schema, TypeOf } from '@kbn/config-schema';
|
||||
import { AxiosError } from 'axios';
|
||||
import { SubActionConnector } from './basic';
|
||||
import { CaseConnector } from './case';
|
||||
import { ExternalServiceIncidentResponse, ServiceParams } from './types';
|
||||
|
||||
export const TestConfigSchema = schema.object({ url: schema.string() });
|
||||
export const TestSecretsSchema = schema.object({
|
||||
username: schema.string(),
|
||||
password: schema.string(),
|
||||
});
|
||||
export type TestConfig = TypeOf<typeof TestConfigSchema>;
|
||||
export type TestSecrets = TypeOf<typeof TestSecretsSchema>;
|
||||
|
||||
interface ErrorSchema {
|
||||
errorMessage: string;
|
||||
errorCode: number;
|
||||
}
|
||||
|
||||
export class TestBasicConnector extends SubActionConnector<TestConfig, TestSecrets> {
|
||||
constructor(params: ServiceParams<TestConfig, TestSecrets>) {
|
||||
super(params);
|
||||
this.registerSubAction({
|
||||
name: 'mySubAction',
|
||||
method: 'triggerSubAction',
|
||||
schema: schema.object({ id: schema.string() }),
|
||||
});
|
||||
}
|
||||
|
||||
protected getResponseErrorMessage(error: AxiosError<ErrorSchema>) {
|
||||
return `Message: ${error.response?.data.errorMessage}. Code: ${error.response?.data.errorCode}`;
|
||||
}
|
||||
|
||||
public async triggerSubAction({ id }: { id: string; }) {
|
||||
const res = await this.request({
|
||||
url,
|
||||
data,
|
||||
headers: { 'X-Test-Header': 'test' },
|
||||
responseSchema: schema.object({ status: schema.string() }),
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Example: Case connector
|
||||
|
||||
```
|
||||
import { schema, TypeOf } from '@kbn/config-schema';
|
||||
import { AxiosError } from 'axios';
|
||||
import { SubActionConnector } from './basic';
|
||||
import { CaseConnector } from './case';
|
||||
import { ExternalServiceIncidentResponse, ServiceParams } from './types';
|
||||
|
||||
export const TestConfigSchema = schema.object({ url: schema.string() });
|
||||
export const TestSecretsSchema = schema.object({
|
||||
username: schema.string(),
|
||||
password: schema.string(),
|
||||
});
|
||||
export type TestConfig = TypeOf<typeof TestConfigSchema>;
|
||||
export type TestSecrets = TypeOf<typeof TestSecretsSchema>;
|
||||
|
||||
interface ErrorSchema {
|
||||
errorMessage: string;
|
||||
errorCode: number;
|
||||
}
|
||||
|
||||
export class TestCaseConnector extends CaseConnector<TestConfig, TestSecrets> {
|
||||
constructor(params: ServiceParams<TestConfig, TestSecrets>) {
|
||||
super(params);
|
||||
this.registerSubAction({
|
||||
name: 'categories',
|
||||
method: 'getCategories',
|
||||
schema: null,
|
||||
});
|
||||
}
|
||||
|
||||
protected getResponseErrorMessage(error: AxiosError<ErrorSchema>) {
|
||||
return `Message: ${error.response?.data.errorMessage}. Code: ${error.response?.data.errorCode}`;
|
||||
}
|
||||
|
||||
public async createIncident(incident: {
|
||||
incident: Record<string, { title: string; }>
|
||||
}): Promise<ExternalServiceIncidentResponse> {
|
||||
const res = await this.request({
|
||||
method: 'post',
|
||||
url: 'https://example.com/api/incident',
|
||||
data: { incident },
|
||||
responseSchema: schema.object({ id: schema.string(), title: schema.string() }),
|
||||
});
|
||||
|
||||
return {
|
||||
id: res.data.id,
|
||||
title: res.data.title,
|
||||
url: 'https://example.com',
|
||||
pushedDate: '2022-05-06T09:41:00.401Z',
|
||||
};
|
||||
}
|
||||
|
||||
public async addComment({
|
||||
incidentId,
|
||||
comment,
|
||||
}: {
|
||||
incidentId: string;
|
||||
comment: string;
|
||||
}): Promise<ExternalServiceIncidentResponse> {
|
||||
const res = await this.request({
|
||||
url: `https://example.com/api/incident/${incidentId}/comment`,
|
||||
data: { comment },
|
||||
responseSchema: schema.object({ id: schema.string(), title: schema.string() }),
|
||||
});
|
||||
|
||||
return {
|
||||
id: res.data.id,
|
||||
title: res.data.title,
|
||||
url: 'https://example.com',
|
||||
pushedDate: '2022-05-06T09:41:00.401Z',
|
||||
};
|
||||
}
|
||||
|
||||
public async updateIncident({
|
||||
incidentId,
|
||||
incident,
|
||||
}: {
|
||||
incidentId: string;
|
||||
incident: { category: string };
|
||||
}): Promise<ExternalServiceIncidentResponse> {
|
||||
const res = await this.request({
|
||||
method: 'put',
|
||||
url: `https://example.com/api/incident/${incidentId}`',
|
||||
responseSchema: schema.object({ id: schema.string(), title: schema.string() }),
|
||||
});
|
||||
|
||||
return {
|
||||
id: res.data.id,
|
||||
title: res.data.title,
|
||||
url: 'https://example.com',
|
||||
pushedDate: '2022-05-06T09:41:00.401Z',
|
||||
};
|
||||
}
|
||||
|
||||
public async getIncident({ id }: { id: string }): Promise<ExternalServiceIncidentResponse> {
|
||||
const res = await this.request({
|
||||
url: 'https://example.com/api/incident/1',
|
||||
responseSchema: schema.object({ id: schema.string(), title: schema.string() }),
|
||||
});
|
||||
|
||||
return {
|
||||
id: res.data.id,
|
||||
title: res.data.title,
|
||||
url: 'https://example.com',
|
||||
pushedDate: '2022-05-06T09:41:00.401Z',
|
||||
};
|
||||
}
|
||||
|
||||
public async getCategories() {
|
||||
const res = await this.request({
|
||||
url: 'https://example.com/api/categories',
|
||||
responseSchema: schema.object({ categories: schema.array(schema.string()) }),
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
```
|
||||
|
||||
### Example: Register sub action connector
|
||||
|
||||
The actions framework exports the `registerSubActionConnectorType` to register sub action connectors. Example:
|
||||
|
||||
```
|
||||
plugins.actions.registerSubActionConnectorType({
|
||||
id: '.test-sub-action-connector',
|
||||
name: 'Test: Sub action connector',
|
||||
minimumLicenseRequired: 'platinum' as const,
|
||||
schema: { config: TestConfigSchema, secrets: TestSecretsSchema },
|
||||
Service: TestSubActionConnector,
|
||||
});
|
||||
```
|
||||
|
||||
You can see a full example in [x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/sub_action_connector.ts](../../../../test/alerting_api_integration/common/fixtures/plugins/alerts/server/sub_action_connector.ts)
|
206
x-pack/plugins/actions/server/sub_action_framework/case.test.ts
Normal file
206
x-pack/plugins/actions/server/sub_action_framework/case.test.ts
Normal file
|
@ -0,0 +1,206 @@
|
|||
/*
|
||||
* 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 { loggingSystemMock } from '@kbn/core/server/mocks';
|
||||
import { MockedLogger } from '@kbn/logging-mocks';
|
||||
import { actionsConfigMock } from '../actions_config.mock';
|
||||
import { actionsMock } from '../mocks';
|
||||
import { TestCaseConnector } from './mocks';
|
||||
import { ActionsConfigurationUtilities } from '../actions_config';
|
||||
|
||||
describe('CaseConnector', () => {
|
||||
const pushToServiceParams = { externalId: null, comments: [] };
|
||||
let logger: MockedLogger;
|
||||
let services: ReturnType<typeof actionsMock.createServices>;
|
||||
let mockedActionsConfig: jest.Mocked<ActionsConfigurationUtilities>;
|
||||
let service: TestCaseConnector;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.clearAllMocks();
|
||||
|
||||
logger = loggingSystemMock.createLogger();
|
||||
services = actionsMock.createServices();
|
||||
mockedActionsConfig = actionsConfigMock.create();
|
||||
|
||||
mockedActionsConfig.getResponseSettings.mockReturnValue({
|
||||
maxContentLength: 1000000,
|
||||
timeout: 360000,
|
||||
});
|
||||
|
||||
service = new TestCaseConnector({
|
||||
configurationUtilities: mockedActionsConfig,
|
||||
logger,
|
||||
connector: { id: 'test-id', type: '.test' },
|
||||
config: { url: 'https://example.com' },
|
||||
secrets: { username: 'elastic', password: 'changeme' },
|
||||
services,
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sub actions', () => {
|
||||
it('registers the pushToService sub action correctly', async () => {
|
||||
const subActions = service.getSubActions();
|
||||
expect(subActions.get('pushToService')).toEqual({
|
||||
method: 'pushToService',
|
||||
name: 'pushToService',
|
||||
schema: expect.anything(),
|
||||
});
|
||||
});
|
||||
|
||||
it('should validate the schema of pushToService correctly', async () => {
|
||||
const subActions = service.getSubActions();
|
||||
const subAction = subActions.get('pushToService');
|
||||
expect(
|
||||
subAction?.schema?.validate({
|
||||
externalId: 'test',
|
||||
comments: [{ comment: 'comment', commentId: 'comment-id' }],
|
||||
})
|
||||
).toEqual({
|
||||
externalId: 'test',
|
||||
comments: [{ comment: 'comment', commentId: 'comment-id' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should accept null for externalId', async () => {
|
||||
const subActions = service.getSubActions();
|
||||
const subAction = subActions.get('pushToService');
|
||||
expect(subAction?.schema?.validate({ externalId: null, comments: [] }));
|
||||
});
|
||||
|
||||
it.each([[undefined], [1], [false], [{ test: 'hello' }], [['test']], [{ test: 'hello' }]])(
|
||||
'should throw if externalId is %p',
|
||||
async (externalId) => {
|
||||
const subActions = service.getSubActions();
|
||||
const subAction = subActions.get('pushToService');
|
||||
expect(() => subAction?.schema?.validate({ externalId, comments: [] }));
|
||||
}
|
||||
);
|
||||
|
||||
it('should accept null for comments', async () => {
|
||||
const subActions = service.getSubActions();
|
||||
const subAction = subActions.get('pushToService');
|
||||
expect(subAction?.schema?.validate({ externalId: 'test', comments: null }));
|
||||
});
|
||||
|
||||
it.each([
|
||||
[undefined],
|
||||
[1],
|
||||
[false],
|
||||
[{ test: 'hello' }],
|
||||
[['test']],
|
||||
[{ test: 'hello' }],
|
||||
[{ comment: 'comment', commentId: 'comment-id', foo: 'foo' }],
|
||||
])('should throw if comments %p', async (comments) => {
|
||||
const subActions = service.getSubActions();
|
||||
const subAction = subActions.get('pushToService');
|
||||
expect(() => subAction?.schema?.validate({ externalId: 'test', comments }));
|
||||
});
|
||||
|
||||
it('should allow any field in the params', async () => {
|
||||
const subActions = service.getSubActions();
|
||||
const subAction = subActions.get('pushToService');
|
||||
expect(
|
||||
subAction?.schema?.validate({
|
||||
externalId: 'test',
|
||||
comments: [{ comment: 'comment', commentId: 'comment-id' }],
|
||||
foo: 'foo',
|
||||
bar: 1,
|
||||
baz: [{ test: 'hello' }, 1, 'test', false],
|
||||
isValid: false,
|
||||
val: null,
|
||||
})
|
||||
).toEqual({
|
||||
externalId: 'test',
|
||||
comments: [{ comment: 'comment', commentId: 'comment-id' }],
|
||||
foo: 'foo',
|
||||
bar: 1,
|
||||
baz: [{ test: 'hello' }, 1, 'test', false],
|
||||
isValid: false,
|
||||
val: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('pushToService', () => {
|
||||
it('should create an incident if externalId is null', async () => {
|
||||
const res = await service.pushToService(pushToServiceParams);
|
||||
expect(res).toEqual({
|
||||
id: 'create-incident',
|
||||
title: 'Test incident',
|
||||
url: 'https://example.com',
|
||||
pushedDate: '2022-05-06T09:41:00.401Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('should update an incident if externalId is not null', async () => {
|
||||
const res = await service.pushToService({ ...pushToServiceParams, externalId: 'test-id' });
|
||||
expect(res).toEqual({
|
||||
id: 'update-incident',
|
||||
title: 'Test incident',
|
||||
url: 'https://example.com',
|
||||
pushedDate: '2022-05-06T09:41:00.401Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('should add comments', async () => {
|
||||
const res = await service.pushToService({
|
||||
...pushToServiceParams,
|
||||
comments: [
|
||||
{ comment: 'comment-1', commentId: 'comment-id-1' },
|
||||
{ comment: 'comment-2', commentId: 'comment-id-2' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(res).toEqual({
|
||||
id: 'create-incident',
|
||||
title: 'Test incident',
|
||||
url: 'https://example.com',
|
||||
pushedDate: '2022-05-06T09:41:00.401Z',
|
||||
comments: [
|
||||
{
|
||||
commentId: 'comment-id-1',
|
||||
pushedDate: '2022-05-06T09:41:00.401Z',
|
||||
},
|
||||
{
|
||||
commentId: 'comment-id-2',
|
||||
pushedDate: '2022-05-06T09:41:00.401Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it.each([[undefined], [null]])('should throw if externalId is %p', async (comments) => {
|
||||
const res = await service.pushToService({
|
||||
...pushToServiceParams,
|
||||
// @ts-expect-error
|
||||
comments,
|
||||
});
|
||||
|
||||
expect(res).toEqual({
|
||||
id: 'create-incident',
|
||||
title: 'Test incident',
|
||||
url: 'https://example.com',
|
||||
pushedDate: '2022-05-06T09:41:00.401Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not add comments if comments are an empty array', async () => {
|
||||
const res = await service.pushToService({
|
||||
...pushToServiceParams,
|
||||
comments: [],
|
||||
});
|
||||
|
||||
expect(res).toEqual({
|
||||
id: 'create-incident',
|
||||
title: 'Test incident',
|
||||
url: 'https://example.com',
|
||||
pushedDate: '2022-05-06T09:41:00.401Z',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
119
x-pack/plugins/actions/server/sub_action_framework/case.ts
Normal file
119
x-pack/plugins/actions/server/sub_action_framework/case.ts
Normal file
|
@ -0,0 +1,119 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { schema } from '@kbn/config-schema';
|
||||
import {
|
||||
ExternalServiceIncidentResponse,
|
||||
PushToServiceParams,
|
||||
PushToServiceResponse,
|
||||
} from './types';
|
||||
import { SubActionConnector } from './sub_action_connector';
|
||||
import { ServiceParams } from './types';
|
||||
|
||||
export interface CaseConnectorInterface {
|
||||
addComment: ({
|
||||
incidentId,
|
||||
comment,
|
||||
}: {
|
||||
incidentId: string;
|
||||
comment: string;
|
||||
}) => Promise<unknown>;
|
||||
createIncident: (incident: Record<string, unknown>) => Promise<ExternalServiceIncidentResponse>;
|
||||
updateIncident: ({
|
||||
incidentId,
|
||||
incident,
|
||||
}: {
|
||||
incidentId: string;
|
||||
incident: Record<string, unknown>;
|
||||
}) => Promise<ExternalServiceIncidentResponse>;
|
||||
getIncident: ({ id }: { id: string }) => Promise<unknown>;
|
||||
pushToService: (params: PushToServiceParams) => Promise<PushToServiceResponse>;
|
||||
}
|
||||
|
||||
export abstract class CaseConnector<Config, Secrets>
|
||||
extends SubActionConnector<Config, Secrets>
|
||||
implements CaseConnectorInterface
|
||||
{
|
||||
constructor(params: ServiceParams<Config, Secrets>) {
|
||||
super(params);
|
||||
|
||||
this.registerSubAction({
|
||||
name: 'pushToService',
|
||||
method: 'pushToService',
|
||||
schema: schema.object(
|
||||
{
|
||||
externalId: schema.nullable(schema.string()),
|
||||
comments: schema.nullable(
|
||||
schema.arrayOf(
|
||||
schema.object({
|
||||
comment: schema.string(),
|
||||
commentId: schema.string(),
|
||||
})
|
||||
)
|
||||
),
|
||||
},
|
||||
{ unknowns: 'allow' }
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
public abstract addComment({
|
||||
incidentId,
|
||||
comment,
|
||||
}: {
|
||||
incidentId: string;
|
||||
comment: string;
|
||||
}): Promise<unknown>;
|
||||
|
||||
public abstract createIncident(
|
||||
incident: Record<string, unknown>
|
||||
): Promise<ExternalServiceIncidentResponse>;
|
||||
public abstract updateIncident({
|
||||
incidentId,
|
||||
incident,
|
||||
}: {
|
||||
incidentId: string;
|
||||
incident: Record<string, unknown>;
|
||||
}): Promise<ExternalServiceIncidentResponse>;
|
||||
public abstract getIncident({ id }: { id: string }): Promise<ExternalServiceIncidentResponse>;
|
||||
|
||||
public async pushToService(params: PushToServiceParams) {
|
||||
const { externalId, comments, ...rest } = params;
|
||||
|
||||
let res: PushToServiceResponse;
|
||||
|
||||
if (externalId != null) {
|
||||
res = await this.updateIncident({
|
||||
incidentId: externalId,
|
||||
incident: rest,
|
||||
});
|
||||
} else {
|
||||
res = await this.createIncident(rest);
|
||||
}
|
||||
|
||||
if (comments && Array.isArray(comments) && comments.length > 0) {
|
||||
res.comments = [];
|
||||
|
||||
for (const currentComment of comments) {
|
||||
await this.addComment({
|
||||
incidentId: res.id,
|
||||
comment: currentComment.comment,
|
||||
});
|
||||
|
||||
res.comments = [
|
||||
...(res.comments ?? []),
|
||||
{
|
||||
commentId: currentComment.commentId,
|
||||
pushedDate: res.pushedDate,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,198 @@
|
|||
/*
|
||||
* 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 { loggingSystemMock } from '@kbn/core/server/mocks';
|
||||
import { MockedLogger } from '@kbn/logging-mocks';
|
||||
import { ActionsConfigurationUtilities } from '../actions_config';
|
||||
import { actionsConfigMock } from '../actions_config.mock';
|
||||
import { actionsMock } from '../mocks';
|
||||
import { buildExecutor } from './executor';
|
||||
import {
|
||||
TestSecretsSchema,
|
||||
TestConfigSchema,
|
||||
TestNoSubActions,
|
||||
TestConfig,
|
||||
TestSecrets,
|
||||
TestExecutor,
|
||||
} from './mocks';
|
||||
import { IService } from './types';
|
||||
|
||||
describe('Executor', () => {
|
||||
const actionId = 'test-action-id';
|
||||
const config = { url: 'https://example.com' };
|
||||
const secrets = { username: 'elastic', password: 'changeme' };
|
||||
const params = { subAction: 'testUrl', subActionParams: { url: 'https://example.com' } };
|
||||
let logger: MockedLogger;
|
||||
let services: ReturnType<typeof actionsMock.createServices>;
|
||||
let mockedActionsConfig: jest.Mocked<ActionsConfigurationUtilities>;
|
||||
|
||||
const createExecutor = (Service: IService<TestConfig, TestSecrets>) => {
|
||||
const connector = {
|
||||
id: '.test',
|
||||
name: 'Test',
|
||||
minimumLicenseRequired: 'basic' as const,
|
||||
schema: {
|
||||
config: TestConfigSchema,
|
||||
secrets: TestSecretsSchema,
|
||||
},
|
||||
Service,
|
||||
};
|
||||
|
||||
return buildExecutor({ configurationUtilities: mockedActionsConfig, logger, connector });
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.clearAllMocks();
|
||||
|
||||
logger = loggingSystemMock.createLogger();
|
||||
services = actionsMock.createServices();
|
||||
mockedActionsConfig = actionsConfigMock.create();
|
||||
});
|
||||
|
||||
it('should execute correctly', async () => {
|
||||
const executor = createExecutor(TestExecutor);
|
||||
|
||||
const res = await executor({
|
||||
actionId,
|
||||
params: { subAction: 'echo', subActionParams: { id: 'test-id' } },
|
||||
config,
|
||||
secrets,
|
||||
services,
|
||||
});
|
||||
|
||||
expect(res).toEqual({
|
||||
actionId: 'test-action-id',
|
||||
data: {
|
||||
id: 'test-id',
|
||||
},
|
||||
status: 'ok',
|
||||
});
|
||||
});
|
||||
|
||||
it('should execute correctly without schema validation', async () => {
|
||||
const executor = createExecutor(TestExecutor);
|
||||
|
||||
const res = await executor({
|
||||
actionId,
|
||||
params: { subAction: 'noSchema', subActionParams: { id: 'test-id' } },
|
||||
config,
|
||||
secrets,
|
||||
services,
|
||||
});
|
||||
|
||||
expect(res).toEqual({
|
||||
actionId: 'test-action-id',
|
||||
data: {
|
||||
id: 'test-id',
|
||||
},
|
||||
status: 'ok',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return an empty object if the func returns undefined', async () => {
|
||||
const executor = createExecutor(TestExecutor);
|
||||
|
||||
const res = await executor({
|
||||
actionId,
|
||||
params: { ...params, subAction: 'noData' },
|
||||
config,
|
||||
secrets,
|
||||
services,
|
||||
});
|
||||
|
||||
expect(res).toEqual({
|
||||
actionId: 'test-action-id',
|
||||
data: {},
|
||||
status: 'ok',
|
||||
});
|
||||
});
|
||||
|
||||
it('should execute a non async function', async () => {
|
||||
const executor = createExecutor(TestExecutor);
|
||||
|
||||
const res = await executor({
|
||||
actionId,
|
||||
params: { ...params, subAction: 'noAsync' },
|
||||
config,
|
||||
secrets,
|
||||
services,
|
||||
});
|
||||
|
||||
expect(res).toEqual({
|
||||
actionId: 'test-action-id',
|
||||
data: {},
|
||||
status: 'ok',
|
||||
});
|
||||
});
|
||||
|
||||
it('throws if the are not sub actions registered', async () => {
|
||||
const executor = createExecutor(TestNoSubActions);
|
||||
|
||||
await expect(async () =>
|
||||
executor({ actionId, params, config, secrets, services })
|
||||
).rejects.toThrowError('You should register at least one subAction for your connector type');
|
||||
});
|
||||
|
||||
it('throws if the sub action is not registered', async () => {
|
||||
const executor = createExecutor(TestExecutor);
|
||||
|
||||
await expect(async () =>
|
||||
executor({
|
||||
actionId,
|
||||
params: { subAction: 'not-exist', subActionParams: {} },
|
||||
config,
|
||||
secrets,
|
||||
services,
|
||||
})
|
||||
).rejects.toThrowError(
|
||||
'Sub action "not-exist" is not registered. Connector id: test-action-id. Connector name: Test. Connector type: .test'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws if the method does not exists', async () => {
|
||||
const executor = createExecutor(TestExecutor);
|
||||
|
||||
await expect(async () =>
|
||||
executor({
|
||||
actionId,
|
||||
params,
|
||||
config,
|
||||
secrets,
|
||||
services,
|
||||
})
|
||||
).rejects.toThrowError(
|
||||
'Method "not-exist" does not exists in service. Sub action: "testUrl". Connector id: test-action-id. Connector name: Test. Connector type: .test'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws if the registered method is not a function', async () => {
|
||||
const executor = createExecutor(TestExecutor);
|
||||
|
||||
await expect(async () =>
|
||||
executor({
|
||||
actionId,
|
||||
params: { ...params, subAction: 'notAFunction' },
|
||||
config,
|
||||
secrets,
|
||||
services,
|
||||
})
|
||||
).rejects.toThrowError(
|
||||
'Method "notAFunction" must be a function. Connector id: test-action-id. Connector name: Test. Connector type: .test'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws if the sub actions params are not valid', async () => {
|
||||
const executor = createExecutor(TestExecutor);
|
||||
|
||||
await expect(async () =>
|
||||
executor({ actionId, params: { ...params, subAction: 'echo' }, config, secrets, services })
|
||||
).rejects.toThrowError(
|
||||
'Request validation failed (Error: [id]: expected value of type [string] but got [undefined])'
|
||||
);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,86 @@
|
|||
/*
|
||||
* 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 { ActionsConfigurationUtilities } from '../actions_config';
|
||||
import { ExecutorType } from '../types';
|
||||
import { ExecutorParams, SubActionConnectorType } from './types';
|
||||
|
||||
const isFunction = (v: unknown): v is Function => {
|
||||
return typeof v === 'function';
|
||||
};
|
||||
|
||||
const getConnectorErrorMsg = (actionId: string, connector: { id: string; name: string }) =>
|
||||
`Connector id: ${actionId}. Connector name: ${connector.name}. Connector type: ${connector.id}`;
|
||||
|
||||
export const buildExecutor = <Config, Secrets>({
|
||||
configurationUtilities,
|
||||
connector,
|
||||
logger,
|
||||
}: {
|
||||
connector: SubActionConnectorType<Config, Secrets>;
|
||||
logger: Logger;
|
||||
configurationUtilities: ActionsConfigurationUtilities;
|
||||
}): ExecutorType<Config, Secrets, ExecutorParams, unknown> => {
|
||||
return async ({ actionId, params, config, secrets, services }) => {
|
||||
const subAction = params.subAction;
|
||||
const subActionParams = params.subActionParams;
|
||||
|
||||
const service = new connector.Service({
|
||||
connector: { id: actionId, type: connector.id },
|
||||
config,
|
||||
secrets,
|
||||
configurationUtilities,
|
||||
logger,
|
||||
services,
|
||||
});
|
||||
|
||||
const subActions = service.getSubActions();
|
||||
|
||||
if (subActions.size === 0) {
|
||||
throw new Error('You should register at least one subAction for your connector type');
|
||||
}
|
||||
|
||||
const action = subActions.get(subAction);
|
||||
|
||||
if (!action) {
|
||||
throw new Error(
|
||||
`Sub action "${subAction}" is not registered. ${getConnectorErrorMsg(actionId, connector)}`
|
||||
);
|
||||
}
|
||||
|
||||
const method = action.method;
|
||||
|
||||
if (!service[method]) {
|
||||
throw new Error(
|
||||
`Method "${method}" does not exists in service. Sub action: "${subAction}". ${getConnectorErrorMsg(
|
||||
actionId,
|
||||
connector
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
const func = service[method];
|
||||
|
||||
if (!isFunction(func)) {
|
||||
throw new Error(
|
||||
`Method "${method}" must be a function. ${getConnectorErrorMsg(actionId, connector)}`
|
||||
);
|
||||
}
|
||||
|
||||
if (action.schema) {
|
||||
try {
|
||||
action.schema.validate(subActionParams);
|
||||
} catch (reqValidationError) {
|
||||
throw new Error(`Request validation failed (${reqValidationError})`);
|
||||
}
|
||||
}
|
||||
|
||||
const data = await func.call(service, subActionParams);
|
||||
return { status: 'ok', data: data ?? {}, actionId };
|
||||
};
|
||||
};
|
33
x-pack/plugins/actions/server/sub_action_framework/index.ts
Normal file
33
x-pack/plugins/actions/server/sub_action_framework/index.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* 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 { PublicMethodsOf } from '@kbn/utility-types';
|
||||
import { Logger } from '@kbn/core/server';
|
||||
import { ActionsConfigurationUtilities } from '../actions_config';
|
||||
|
||||
import { ActionTypeRegistry } from '../action_type_registry';
|
||||
import { register } from './register';
|
||||
import { SubActionConnectorType } from './types';
|
||||
import { ActionTypeConfig, ActionTypeSecrets } from '../types';
|
||||
|
||||
export const createSubActionConnectorFramework = ({
|
||||
actionsConfigUtils: configurationUtilities,
|
||||
actionTypeRegistry,
|
||||
logger,
|
||||
}: {
|
||||
actionTypeRegistry: PublicMethodsOf<ActionTypeRegistry>;
|
||||
logger: Logger;
|
||||
actionsConfigUtils: ActionsConfigurationUtilities;
|
||||
}) => {
|
||||
return {
|
||||
registerConnector: <Config extends ActionTypeConfig, Secrets extends ActionTypeSecrets>(
|
||||
connector: SubActionConnectorType<Config, Secrets>
|
||||
) => {
|
||||
register({ actionTypeRegistry, logger, connector, configurationUtilities });
|
||||
},
|
||||
};
|
||||
};
|
194
x-pack/plugins/actions/server/sub_action_framework/mocks.ts
Normal file
194
x-pack/plugins/actions/server/sub_action_framework/mocks.ts
Normal file
|
@ -0,0 +1,194 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
/* eslint-disable max-classes-per-file */
|
||||
|
||||
import { schema, TypeOf } from '@kbn/config-schema';
|
||||
import { AxiosError } from 'axios';
|
||||
import { SubActionConnector } from './sub_action_connector';
|
||||
import { CaseConnector } from './case';
|
||||
import { ExternalServiceIncidentResponse, ServiceParams } from './types';
|
||||
|
||||
export const TestConfigSchema = schema.object({ url: schema.string() });
|
||||
export const TestSecretsSchema = schema.object({
|
||||
username: schema.string(),
|
||||
password: schema.string(),
|
||||
});
|
||||
export type TestConfig = TypeOf<typeof TestConfigSchema>;
|
||||
export type TestSecrets = TypeOf<typeof TestSecretsSchema>;
|
||||
|
||||
interface ErrorSchema {
|
||||
errorMessage: string;
|
||||
errorCode: number;
|
||||
}
|
||||
|
||||
export class TestSubActionConnector extends SubActionConnector<TestConfig, TestSecrets> {
|
||||
constructor(params: ServiceParams<TestConfig, TestSecrets>) {
|
||||
super(params);
|
||||
this.registerSubAction({
|
||||
name: 'testUrl',
|
||||
method: 'testUrl',
|
||||
schema: schema.object({ url: schema.string() }),
|
||||
});
|
||||
|
||||
this.registerSubAction({
|
||||
name: 'testData',
|
||||
method: 'testData',
|
||||
schema: null,
|
||||
});
|
||||
}
|
||||
|
||||
protected getResponseErrorMessage(error: AxiosError<ErrorSchema>) {
|
||||
return `Message: ${error.response?.data.errorMessage}. Code: ${error.response?.data.errorCode}`;
|
||||
}
|
||||
|
||||
public async testUrl({ url, data = {} }: { url: string; data?: Record<string, unknown> | null }) {
|
||||
const res = await this.request({
|
||||
url,
|
||||
data,
|
||||
headers: { 'X-Test-Header': 'test' },
|
||||
responseSchema: schema.object({ status: schema.string() }),
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
public async testData({ data }: { data: Record<string, unknown> }) {
|
||||
const res = await this.request({
|
||||
url: 'https://example.com',
|
||||
data: this.removeNullOrUndefinedFields(data),
|
||||
headers: { 'X-Test-Header': 'test' },
|
||||
responseSchema: schema.object({ status: schema.string() }),
|
||||
});
|
||||
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
export class TestNoSubActions extends SubActionConnector<TestConfig, TestSecrets> {
|
||||
protected getResponseErrorMessage(error: AxiosError<ErrorSchema>) {
|
||||
return `Error`;
|
||||
}
|
||||
}
|
||||
|
||||
export class TestExecutor extends SubActionConnector<TestConfig, TestSecrets> {
|
||||
public notAFunction: string = 'notAFunction';
|
||||
|
||||
constructor(params: ServiceParams<TestConfig, TestSecrets>) {
|
||||
super(params);
|
||||
this.registerSubAction({
|
||||
name: 'testUrl',
|
||||
method: 'not-exist',
|
||||
schema: schema.object({}),
|
||||
});
|
||||
|
||||
this.registerSubAction({
|
||||
name: 'notAFunction',
|
||||
method: 'notAFunction',
|
||||
schema: schema.object({}),
|
||||
});
|
||||
|
||||
this.registerSubAction({
|
||||
name: 'echo',
|
||||
method: 'echo',
|
||||
schema: schema.object({ id: schema.string() }),
|
||||
});
|
||||
|
||||
this.registerSubAction({
|
||||
name: 'noSchema',
|
||||
method: 'noSchema',
|
||||
schema: null,
|
||||
});
|
||||
|
||||
this.registerSubAction({
|
||||
name: 'noData',
|
||||
method: 'noData',
|
||||
schema: null,
|
||||
});
|
||||
|
||||
this.registerSubAction({
|
||||
name: 'noAsync',
|
||||
method: 'noAsync',
|
||||
schema: null,
|
||||
});
|
||||
}
|
||||
|
||||
protected getResponseErrorMessage(error: AxiosError<ErrorSchema>) {
|
||||
return `Error`;
|
||||
}
|
||||
|
||||
public async echo({ id }: { id: string }) {
|
||||
return Promise.resolve({ id });
|
||||
}
|
||||
|
||||
public async noSchema({ id }: { id: string }) {
|
||||
return { id };
|
||||
}
|
||||
|
||||
public async noData() {}
|
||||
|
||||
public noAsync() {}
|
||||
}
|
||||
|
||||
export class TestCaseConnector extends CaseConnector<TestConfig, TestSecrets> {
|
||||
constructor(params: ServiceParams<TestConfig, TestSecrets>) {
|
||||
super(params);
|
||||
}
|
||||
|
||||
protected getResponseErrorMessage(error: AxiosError<ErrorSchema>) {
|
||||
return `Message: ${error.response?.data.errorMessage}. Code: ${error.response?.data.errorCode}`;
|
||||
}
|
||||
|
||||
public async createIncident(incident: {
|
||||
category: string;
|
||||
}): Promise<ExternalServiceIncidentResponse> {
|
||||
return {
|
||||
id: 'create-incident',
|
||||
title: 'Test incident',
|
||||
url: 'https://example.com',
|
||||
pushedDate: '2022-05-06T09:41:00.401Z',
|
||||
};
|
||||
}
|
||||
|
||||
public async addComment({
|
||||
incidentId,
|
||||
comment,
|
||||
}: {
|
||||
incidentId: string;
|
||||
comment: string;
|
||||
}): Promise<ExternalServiceIncidentResponse> {
|
||||
return {
|
||||
id: 'add-comment',
|
||||
title: 'Test incident',
|
||||
url: 'https://example.com',
|
||||
pushedDate: '2022-05-06T09:41:00.401Z',
|
||||
};
|
||||
}
|
||||
|
||||
public async updateIncident({
|
||||
incidentId,
|
||||
incident,
|
||||
}: {
|
||||
incidentId: string;
|
||||
incident: { category: string };
|
||||
}): Promise<ExternalServiceIncidentResponse> {
|
||||
return {
|
||||
id: 'update-incident',
|
||||
title: 'Test incident',
|
||||
url: 'https://example.com',
|
||||
pushedDate: '2022-05-06T09:41:00.401Z',
|
||||
};
|
||||
}
|
||||
|
||||
public async getIncident({ id }: { id: string }): Promise<ExternalServiceIncidentResponse> {
|
||||
return {
|
||||
id: 'get-incident',
|
||||
title: 'Test incident',
|
||||
url: 'https://example.com',
|
||||
pushedDate: '2022-05-06T09:41:00.401Z',
|
||||
};
|
||||
}
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
/*
|
||||
* 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 { loggingSystemMock } from '@kbn/core/server/mocks';
|
||||
import { actionsConfigMock } from '../actions_config.mock';
|
||||
import { actionTypeRegistryMock } from '../action_type_registry.mock';
|
||||
import {
|
||||
TestSecretsSchema,
|
||||
TestConfigSchema,
|
||||
TestConfig,
|
||||
TestSecrets,
|
||||
TestSubActionConnector,
|
||||
} from './mocks';
|
||||
import { register } from './register';
|
||||
|
||||
describe('Registration', () => {
|
||||
const connector = {
|
||||
id: '.test',
|
||||
name: 'Test',
|
||||
minimumLicenseRequired: 'basic' as const,
|
||||
schema: {
|
||||
config: TestConfigSchema,
|
||||
secrets: TestSecretsSchema,
|
||||
},
|
||||
Service: TestSubActionConnector,
|
||||
};
|
||||
|
||||
const actionTypeRegistry = actionTypeRegistryMock.create();
|
||||
const mockedActionsConfig = actionsConfigMock.create();
|
||||
const logger = loggingSystemMock.createLogger();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('registers the connector correctly', async () => {
|
||||
register<TestConfig, TestSecrets>({
|
||||
actionTypeRegistry,
|
||||
connector,
|
||||
configurationUtilities: mockedActionsConfig,
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(actionTypeRegistry.register).toHaveBeenCalledTimes(1);
|
||||
expect(actionTypeRegistry.register).toHaveBeenCalledWith({
|
||||
id: connector.id,
|
||||
name: connector.name,
|
||||
minimumLicenseRequired: connector.minimumLicenseRequired,
|
||||
validate: expect.anything(),
|
||||
executor: expect.anything(),
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,57 @@
|
|||
/*
|
||||
* 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 { PublicMethodsOf } from '@kbn/utility-types';
|
||||
import { Logger } from '@kbn/core/server';
|
||||
import { ActionsConfigurationUtilities } from '../actions_config';
|
||||
import { ActionTypeRegistry } from '../action_type_registry';
|
||||
import { SubActionConnector } from './sub_action_connector';
|
||||
import { CaseConnector } from './case';
|
||||
import { ActionTypeConfig, ActionTypeSecrets } from '../types';
|
||||
import { buildExecutor } from './executor';
|
||||
import { ExecutorParams, SubActionConnectorType, IService } from './types';
|
||||
import { buildValidators } from './validators';
|
||||
|
||||
const validateService = <Config, Secrets>(Service: IService<Config, Secrets>) => {
|
||||
if (
|
||||
!(Service.prototype instanceof CaseConnector) &&
|
||||
!(Service.prototype instanceof SubActionConnector)
|
||||
) {
|
||||
throw new Error(
|
||||
'Service must be extend one of the abstract classes: SubActionConnector or CaseConnector'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const register = <Config extends ActionTypeConfig, Secrets extends ActionTypeSecrets>({
|
||||
actionTypeRegistry,
|
||||
connector,
|
||||
logger,
|
||||
configurationUtilities,
|
||||
}: {
|
||||
configurationUtilities: ActionsConfigurationUtilities;
|
||||
actionTypeRegistry: PublicMethodsOf<ActionTypeRegistry>;
|
||||
connector: SubActionConnectorType<Config, Secrets>;
|
||||
logger: Logger;
|
||||
}) => {
|
||||
validateService(connector.Service);
|
||||
|
||||
const validators = buildValidators<Config, Secrets>({ connector, configurationUtilities });
|
||||
const executor = buildExecutor({
|
||||
connector,
|
||||
logger,
|
||||
configurationUtilities,
|
||||
});
|
||||
|
||||
actionTypeRegistry.register<Config, Secrets, ExecutorParams, unknown>({
|
||||
id: connector.id,
|
||||
name: connector.name,
|
||||
minimumLicenseRequired: connector.minimumLicenseRequired,
|
||||
validate: validators,
|
||||
executor,
|
||||
});
|
||||
};
|
|
@ -0,0 +1,343 @@
|
|||
/*
|
||||
* 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 { Agent as HttpsAgent } from 'https';
|
||||
import HttpProxyAgent from 'http-proxy-agent';
|
||||
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||
import axios, { AxiosError, AxiosInstance, AxiosResponse } from 'axios';
|
||||
import { loggingSystemMock } from '@kbn/core/server/mocks';
|
||||
import { MockedLogger } from '@kbn/logging-mocks';
|
||||
import { actionsConfigMock } from '../actions_config.mock';
|
||||
import { actionsMock } from '../mocks';
|
||||
import { TestSubActionConnector } from './mocks';
|
||||
import { getCustomAgents } from '../builtin_action_types/lib/get_custom_agents';
|
||||
import { ActionsConfigurationUtilities } from '../actions_config';
|
||||
|
||||
jest.mock('axios');
|
||||
const axiosMock = axios as jest.Mocked<typeof axios>;
|
||||
|
||||
const createAxiosError = (): AxiosError => {
|
||||
const error = new Error() as AxiosError;
|
||||
error.isAxiosError = true;
|
||||
error.config = { method: 'get', url: 'https://example.com' };
|
||||
error.response = {
|
||||
data: { errorMessage: 'An error occurred', errorCode: 500 },
|
||||
} as AxiosResponse;
|
||||
|
||||
return error;
|
||||
};
|
||||
|
||||
describe('SubActionConnector', () => {
|
||||
const axiosInstanceMock = jest.fn();
|
||||
let logger: MockedLogger;
|
||||
let services: ReturnType<typeof actionsMock.createServices>;
|
||||
let mockedActionsConfig: jest.Mocked<ActionsConfigurationUtilities>;
|
||||
let service: TestSubActionConnector;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.clearAllMocks();
|
||||
|
||||
axiosInstanceMock.mockReturnValue({ data: { status: 'ok' } });
|
||||
axiosMock.create.mockImplementation(() => {
|
||||
return axiosInstanceMock as unknown as AxiosInstance;
|
||||
});
|
||||
|
||||
logger = loggingSystemMock.createLogger();
|
||||
services = actionsMock.createServices();
|
||||
mockedActionsConfig = actionsConfigMock.create();
|
||||
|
||||
mockedActionsConfig.getResponseSettings.mockReturnValue({
|
||||
maxContentLength: 1000000,
|
||||
timeout: 360000,
|
||||
});
|
||||
|
||||
service = new TestSubActionConnector({
|
||||
configurationUtilities: mockedActionsConfig,
|
||||
logger,
|
||||
connector: { id: 'test-id', type: '.test' },
|
||||
config: { url: 'https://example.com' },
|
||||
secrets: { username: 'elastic', password: 'changeme' },
|
||||
services,
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sub actions', () => {
|
||||
it('gets the sub actions correctly', async () => {
|
||||
const subActions = service.getSubActions();
|
||||
expect(subActions.get('testUrl')).toEqual({
|
||||
method: 'testUrl',
|
||||
name: 'testUrl',
|
||||
schema: expect.anything(),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('URL validation', () => {
|
||||
it('removes double slashes correctly', async () => {
|
||||
await service.testUrl({ url: 'https://example.com//api///test-endpoint' });
|
||||
expect(axiosInstanceMock.mock.calls[0][0]).toBe('https://example.com/api/test-endpoint');
|
||||
});
|
||||
|
||||
it('removes the ending slash correctly', async () => {
|
||||
await service.testUrl({ url: 'https://example.com/' });
|
||||
expect(axiosInstanceMock.mock.calls[0][0]).toBe('https://example.com');
|
||||
});
|
||||
|
||||
it('throws an error if the url is invalid', async () => {
|
||||
expect.assertions(1);
|
||||
await expect(async () => service.testUrl({ url: 'invalid-url' })).rejects.toThrow(
|
||||
'URL Error: Invalid URL: invalid-url'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws an error if the url starts with backslashes', async () => {
|
||||
expect.assertions(1);
|
||||
await expect(async () => service.testUrl({ url: '//example.com/foo' })).rejects.toThrow(
|
||||
'URL Error: Invalid URL: //example.com/foo'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws an error if the protocol is not supported', async () => {
|
||||
expect.assertions(1);
|
||||
await expect(async () => service.testUrl({ url: 'ftp://example.com' })).rejects.toThrow(
|
||||
'URL Error: Invalid protocol'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws if the host is the URI is not allowed', async () => {
|
||||
expect.assertions(1);
|
||||
|
||||
mockedActionsConfig.ensureUriAllowed.mockImplementation(() => {
|
||||
throw new Error('URI is not allowed');
|
||||
});
|
||||
|
||||
await expect(async () => service.testUrl({ url: 'https://example.com' })).rejects.toThrow(
|
||||
'error configuring connector action: URI is not allowed'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Data', () => {
|
||||
it('sets data to an empty object if the data are null', async () => {
|
||||
await service.testUrl({ url: 'https://example.com', data: null });
|
||||
|
||||
expect(axiosInstanceMock).toHaveBeenCalledTimes(1);
|
||||
const { data } = axiosInstanceMock.mock.calls[0][1];
|
||||
expect(data).toEqual({});
|
||||
});
|
||||
|
||||
it('pass data to axios correctly if not null', async () => {
|
||||
await service.testUrl({ url: 'https://example.com', data: { foo: 'foo' } });
|
||||
|
||||
expect(axiosInstanceMock).toHaveBeenCalledTimes(1);
|
||||
const { data } = axiosInstanceMock.mock.calls[0][1];
|
||||
expect(data).toEqual({ foo: 'foo' });
|
||||
});
|
||||
|
||||
it('removeNullOrUndefinedFields: removes null values and undefined values correctly', async () => {
|
||||
await service.testData({ data: { foo: 'foo', bar: null, baz: undefined } });
|
||||
|
||||
expect(axiosInstanceMock).toHaveBeenCalledTimes(1);
|
||||
const { data } = axiosInstanceMock.mock.calls[0][1];
|
||||
expect(data).toEqual({ foo: 'foo' });
|
||||
});
|
||||
|
||||
it.each([[null], [undefined], [[]], [() => {}], [new Date()]])(
|
||||
'removeNullOrUndefinedFields: returns data if it is not an object',
|
||||
async (dataToTest) => {
|
||||
// @ts-expect-error
|
||||
await service.testData({ data: dataToTest });
|
||||
|
||||
const { data } = axiosInstanceMock.mock.calls[0][1];
|
||||
expect(data).toEqual({});
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('Fetching', () => {
|
||||
it('fetch correctly', async () => {
|
||||
const res = await service.testUrl({ url: 'https://example.com' });
|
||||
|
||||
expect(axiosInstanceMock).toHaveBeenCalledTimes(1);
|
||||
expect(axiosInstanceMock).toBeCalledWith('https://example.com', {
|
||||
method: 'get',
|
||||
data: {},
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Test-Header': 'test',
|
||||
},
|
||||
httpAgent: undefined,
|
||||
httpsAgent: expect.any(HttpsAgent),
|
||||
proxy: false,
|
||||
maxContentLength: 1000000,
|
||||
timeout: 360000,
|
||||
});
|
||||
|
||||
expect(logger.debug).toBeCalledWith(
|
||||
'Request to external service. Connector Id: test-id. Connector type: .test Method: get. URL: https://example.com'
|
||||
);
|
||||
|
||||
expect(res).toEqual({ data: { status: 'ok' } });
|
||||
});
|
||||
|
||||
it('validates the response correctly', async () => {
|
||||
axiosInstanceMock.mockReturnValue({ data: { invalidField: 'test' } });
|
||||
await expect(async () => service.testUrl({ url: 'https://example.com' })).rejects.toThrow(
|
||||
'Response validation failed (Error: [status]: expected value of type [string] but got [undefined])'
|
||||
);
|
||||
});
|
||||
|
||||
it('formats the response error correctly', async () => {
|
||||
axiosInstanceMock.mockImplementation(() => {
|
||||
throw createAxiosError();
|
||||
});
|
||||
|
||||
await expect(async () => service.testUrl({ url: 'https://example.com' })).rejects.toThrow(
|
||||
'Message: An error occurred. Code: 500'
|
||||
);
|
||||
|
||||
expect(logger.debug).toHaveBeenLastCalledWith(
|
||||
'Request to external service failed. Connector Id: test-id. Connector type: .test. Method: get. URL: https://example.com'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Proxy', () => {
|
||||
it('have been called with proper proxy agent for a valid url', async () => {
|
||||
mockedActionsConfig.getProxySettings.mockReturnValue({
|
||||
proxySSLSettings: {
|
||||
verificationMode: 'full',
|
||||
},
|
||||
proxyUrl: 'https://localhost:1212',
|
||||
proxyBypassHosts: undefined,
|
||||
proxyOnlyHosts: undefined,
|
||||
});
|
||||
|
||||
const { httpAgent, httpsAgent } = getCustomAgents(
|
||||
mockedActionsConfig,
|
||||
logger,
|
||||
'https://example.com'
|
||||
);
|
||||
|
||||
await service.testUrl({ url: 'https://example.com' });
|
||||
|
||||
expect(axiosInstanceMock).toBeCalledWith('https://example.com', {
|
||||
method: 'get',
|
||||
data: {},
|
||||
headers: {
|
||||
'X-Test-Header': 'test',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
httpAgent,
|
||||
httpsAgent,
|
||||
proxy: false,
|
||||
maxContentLength: 1000000,
|
||||
timeout: 360000,
|
||||
});
|
||||
});
|
||||
|
||||
it('have been called with proper proxy agent for an invalid url', async () => {
|
||||
mockedActionsConfig.getProxySettings.mockReturnValue({
|
||||
proxyUrl: ':nope:',
|
||||
proxySSLSettings: {
|
||||
verificationMode: 'none',
|
||||
},
|
||||
proxyBypassHosts: undefined,
|
||||
proxyOnlyHosts: undefined,
|
||||
});
|
||||
|
||||
await service.testUrl({ url: 'https://example.com' });
|
||||
|
||||
expect(axiosInstanceMock).toBeCalledWith('https://example.com', {
|
||||
method: 'get',
|
||||
data: {},
|
||||
headers: {
|
||||
'X-Test-Header': 'test',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
httpAgent: undefined,
|
||||
httpsAgent: expect.any(HttpsAgent),
|
||||
proxy: false,
|
||||
maxContentLength: 1000000,
|
||||
timeout: 360000,
|
||||
});
|
||||
});
|
||||
|
||||
it('bypasses with proxyBypassHosts when expected', async () => {
|
||||
mockedActionsConfig.getProxySettings.mockReturnValue({
|
||||
proxySSLSettings: {
|
||||
verificationMode: 'full',
|
||||
},
|
||||
proxyUrl: 'https://elastic.proxy.co',
|
||||
proxyBypassHosts: new Set(['example.com']),
|
||||
proxyOnlyHosts: undefined,
|
||||
});
|
||||
|
||||
await service.testUrl({ url: 'https://example.com' });
|
||||
|
||||
expect(axiosInstanceMock).toHaveBeenCalledTimes(1);
|
||||
const { httpAgent, httpsAgent } = axiosInstanceMock.mock.calls[0][1];
|
||||
expect(httpAgent instanceof HttpProxyAgent).toBe(false);
|
||||
expect(httpsAgent instanceof HttpsProxyAgent).toBe(false);
|
||||
});
|
||||
|
||||
it('does not bypass with proxyBypassHosts when expected', async () => {
|
||||
mockedActionsConfig.getProxySettings.mockReturnValue({
|
||||
proxySSLSettings: {
|
||||
verificationMode: 'full',
|
||||
},
|
||||
proxyUrl: 'https://elastic.proxy.co',
|
||||
proxyBypassHosts: new Set(['not-example.com']),
|
||||
proxyOnlyHosts: undefined,
|
||||
});
|
||||
|
||||
await service.testUrl({ url: 'https://example.com' });
|
||||
|
||||
expect(axiosInstanceMock).toHaveBeenCalledTimes(1);
|
||||
const { httpAgent, httpsAgent } = axiosInstanceMock.mock.calls[0][1];
|
||||
expect(httpAgent instanceof HttpProxyAgent).toBe(true);
|
||||
expect(httpsAgent instanceof HttpsProxyAgent).toBe(true);
|
||||
});
|
||||
|
||||
it('proxies with proxyOnlyHosts when expected', async () => {
|
||||
mockedActionsConfig.getProxySettings.mockReturnValue({
|
||||
proxySSLSettings: {
|
||||
verificationMode: 'full',
|
||||
},
|
||||
proxyUrl: 'https://elastic.proxy.co',
|
||||
proxyBypassHosts: undefined,
|
||||
proxyOnlyHosts: new Set(['example.com']),
|
||||
});
|
||||
|
||||
await service.testUrl({ url: 'https://example.com' });
|
||||
|
||||
expect(axiosInstanceMock).toHaveBeenCalledTimes(1);
|
||||
const { httpAgent, httpsAgent } = axiosInstanceMock.mock.calls[0][1];
|
||||
expect(httpAgent instanceof HttpProxyAgent).toBe(true);
|
||||
expect(httpsAgent instanceof HttpsProxyAgent).toBe(true);
|
||||
});
|
||||
|
||||
it('does not proxy with proxyOnlyHosts when expected', async () => {
|
||||
mockedActionsConfig.getProxySettings.mockReturnValue({
|
||||
proxySSLSettings: {
|
||||
verificationMode: 'full',
|
||||
},
|
||||
proxyUrl: 'https://elastic.proxy.co',
|
||||
proxyBypassHosts: undefined,
|
||||
proxyOnlyHosts: new Set(['not-example.com']),
|
||||
});
|
||||
|
||||
await service.testUrl({ url: 'https://example.com' });
|
||||
|
||||
expect(axiosInstanceMock).toHaveBeenCalledTimes(1);
|
||||
const { httpAgent, httpsAgent } = axiosInstanceMock.mock.calls[0][1];
|
||||
expect(httpAgent instanceof HttpProxyAgent).toBe(false);
|
||||
expect(httpsAgent instanceof HttpsProxyAgent).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,175 @@
|
|||
/*
|
||||
* 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 { isPlainObject, isEmpty } from 'lodash';
|
||||
import { Type } from '@kbn/config-schema';
|
||||
import { Logger } from '@kbn/logging';
|
||||
import axios, {
|
||||
AxiosInstance,
|
||||
AxiosRequestConfig,
|
||||
AxiosResponse,
|
||||
Method,
|
||||
AxiosError,
|
||||
AxiosRequestHeaders,
|
||||
} from 'axios';
|
||||
import { ActionsConfigurationUtilities } from '../actions_config';
|
||||
import { getCustomAgents } from '../builtin_action_types/lib/get_custom_agents';
|
||||
import { SubAction } from './types';
|
||||
import { ServiceParams } from './types';
|
||||
import * as i18n from './translations';
|
||||
|
||||
const isObject = (value: unknown): value is Record<string, unknown> => {
|
||||
return isPlainObject(value);
|
||||
};
|
||||
|
||||
const isAxiosError = (error: unknown): error is AxiosError => (error as AxiosError).isAxiosError;
|
||||
|
||||
export abstract class SubActionConnector<Config, Secrets> {
|
||||
[k: string]: ((params: unknown) => unknown) | unknown;
|
||||
private axiosInstance: AxiosInstance;
|
||||
private validProtocols: string[] = ['http:', 'https:'];
|
||||
private subActions: Map<string, SubAction> = new Map();
|
||||
private configurationUtilities: ActionsConfigurationUtilities;
|
||||
protected logger: Logger;
|
||||
protected connector: ServiceParams<Config, Secrets>['connector'];
|
||||
protected config: Config;
|
||||
protected secrets: Secrets;
|
||||
|
||||
constructor(params: ServiceParams<Config, Secrets>) {
|
||||
this.connector = params.connector;
|
||||
this.logger = params.logger;
|
||||
this.config = params.config;
|
||||
this.secrets = params.secrets;
|
||||
this.configurationUtilities = params.configurationUtilities;
|
||||
this.axiosInstance = axios.create();
|
||||
}
|
||||
|
||||
private normalizeURL(url: string) {
|
||||
const urlWithoutTrailingSlash = url.endsWith('/') ? url.slice(0, -1) : url;
|
||||
const replaceDoubleSlashesRegex = new RegExp('([^:]/)/+', 'g');
|
||||
return urlWithoutTrailingSlash.replace(replaceDoubleSlashesRegex, '$1');
|
||||
}
|
||||
|
||||
private normalizeData(data: unknown | undefined | null) {
|
||||
if (isEmpty(data)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
private assertURL(url: string) {
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
|
||||
if (!parsedUrl.hostname) {
|
||||
throw new Error('URL must contain hostname');
|
||||
}
|
||||
|
||||
if (!this.validProtocols.includes(parsedUrl.protocol)) {
|
||||
throw new Error('Invalid protocol');
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`URL Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private ensureUriAllowed(url: string) {
|
||||
try {
|
||||
this.configurationUtilities.ensureUriAllowed(url);
|
||||
} catch (allowedListError) {
|
||||
throw new Error(i18n.ALLOWED_HOSTS_ERROR(allowedListError.message));
|
||||
}
|
||||
}
|
||||
|
||||
private getHeaders(headers?: AxiosRequestHeaders) {
|
||||
return { ...headers, 'Content-Type': 'application/json' };
|
||||
}
|
||||
|
||||
private validateResponse(responseSchema: Type<unknown>, data: unknown) {
|
||||
try {
|
||||
responseSchema.validate(data);
|
||||
} catch (resValidationError) {
|
||||
throw new Error(`Response validation failed (${resValidationError})`);
|
||||
}
|
||||
}
|
||||
|
||||
protected registerSubAction(subAction: SubAction) {
|
||||
this.subActions.set(subAction.name, subAction);
|
||||
}
|
||||
|
||||
protected removeNullOrUndefinedFields(data: unknown | undefined) {
|
||||
if (isObject(data)) {
|
||||
return Object.fromEntries(Object.entries(data).filter(([_, value]) => value != null));
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
public getSubActions() {
|
||||
return this.subActions;
|
||||
}
|
||||
|
||||
protected abstract getResponseErrorMessage(error: AxiosError): string;
|
||||
|
||||
protected async request<R>({
|
||||
url,
|
||||
data,
|
||||
method = 'get',
|
||||
responseSchema,
|
||||
headers,
|
||||
...config
|
||||
}: {
|
||||
url: string;
|
||||
responseSchema: Type<R>;
|
||||
method?: Method;
|
||||
} & AxiosRequestConfig): Promise<AxiosResponse<R>> {
|
||||
try {
|
||||
this.assertURL(url);
|
||||
this.ensureUriAllowed(url);
|
||||
const normalizedURL = this.normalizeURL(url);
|
||||
|
||||
const { httpAgent, httpsAgent } = getCustomAgents(
|
||||
this.configurationUtilities,
|
||||
this.logger,
|
||||
url
|
||||
);
|
||||
const { maxContentLength, timeout } = this.configurationUtilities.getResponseSettings();
|
||||
|
||||
this.logger.debug(
|
||||
`Request to external service. Connector Id: ${this.connector.id}. Connector type: ${this.connector.type} Method: ${method}. URL: ${normalizedURL}`
|
||||
);
|
||||
const res = await this.axiosInstance(normalizedURL, {
|
||||
...config,
|
||||
method,
|
||||
headers: this.getHeaders(headers),
|
||||
data: this.normalizeData(data),
|
||||
// use httpAgent and httpsAgent and set axios proxy: false, to be able to handle fail on invalid certs
|
||||
httpAgent,
|
||||
httpsAgent,
|
||||
proxy: false,
|
||||
maxContentLength,
|
||||
timeout,
|
||||
});
|
||||
|
||||
this.validateResponse(responseSchema, res.data);
|
||||
|
||||
return res;
|
||||
} catch (error) {
|
||||
if (isAxiosError(error)) {
|
||||
this.logger.debug(
|
||||
`Request to external service failed. Connector Id: ${this.connector.id}. Connector type: ${this.connector.type}. Method: ${error.config.method}. URL: ${error.config.url}`
|
||||
);
|
||||
|
||||
const errorMessage = this.getResponseErrorMessage(error);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
export const NAME = i18n.translate('xpack.actions.builtin.cases.jiraTitle', {
|
||||
defaultMessage: 'Jira',
|
||||
});
|
||||
|
||||
export const ALLOWED_HOSTS_ERROR = (message: string) =>
|
||||
i18n.translate('xpack.actions.apiAllowedHostsError', {
|
||||
defaultMessage: 'error configuring connector action: {message}',
|
||||
values: {
|
||||
message,
|
||||
},
|
||||
});
|
83
x-pack/plugins/actions/server/sub_action_framework/types.ts
Normal file
83
x-pack/plugins/actions/server/sub_action_framework/types.ts
Normal file
|
@ -0,0 +1,83 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import type { Type } from '@kbn/config-schema';
|
||||
import { Logger } from '@kbn/logging';
|
||||
import type { LicenseType } from '@kbn/licensing-plugin/common/types';
|
||||
|
||||
import { ActionsConfigurationUtilities } from '../actions_config';
|
||||
import { ActionTypeParams, Services } from '../types';
|
||||
import { SubActionConnector } from './sub_action_connector';
|
||||
|
||||
export interface ServiceParams<Config, Secrets> {
|
||||
/**
|
||||
* The type is the connector type id. For example ".servicenow"
|
||||
* The id is the connector's SavedObject UUID.
|
||||
*/
|
||||
connector: { id: string; type: string };
|
||||
config: Config;
|
||||
configurationUtilities: ActionsConfigurationUtilities;
|
||||
logger: Logger;
|
||||
secrets: Secrets;
|
||||
services: Services;
|
||||
}
|
||||
|
||||
export type IService<Config, Secrets> = new (
|
||||
params: ServiceParams<Config, Secrets>
|
||||
) => SubActionConnector<Config, Secrets>;
|
||||
|
||||
export type IServiceAbstract<Config, Secrets> = abstract new (
|
||||
params: ServiceParams<Config, Secrets>
|
||||
) => SubActionConnector<Config, Secrets>;
|
||||
|
||||
export interface SubActionConnectorType<Config, Secrets> {
|
||||
id: string;
|
||||
name: string;
|
||||
minimumLicenseRequired: LicenseType;
|
||||
schema: {
|
||||
config: Type<Config>;
|
||||
secrets: Type<Secrets>;
|
||||
};
|
||||
Service: IService<Config, Secrets>;
|
||||
}
|
||||
|
||||
export interface ExecutorParams extends ActionTypeParams {
|
||||
subAction: string;
|
||||
subActionParams: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type ExtractFunctionKeys<T> = {
|
||||
[P in keyof T]-?: T[P] extends Function ? P : never;
|
||||
}[keyof T];
|
||||
|
||||
export interface SubAction {
|
||||
name: string;
|
||||
method: string;
|
||||
schema: Type<unknown> | null;
|
||||
}
|
||||
|
||||
export interface PushToServiceParams {
|
||||
externalId: string | null;
|
||||
comments: Array<{ commentId: string; comment: string }>;
|
||||
[x: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ExternalServiceIncidentResponse {
|
||||
id: string;
|
||||
title: string;
|
||||
url: string;
|
||||
pushedDate: string;
|
||||
}
|
||||
|
||||
export interface ExternalServiceCommentResponse {
|
||||
commentId: string;
|
||||
pushedDate: string;
|
||||
externalCommentId?: string;
|
||||
}
|
||||
export interface PushToServiceResponse extends ExternalServiceIncidentResponse {
|
||||
comments?: ExternalServiceCommentResponse[];
|
||||
}
|
|
@ -0,0 +1,98 @@
|
|||
/*
|
||||
* 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 { ActionsConfigurationUtilities } from '../actions_config';
|
||||
import { actionsConfigMock } from '../actions_config.mock';
|
||||
import {
|
||||
TestSecretsSchema,
|
||||
TestConfigSchema,
|
||||
TestConfig,
|
||||
TestSecrets,
|
||||
TestSubActionConnector,
|
||||
} from './mocks';
|
||||
import { IService } from './types';
|
||||
import { buildValidators } from './validators';
|
||||
|
||||
describe('Validators', () => {
|
||||
let mockedActionsConfig: jest.Mocked<ActionsConfigurationUtilities>;
|
||||
|
||||
const createValidator = (Service: IService<TestConfig, TestSecrets>) => {
|
||||
const connector = {
|
||||
id: '.test',
|
||||
name: 'Test',
|
||||
minimumLicenseRequired: 'basic' as const,
|
||||
schema: {
|
||||
config: TestConfigSchema,
|
||||
secrets: TestSecretsSchema,
|
||||
},
|
||||
Service,
|
||||
};
|
||||
|
||||
return buildValidators({ configurationUtilities: mockedActionsConfig, connector });
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
jest.clearAllMocks();
|
||||
|
||||
mockedActionsConfig = actionsConfigMock.create();
|
||||
});
|
||||
|
||||
it('should create the config and secrets validators correctly', async () => {
|
||||
const validator = createValidator(TestSubActionConnector);
|
||||
const { config, secrets } = validator;
|
||||
|
||||
expect(config).toEqual(TestConfigSchema);
|
||||
expect(secrets).toEqual(TestSecretsSchema);
|
||||
});
|
||||
|
||||
it('should validate the params correctly', async () => {
|
||||
const validator = createValidator(TestSubActionConnector);
|
||||
const { params } = validator;
|
||||
expect(params.validate({ subAction: 'test', subActionParams: {} }));
|
||||
});
|
||||
|
||||
it('should allow any field in subActionParams', async () => {
|
||||
const validator = createValidator(TestSubActionConnector);
|
||||
const { params } = validator;
|
||||
expect(
|
||||
params.validate({
|
||||
subAction: 'test',
|
||||
subActionParams: {
|
||||
foo: 'foo',
|
||||
bar: 1,
|
||||
baz: [{ test: 'hello' }, 1, 'test', false],
|
||||
isValid: false,
|
||||
val: null,
|
||||
},
|
||||
})
|
||||
).toEqual({
|
||||
subAction: 'test',
|
||||
subActionParams: {
|
||||
foo: 'foo',
|
||||
bar: 1,
|
||||
baz: [{ test: 'hello' }, 1, 'test', false],
|
||||
isValid: false,
|
||||
val: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[undefined],
|
||||
[null],
|
||||
[1],
|
||||
[false],
|
||||
[{ test: 'hello' }],
|
||||
[['test']],
|
||||
[{ test: 'hello' }],
|
||||
])('should throw if the subAction is %p', async (subAction) => {
|
||||
const validator = createValidator(TestSubActionConnector);
|
||||
const { params } = validator;
|
||||
expect(() => params.validate({ subAction, subActionParams: {} })).toThrow();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { schema } from '@kbn/config-schema';
|
||||
import { ActionsConfigurationUtilities } from '../actions_config';
|
||||
import { ActionTypeConfig, ActionTypeSecrets } from '../types';
|
||||
import { SubActionConnectorType } from './types';
|
||||
|
||||
export const buildValidators = <
|
||||
Config extends ActionTypeConfig,
|
||||
Secrets extends ActionTypeSecrets
|
||||
>({
|
||||
connector,
|
||||
configurationUtilities,
|
||||
}: {
|
||||
configurationUtilities: ActionsConfigurationUtilities;
|
||||
connector: SubActionConnectorType<Config, Secrets>;
|
||||
}) => {
|
||||
return {
|
||||
config: connector.schema.config,
|
||||
secrets: connector.schema.secrets,
|
||||
params: schema.object({
|
||||
subAction: schema.string(),
|
||||
/**
|
||||
* With this validation we enforce the subActionParams to be an object.
|
||||
* Each sub action has different parameters and they are validated inside the executor
|
||||
* (x-pack/plugins/actions/server/sub_action_framework/executor.ts). For that reason,
|
||||
* we allow all unknowns at this level of validation as they are not known at this
|
||||
* time of execution.
|
||||
*/
|
||||
subActionParams: schema.object({}, { unknowns: 'allow' }),
|
||||
}),
|
||||
};
|
||||
};
|
|
@ -43,6 +43,8 @@ const enabledActionTypes = [
|
|||
'.slack',
|
||||
'.webhook',
|
||||
'.xmatters',
|
||||
'.test-sub-action-connector',
|
||||
'.test-sub-action-connector-without-sub-actions',
|
||||
'test.authorization',
|
||||
'test.failing',
|
||||
'test.index-record',
|
||||
|
|
|
@ -9,6 +9,10 @@ import { CoreSetup } from '@kbn/core/server';
|
|||
import { schema, TypeOf } from '@kbn/config-schema';
|
||||
import { ActionType } from '@kbn/actions-plugin/server';
|
||||
import { FixtureStartDeps, FixtureSetupDeps } from './plugin';
|
||||
import {
|
||||
getTestSubActionConnector,
|
||||
getTestSubActionConnectorWithoutSubActions,
|
||||
} from './sub_action_connector';
|
||||
|
||||
export function defineActionTypes(
|
||||
core: CoreSetup<FixtureStartDeps>,
|
||||
|
@ -23,6 +27,7 @@ export function defineActionTypes(
|
|||
return { status: 'ok', actionId: '' };
|
||||
},
|
||||
};
|
||||
|
||||
const throwActionType: ActionType = {
|
||||
id: 'test.throw',
|
||||
name: 'Test: Throw',
|
||||
|
@ -31,6 +36,7 @@ export function defineActionTypes(
|
|||
throw new Error('this action is intended to fail');
|
||||
},
|
||||
};
|
||||
|
||||
const cappedActionType: ActionType = {
|
||||
id: 'test.capped',
|
||||
name: 'Test: Capped',
|
||||
|
@ -39,6 +45,7 @@ export function defineActionTypes(
|
|||
return { status: 'ok', actionId: '' };
|
||||
},
|
||||
};
|
||||
|
||||
actions.registerType(noopActionType);
|
||||
actions.registerType(throwActionType);
|
||||
actions.registerType(cappedActionType);
|
||||
|
@ -49,6 +56,11 @@ export function defineActionTypes(
|
|||
actions.registerType(getNoAttemptsRateLimitedActionType());
|
||||
actions.registerType(getAuthorizationActionType(core));
|
||||
actions.registerType(getExcludedActionType());
|
||||
|
||||
/** Sub action framework */
|
||||
|
||||
actions.registerSubActionConnectorType(getTestSubActionConnector(actions));
|
||||
actions.registerSubActionConnectorType(getTestSubActionConnectorWithoutSubActions(actions));
|
||||
}
|
||||
|
||||
function getIndexRecordActionType() {
|
||||
|
|
|
@ -0,0 +1,109 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line max-classes-per-file
|
||||
import { AxiosError } from 'axios';
|
||||
import type { ServiceParams } from '@kbn/actions-plugin/server';
|
||||
import { PluginSetupContract as ActionsPluginSetup } from '@kbn/actions-plugin/server/plugin';
|
||||
import { schema, TypeOf } from '@kbn/config-schema';
|
||||
import { SubActionConnectorType } from '@kbn/actions-plugin/server/sub_action_framework/types';
|
||||
|
||||
const TestConfigSchema = schema.object({ url: schema.string() });
|
||||
const TestSecretsSchema = schema.object({
|
||||
username: schema.string(),
|
||||
password: schema.string(),
|
||||
});
|
||||
|
||||
type TestConfig = TypeOf<typeof TestConfigSchema>;
|
||||
type TestSecrets = TypeOf<typeof TestSecretsSchema>;
|
||||
|
||||
interface ErrorSchema {
|
||||
errorMessage: string;
|
||||
errorCode: number;
|
||||
}
|
||||
|
||||
export const getTestSubActionConnector = (
|
||||
actions: ActionsPluginSetup
|
||||
): SubActionConnectorType<TestConfig, TestSecrets> => {
|
||||
const SubActionConnector = actions.getSubActionConnectorClass<TestConfig, TestSecrets>();
|
||||
|
||||
class TestSubActionConnector extends SubActionConnector {
|
||||
constructor(params: ServiceParams<TestConfig, TestSecrets>) {
|
||||
super(params);
|
||||
this.registerSubAction({
|
||||
name: 'subActionWithParams',
|
||||
method: 'subActionWithParams',
|
||||
schema: schema.object({ id: schema.string() }),
|
||||
});
|
||||
|
||||
this.registerSubAction({
|
||||
name: 'subActionWithoutParams',
|
||||
method: 'subActionWithoutParams',
|
||||
schema: null,
|
||||
});
|
||||
|
||||
this.registerSubAction({
|
||||
name: 'notExist',
|
||||
method: 'notExist',
|
||||
schema: schema.object({}),
|
||||
});
|
||||
|
||||
this.registerSubAction({
|
||||
name: 'notAFunction',
|
||||
method: 'notAFunction',
|
||||
schema: schema.object({}),
|
||||
});
|
||||
|
||||
this.registerSubAction({
|
||||
name: 'noData',
|
||||
method: 'noData',
|
||||
schema: null,
|
||||
});
|
||||
}
|
||||
|
||||
protected getResponseErrorMessage(error: AxiosError<ErrorSchema>) {
|
||||
return `Message: ${error.response?.data.errorMessage}. Code: ${error.response?.data.errorCode}`;
|
||||
}
|
||||
|
||||
public async subActionWithParams({ id }: { id: string }) {
|
||||
return { id };
|
||||
}
|
||||
|
||||
public async subActionWithoutParams() {
|
||||
return { id: 'test' };
|
||||
}
|
||||
|
||||
public async noData() {}
|
||||
}
|
||||
return {
|
||||
id: '.test-sub-action-connector',
|
||||
name: 'Test: Sub action connector',
|
||||
minimumLicenseRequired: 'platinum' as const,
|
||||
schema: { config: TestConfigSchema, secrets: TestSecretsSchema },
|
||||
Service: TestSubActionConnector,
|
||||
};
|
||||
};
|
||||
|
||||
export const getTestSubActionConnectorWithoutSubActions = (
|
||||
actions: ActionsPluginSetup
|
||||
): SubActionConnectorType<TestConfig, TestSecrets> => {
|
||||
const SubActionConnector = actions.getSubActionConnectorClass<TestConfig, TestSecrets>();
|
||||
|
||||
class TestNoSubActions extends SubActionConnector {
|
||||
protected getResponseErrorMessage(error: AxiosError<ErrorSchema>) {
|
||||
return `Error`;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: '.test-sub-action-connector-without-sub-actions',
|
||||
name: 'Test: Sub action connector',
|
||||
minimumLicenseRequired: 'platinum' as const,
|
||||
schema: { config: TestConfigSchema, secrets: TestSecretsSchema },
|
||||
Service: TestNoSubActions,
|
||||
};
|
||||
};
|
|
@ -41,5 +41,10 @@ export default function actionsTests({ loadTestFile, getService }: FtrProviderCo
|
|||
loadTestFile(require.resolve('./get'));
|
||||
loadTestFile(require.resolve('./connector_types'));
|
||||
loadTestFile(require.resolve('./update'));
|
||||
|
||||
/**
|
||||
* Sub action framework
|
||||
*/
|
||||
loadTestFile(require.resolve('./sub_action_framework'));
|
||||
});
|
||||
}
|
||||
|
|
|
@ -0,0 +1,318 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import type SuperTest from 'supertest';
|
||||
import expect from '@kbn/expect';
|
||||
import { FtrProviderContext } from '../../../../../common/ftr_provider_context';
|
||||
import { getUrlPrefix, ObjectRemover } from '../../../../../common/lib';
|
||||
|
||||
/**
|
||||
* The sub action connector is defined here
|
||||
* x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/sub_action_connector.ts
|
||||
*/
|
||||
const createSubActionConnector = async ({
|
||||
supertest,
|
||||
config,
|
||||
secrets,
|
||||
connectorTypeId = '.test-sub-action-connector',
|
||||
expectedHttpCode = 200,
|
||||
}: {
|
||||
supertest: SuperTest.SuperTest<SuperTest.Test>;
|
||||
config?: Record<string, unknown>;
|
||||
secrets?: Record<string, unknown>;
|
||||
connectorTypeId?: string;
|
||||
expectedHttpCode?: number;
|
||||
}) => {
|
||||
const response = await supertest
|
||||
.post(`${getUrlPrefix('default')}/api/actions/connector`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({
|
||||
name: 'My sub connector',
|
||||
connector_type_id: connectorTypeId,
|
||||
config: {
|
||||
url: 'https://example.com',
|
||||
...config,
|
||||
},
|
||||
secrets: {
|
||||
username: 'elastic',
|
||||
password: 'changeme',
|
||||
...secrets,
|
||||
},
|
||||
})
|
||||
.expect(expectedHttpCode);
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
const executeSubAction = async ({
|
||||
supertest,
|
||||
connectorId,
|
||||
subAction,
|
||||
subActionParams,
|
||||
expectedHttpCode = 200,
|
||||
}: {
|
||||
supertest: SuperTest.SuperTest<SuperTest.Test>;
|
||||
connectorId: string;
|
||||
subAction: string;
|
||||
subActionParams: Record<string, unknown>;
|
||||
expectedHttpCode?: number;
|
||||
}) => {
|
||||
const response = await supertest
|
||||
.post(`${getUrlPrefix('default')}/api/actions/connector/${connectorId}/_execute`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({
|
||||
params: {
|
||||
subAction,
|
||||
subActionParams,
|
||||
},
|
||||
})
|
||||
.expect(expectedHttpCode);
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default function createActionTests({ getService }: FtrProviderContext) {
|
||||
const supertest = getService('supertest');
|
||||
|
||||
describe('Sub action framework', () => {
|
||||
const objectRemover = new ObjectRemover(supertest);
|
||||
after(() => objectRemover.removeAll());
|
||||
|
||||
describe('Create', () => {
|
||||
it('creates the sub action connector correctly', async () => {
|
||||
const res = await createSubActionConnector({ supertest });
|
||||
objectRemover.add('default', res.body.id, 'action', 'actions');
|
||||
|
||||
expect(res.body).to.eql({
|
||||
id: res.body.id,
|
||||
is_preconfigured: false,
|
||||
is_deprecated: false,
|
||||
is_missing_secrets: false,
|
||||
name: 'My sub connector',
|
||||
connector_type_id: '.test-sub-action-connector',
|
||||
config: {
|
||||
url: 'https://example.com',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Schema validation', () => {
|
||||
it('passes the config schema to the actions framework and validates correctly', async () => {
|
||||
const res = await createSubActionConnector({
|
||||
supertest,
|
||||
config: { foo: 'foo' },
|
||||
expectedHttpCode: 400,
|
||||
});
|
||||
|
||||
expect(res.body).to.eql({
|
||||
statusCode: 400,
|
||||
error: 'Bad Request',
|
||||
message: 'error validating action type config: [foo]: definition for this key is missing',
|
||||
});
|
||||
});
|
||||
|
||||
it('passes the secrets schema to the actions framework and validates correctly', async () => {
|
||||
const res = await createSubActionConnector({
|
||||
supertest,
|
||||
secrets: { foo: 'foo' },
|
||||
expectedHttpCode: 400,
|
||||
});
|
||||
|
||||
expect(res.body).to.eql({
|
||||
statusCode: 400,
|
||||
error: 'Bad Request',
|
||||
message:
|
||||
'error validating action type secrets: [foo]: definition for this key is missing',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sub actions', () => {
|
||||
it('executes a sub action with parameters correctly', async () => {
|
||||
const res = await createSubActionConnector({ supertest });
|
||||
objectRemover.add('default', res.body.id, 'action', 'actions');
|
||||
|
||||
const execRes = await executeSubAction({
|
||||
supertest,
|
||||
connectorId: res.body.id as string,
|
||||
subAction: 'subActionWithParams',
|
||||
subActionParams: { id: 'test-id' },
|
||||
});
|
||||
|
||||
expect(execRes.body).to.eql({
|
||||
status: 'ok',
|
||||
data: { id: 'test-id' },
|
||||
connector_id: res.body.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('validates the subParams correctly', async () => {
|
||||
const res = await createSubActionConnector({ supertest });
|
||||
objectRemover.add('default', res.body.id, 'action', 'actions');
|
||||
|
||||
const execRes = await executeSubAction({
|
||||
supertest,
|
||||
connectorId: res.body.id as string,
|
||||
subAction: 'subActionWithParams',
|
||||
subActionParams: { foo: 'foo' },
|
||||
});
|
||||
|
||||
expect(execRes.body).to.eql({
|
||||
status: 'error',
|
||||
message: 'an error occurred while running the action',
|
||||
retry: false,
|
||||
connector_id: res.body.id,
|
||||
service_message:
|
||||
'Request validation failed (Error: [id]: expected value of type [string] but got [undefined])',
|
||||
});
|
||||
});
|
||||
|
||||
it('validates correctly if the subActionParams is not an object', async () => {
|
||||
const res = await createSubActionConnector({ supertest });
|
||||
objectRemover.add('default', res.body.id, 'action', 'actions');
|
||||
|
||||
for (const subActionParams of ['foo', 1, true, null, ['bar']]) {
|
||||
const execRes = await executeSubAction({
|
||||
supertest,
|
||||
connectorId: res.body.id as string,
|
||||
subAction: 'subActionWithParams',
|
||||
// @ts-expect-error
|
||||
subActionParams,
|
||||
});
|
||||
|
||||
const { message, ...resWithoutMessage } = execRes.body;
|
||||
expect(resWithoutMessage).to.eql({
|
||||
status: 'error',
|
||||
retry: false,
|
||||
connector_id: res.body.id,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('should execute correctly without schema validation', async () => {
|
||||
const res = await createSubActionConnector({ supertest });
|
||||
objectRemover.add('default', res.body.id, 'action', 'actions');
|
||||
|
||||
const execRes = await executeSubAction({
|
||||
supertest,
|
||||
connectorId: res.body.id as string,
|
||||
subAction: 'subActionWithoutParams',
|
||||
subActionParams: {},
|
||||
});
|
||||
|
||||
expect(execRes.body).to.eql({
|
||||
status: 'ok',
|
||||
data: { id: 'test' },
|
||||
connector_id: res.body.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return an empty object if the func returns undefined', async () => {
|
||||
const res = await createSubActionConnector({ supertest });
|
||||
objectRemover.add('default', res.body.id, 'action', 'actions');
|
||||
|
||||
const execRes = await executeSubAction({
|
||||
supertest,
|
||||
connectorId: res.body.id as string,
|
||||
subAction: 'noData',
|
||||
subActionParams: {},
|
||||
});
|
||||
|
||||
expect(execRes.body).to.eql({
|
||||
status: 'ok',
|
||||
data: {},
|
||||
connector_id: res.body.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return an error if sub action is not registered', async () => {
|
||||
const res = await createSubActionConnector({ supertest });
|
||||
objectRemover.add('default', res.body.id, 'action', 'actions');
|
||||
|
||||
const execRes = await executeSubAction({
|
||||
supertest,
|
||||
connectorId: res.body.id as string,
|
||||
subAction: 'notRegistered',
|
||||
subActionParams: { foo: 'foo' },
|
||||
});
|
||||
|
||||
expect(execRes.body).to.eql({
|
||||
status: 'error',
|
||||
message: 'an error occurred while running the action',
|
||||
retry: false,
|
||||
connector_id: res.body.id,
|
||||
service_message: `Sub action \"notRegistered\" is not registered. Connector id: ${res.body.id}. Connector name: Test: Sub action connector. Connector type: .test-sub-action-connector`,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return an error if the registered method is not a function', async () => {
|
||||
const res = await createSubActionConnector({ supertest });
|
||||
objectRemover.add('default', res.body.id, 'action', 'actions');
|
||||
|
||||
const execRes = await executeSubAction({
|
||||
supertest,
|
||||
connectorId: res.body.id as string,
|
||||
subAction: 'notAFunction',
|
||||
subActionParams: { foo: 'foo' },
|
||||
});
|
||||
|
||||
expect(execRes.body).to.eql({
|
||||
status: 'error',
|
||||
message: 'an error occurred while running the action',
|
||||
retry: false,
|
||||
connector_id: res.body.id,
|
||||
service_message: `Method \"notAFunction\" does not exists in service. Sub action: \"notAFunction\". Connector id: ${res.body.id}. Connector name: Test: Sub action connector. Connector type: .test-sub-action-connector`,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return an error if the registered method does not exists', async () => {
|
||||
const res = await createSubActionConnector({ supertest });
|
||||
objectRemover.add('default', res.body.id, 'action', 'actions');
|
||||
|
||||
const execRes = await executeSubAction({
|
||||
supertest,
|
||||
connectorId: res.body.id as string,
|
||||
subAction: 'notExist',
|
||||
subActionParams: { foo: 'foo' },
|
||||
});
|
||||
|
||||
expect(execRes.body).to.eql({
|
||||
status: 'error',
|
||||
message: 'an error occurred while running the action',
|
||||
retry: false,
|
||||
connector_id: res.body.id,
|
||||
service_message: `Method \"notExist\" does not exists in service. Sub action: \"notExist\". Connector id: ${res.body.id}. Connector name: Test: Sub action connector. Connector type: .test-sub-action-connector`,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return an error if there are no sub actions registered', async () => {
|
||||
const res = await createSubActionConnector({
|
||||
supertest,
|
||||
connectorTypeId: '.test-sub-action-connector-without-sub-actions',
|
||||
});
|
||||
objectRemover.add('default', res.body.id, 'action', 'actions');
|
||||
|
||||
const execRes = await executeSubAction({
|
||||
supertest,
|
||||
connectorId: res.body.id as string,
|
||||
subAction: 'notRegistered',
|
||||
subActionParams: { foo: 'foo' },
|
||||
});
|
||||
|
||||
expect(execRes.body).to.eql({
|
||||
status: 'error',
|
||||
message: 'an error occurred while running the action',
|
||||
retry: false,
|
||||
connector_id: res.body.id,
|
||||
service_message: 'You should register at least one subAction for your connector type',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue