mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
License checks for actions plugin (#59070)
* Define minimum license required for each action type (#58668)
* Add minimum required license
* Require at least gold license as a minimum license required on third party action types
* Use strings for license references
* Ensure license type is valid
* Fix some tests
* Add servicenow to gold
* Add tests
* Set license requirements on other built in action types
* Use jest.Mocked<ActionType> instead
* Change servicenow to platinum
Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
* Make actions config mock and license state mock use factory pattern and jest mocks (#59370)
* Add license checks to action HTTP APIs (#59153)
* Initial work
* Handle errors in update action API
* Add unit tests for APIs
* Make action executor throw when action type isn't enabled
* Add test suite for basic license
* Fix ESLint errors
* Fix failing tests
* Attempt 1 to fix CI
* ESLint fixes
* Create sendResponse function on ActionTypeDisabledError
* Make disabled action types by config return 403
* Remove switch case
* Fix ESLint
* Add license checks within alerting / actions framework (#59699)
* Initial work
* Handle errors in update action API
* Add unit tests for APIs
* Verify action type before scheduling action task
* Make actions plugin.execute throw error if action type is disabled
* Bug fixes
* Make action executor throw when action type isn't enabled
* Add test suite for basic license
* Fix ESLint errors
* Stop action task from re-running when license check fails
* Fix failing tests
* Attempt 1 to fix CI
* ESLint fixes
* Create sendResponse function on ActionTypeDisabledError
* Make disabled action types by config return 403
* Remove switch case
* Fix ESLint
* Fix confusing assertion
* Add comment explaining double mock
* Log warning when alert action isn't scheduled
* Disable action types in UI when license doesn't support it (#59819)
* Initial work
* Handle errors in update action API
* Add unit tests for APIs
* Verify action type before scheduling action task
* Make actions plugin.execute throw error if action type is disabled
* Bug fixes
* Make action executor throw when action type isn't enabled
* Add test suite for basic license
* Fix ESLint errors
* Stop action task from re-running when license check fails
* Fix failing tests
* Attempt 1 to fix CI
* ESLint fixes
* Return enabledInConfig and enabledInLicense from actions get types API
* Disable cards that have invalid license in create connector flyout
* Create sendResponse function on ActionTypeDisabledError
* Make disabled action types by config return 403
* Remove switch case
* Fix ESLint
* Disable when creating alert action
* Return minimumLicenseRequired in /types API
* Disable row in connectors when action type is disabled
* Fix failing jest test
* Some refactoring
* Card in edit alert flyout
* Sort action types by name
* Add tooltips to create connector action type selector
* Add tooltips to alert flyout action type selector
* Add get more actions link in alert flyout
* Add callout when creating a connector
* Typos
* remove float right and use flexgroup
* replace pixels with eui variables
* turn on sass lint for triggers_actions_ui dir
* trying to add padding around cards
* Add callout in edit alert screen when some actions are disabled
* improve card selection for Add Connector flyout
* Fix cards for create connector
* Add tests
* ESLint issue
* Cleanup
* Cleanup pt2
* Fix type check errors
* moving to 3-columns cards for connector selection
* Change re-enable to enable terminology
* Revert "Change re-enable to enable terminology"
This reverts commit b497dfd6b6
.
* Add re-enable comment
* Remove unecessary fragment
* Add type to actionTypeNodes
* Fix EuiLink to not have opacity of 0.7 when not hovered
* design cleanup in progress
* updating classNames
* using EuiIconTip
* Remove label on icon tip
* Fix failing jest test
Co-authored-by: Andrea Del Rio <delrio.andre@gmail.com>
* Add index to .index action type test
* PR feedback
* Add isErrorThatHandlesItsOwnResponse
Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
Co-authored-by: Andrea Del Rio <delrio.andre@gmail.com>
This commit is contained in:
parent
64e09af107
commit
851b8a82a5
102 changed files with 2402 additions and 397 deletions
|
@ -7,6 +7,7 @@ files:
|
|||
- 'x-pack/legacy/plugins/rollup/**/*.s+(a|c)ss'
|
||||
- 'x-pack/legacy/plugins/security/**/*.s+(a|c)ss'
|
||||
- 'x-pack/legacy/plugins/canvas/**/*.s+(a|c)ss'
|
||||
- 'x-pack/plugins/triggers_actions_ui/**/*.s+(a|c)ss'
|
||||
ignore:
|
||||
- 'x-pack/legacy/plugins/canvas/shareable_runtime/**/*.s+(a|c)ss'
|
||||
- 'x-pack/legacy/plugins/lens/**/*.s+(a|c)ss'
|
||||
|
|
|
@ -21,6 +21,7 @@ import { useConnectors } from '../../../../containers/case/configure/use_connect
|
|||
import { useCaseConfigure } from '../../../../containers/case/configure/use_configure';
|
||||
import {
|
||||
ActionsConnectorsContextProvider,
|
||||
ActionType,
|
||||
ConnectorAddFlyout,
|
||||
ConnectorEditFlyout,
|
||||
} from '../../../../../../../../plugins/triggers_actions_ui/public';
|
||||
|
@ -60,11 +61,14 @@ const initialState: State = {
|
|||
mapping: null,
|
||||
};
|
||||
|
||||
const actionTypes = [
|
||||
const actionTypes: ActionType[] = [
|
||||
{
|
||||
id: '.servicenow',
|
||||
name: 'ServiceNow',
|
||||
enabled: true,
|
||||
enabledInConfig: true,
|
||||
enabledInLicense: true,
|
||||
minimumLicenseRequired: 'platinum',
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
@ -4,10 +4,15 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { LicenseType } from '../../licensing/common/types';
|
||||
|
||||
export interface ActionType {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
enabledInConfig: boolean;
|
||||
enabledInLicense: boolean;
|
||||
minimumLicenseRequired: LicenseType;
|
||||
}
|
||||
|
||||
export interface ActionResult {
|
||||
|
|
|
@ -13,6 +13,7 @@ const createActionTypeRegistryMock = () => {
|
|||
get: jest.fn(),
|
||||
list: jest.fn(),
|
||||
ensureActionTypeEnabled: jest.fn(),
|
||||
isActionTypeEnabled: jest.fn(),
|
||||
};
|
||||
return mocked;
|
||||
};
|
||||
|
|
|
@ -5,21 +5,31 @@
|
|||
*/
|
||||
|
||||
import { taskManagerMock } from '../../task_manager/server/task_manager.mock';
|
||||
import { ActionTypeRegistry } from './action_type_registry';
|
||||
import { ExecutorType } from './types';
|
||||
import { ActionExecutor, ExecutorError, TaskRunnerFactory } from './lib';
|
||||
import { configUtilsMock } from './actions_config.mock';
|
||||
import { ActionTypeRegistry, ActionTypeRegistryOpts } from './action_type_registry';
|
||||
import { ActionType, ExecutorType } from './types';
|
||||
import { ActionExecutor, ExecutorError, ILicenseState, TaskRunnerFactory } from './lib';
|
||||
import { actionsConfigMock } from './actions_config.mock';
|
||||
import { licenseStateMock } from './lib/license_state.mock';
|
||||
import { ActionsConfigurationUtilities } from './actions_config';
|
||||
|
||||
const mockTaskManager = taskManagerMock.setup();
|
||||
const actionTypeRegistryParams = {
|
||||
taskManager: mockTaskManager,
|
||||
taskRunnerFactory: new TaskRunnerFactory(
|
||||
new ActionExecutor({ isESOUsingEphemeralEncryptionKey: false })
|
||||
),
|
||||
actionsConfigUtils: configUtilsMock,
|
||||
};
|
||||
let mockedLicenseState: jest.Mocked<ILicenseState>;
|
||||
let mockedActionsConfig: jest.Mocked<ActionsConfigurationUtilities>;
|
||||
let actionTypeRegistryParams: ActionTypeRegistryOpts;
|
||||
|
||||
beforeEach(() => jest.resetAllMocks());
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
mockedLicenseState = licenseStateMock.create();
|
||||
mockedActionsConfig = actionsConfigMock.create();
|
||||
actionTypeRegistryParams = {
|
||||
taskManager: mockTaskManager,
|
||||
taskRunnerFactory: new TaskRunnerFactory(
|
||||
new ActionExecutor({ isESOUsingEphemeralEncryptionKey: false })
|
||||
),
|
||||
actionsConfigUtils: mockedActionsConfig,
|
||||
licenseState: mockedLicenseState,
|
||||
};
|
||||
});
|
||||
|
||||
const executor: ExecutorType = async options => {
|
||||
return { status: 'ok', actionId: options.actionId };
|
||||
|
@ -31,6 +41,7 @@ describe('register()', () => {
|
|||
actionTypeRegistry.register({
|
||||
id: 'my-action-type',
|
||||
name: 'My action type',
|
||||
minimumLicenseRequired: 'basic',
|
||||
executor,
|
||||
});
|
||||
expect(actionTypeRegistry.has('my-action-type')).toEqual(true);
|
||||
|
@ -55,12 +66,14 @@ describe('register()', () => {
|
|||
actionTypeRegistry.register({
|
||||
id: 'my-action-type',
|
||||
name: 'My action type',
|
||||
minimumLicenseRequired: 'basic',
|
||||
executor,
|
||||
});
|
||||
expect(() =>
|
||||
actionTypeRegistry.register({
|
||||
id: 'my-action-type',
|
||||
name: 'My action type',
|
||||
minimumLicenseRequired: 'basic',
|
||||
executor,
|
||||
})
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
|
@ -73,6 +86,7 @@ describe('register()', () => {
|
|||
actionTypeRegistry.register({
|
||||
id: 'my-action-type',
|
||||
name: 'My action type',
|
||||
minimumLicenseRequired: 'basic',
|
||||
executor,
|
||||
});
|
||||
expect(mockTaskManager.registerTaskDefinitions).toHaveBeenCalledTimes(1);
|
||||
|
@ -94,6 +108,7 @@ describe('get()', () => {
|
|||
actionTypeRegistry.register({
|
||||
id: 'my-action-type',
|
||||
name: 'My action type',
|
||||
minimumLicenseRequired: 'basic',
|
||||
executor,
|
||||
});
|
||||
const actionType = actionTypeRegistry.get('my-action-type');
|
||||
|
@ -101,6 +116,7 @@ describe('get()', () => {
|
|||
Object {
|
||||
"executor": [Function],
|
||||
"id": "my-action-type",
|
||||
"minimumLicenseRequired": "basic",
|
||||
"name": "My action type",
|
||||
}
|
||||
`);
|
||||
|
@ -116,10 +132,12 @@ describe('get()', () => {
|
|||
|
||||
describe('list()', () => {
|
||||
test('returns list of action types', () => {
|
||||
mockedLicenseState.isLicenseValidForActionType.mockReturnValue({ isValid: true });
|
||||
const actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams);
|
||||
actionTypeRegistry.register({
|
||||
id: 'my-action-type',
|
||||
name: 'My action type',
|
||||
minimumLicenseRequired: 'basic',
|
||||
executor,
|
||||
});
|
||||
const actionTypes = actionTypeRegistry.list();
|
||||
|
@ -128,8 +146,13 @@ describe('list()', () => {
|
|||
id: 'my-action-type',
|
||||
name: 'My action type',
|
||||
enabled: true,
|
||||
enabledInConfig: true,
|
||||
enabledInLicense: true,
|
||||
minimumLicenseRequired: 'basic',
|
||||
},
|
||||
]);
|
||||
expect(mockedActionsConfig.isActionTypeEnabled).toHaveBeenCalled();
|
||||
expect(mockedLicenseState.isLicenseValidForActionType).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -144,8 +167,94 @@ describe('has()', () => {
|
|||
actionTypeRegistry.register({
|
||||
id: 'my-action-type',
|
||||
name: 'My action type',
|
||||
minimumLicenseRequired: 'basic',
|
||||
executor,
|
||||
});
|
||||
expect(actionTypeRegistry.has('my-action-type'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('isActionTypeEnabled', () => {
|
||||
let actionTypeRegistry: ActionTypeRegistry;
|
||||
const fooActionType: ActionType = {
|
||||
id: 'foo',
|
||||
name: 'Foo',
|
||||
minimumLicenseRequired: 'basic',
|
||||
executor: async () => {},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams);
|
||||
actionTypeRegistry.register(fooActionType);
|
||||
});
|
||||
|
||||
test('should call isActionTypeEnabled of the actions config', async () => {
|
||||
mockedLicenseState.isLicenseValidForActionType.mockReturnValue({ isValid: true });
|
||||
actionTypeRegistry.isActionTypeEnabled('foo');
|
||||
expect(mockedActionsConfig.isActionTypeEnabled).toHaveBeenCalledWith('foo');
|
||||
});
|
||||
|
||||
test('should call isLicenseValidForActionType of the license state', async () => {
|
||||
mockedLicenseState.isLicenseValidForActionType.mockReturnValue({ isValid: true });
|
||||
actionTypeRegistry.isActionTypeEnabled('foo');
|
||||
expect(mockedLicenseState.isLicenseValidForActionType).toHaveBeenCalledWith(fooActionType);
|
||||
});
|
||||
|
||||
test('should return false when isActionTypeEnabled is false and isLicenseValidForActionType is true', async () => {
|
||||
mockedActionsConfig.isActionTypeEnabled.mockReturnValue(false);
|
||||
mockedLicenseState.isLicenseValidForActionType.mockReturnValue({ isValid: true });
|
||||
expect(actionTypeRegistry.isActionTypeEnabled('foo')).toEqual(false);
|
||||
});
|
||||
|
||||
test('should return false when isActionTypeEnabled is true and isLicenseValidForActionType is false', async () => {
|
||||
mockedActionsConfig.isActionTypeEnabled.mockReturnValue(true);
|
||||
mockedLicenseState.isLicenseValidForActionType.mockReturnValue({
|
||||
isValid: false,
|
||||
reason: 'invalid',
|
||||
});
|
||||
expect(actionTypeRegistry.isActionTypeEnabled('foo')).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureActionTypeEnabled', () => {
|
||||
let actionTypeRegistry: ActionTypeRegistry;
|
||||
const fooActionType: ActionType = {
|
||||
id: 'foo',
|
||||
name: 'Foo',
|
||||
minimumLicenseRequired: 'basic',
|
||||
executor: async () => {},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams);
|
||||
actionTypeRegistry.register(fooActionType);
|
||||
});
|
||||
|
||||
test('should call ensureActionTypeEnabled of the action config', async () => {
|
||||
actionTypeRegistry.ensureActionTypeEnabled('foo');
|
||||
expect(mockedActionsConfig.ensureActionTypeEnabled).toHaveBeenCalledWith('foo');
|
||||
});
|
||||
|
||||
test('should call ensureLicenseForActionType on the license state', async () => {
|
||||
actionTypeRegistry.ensureActionTypeEnabled('foo');
|
||||
expect(mockedLicenseState.ensureLicenseForActionType).toHaveBeenCalledWith(fooActionType);
|
||||
});
|
||||
|
||||
test('should throw when ensureActionTypeEnabled throws', async () => {
|
||||
mockedActionsConfig.ensureActionTypeEnabled.mockImplementation(() => {
|
||||
throw new Error('Fail');
|
||||
});
|
||||
expect(() =>
|
||||
actionTypeRegistry.ensureActionTypeEnabled('foo')
|
||||
).toThrowErrorMatchingInlineSnapshot(`"Fail"`);
|
||||
});
|
||||
|
||||
test('should throw when ensureLicenseForActionType throws', async () => {
|
||||
mockedLicenseState.ensureLicenseForActionType.mockImplementation(() => {
|
||||
throw new Error('Fail');
|
||||
});
|
||||
expect(() =>
|
||||
actionTypeRegistry.ensureActionTypeEnabled('foo')
|
||||
).toThrowErrorMatchingInlineSnapshot(`"Fail"`);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -7,15 +7,16 @@
|
|||
import Boom from 'boom';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { RunContext, TaskManagerSetupContract } from '../../task_manager/server';
|
||||
import { ExecutorError, TaskRunnerFactory } from './lib';
|
||||
import { ExecutorError, TaskRunnerFactory, ILicenseState } from './lib';
|
||||
import { ActionType } from './types';
|
||||
import { ActionType as CommonActionType } from '../common';
|
||||
import { ActionsConfigurationUtilities } from './actions_config';
|
||||
|
||||
interface ConstructorOptions {
|
||||
export interface ActionTypeRegistryOpts {
|
||||
taskManager: TaskManagerSetupContract;
|
||||
taskRunnerFactory: TaskRunnerFactory;
|
||||
actionsConfigUtils: ActionsConfigurationUtilities;
|
||||
licenseState: ILicenseState;
|
||||
}
|
||||
|
||||
export class ActionTypeRegistry {
|
||||
|
@ -23,11 +24,13 @@ export class ActionTypeRegistry {
|
|||
private readonly actionTypes: Map<string, ActionType> = new Map();
|
||||
private readonly taskRunnerFactory: TaskRunnerFactory;
|
||||
private readonly actionsConfigUtils: ActionsConfigurationUtilities;
|
||||
private readonly licenseState: ILicenseState;
|
||||
|
||||
constructor(constructorParams: ConstructorOptions) {
|
||||
constructor(constructorParams: ActionTypeRegistryOpts) {
|
||||
this.taskManager = constructorParams.taskManager;
|
||||
this.taskRunnerFactory = constructorParams.taskRunnerFactory;
|
||||
this.actionsConfigUtils = constructorParams.actionsConfigUtils;
|
||||
this.licenseState = constructorParams.licenseState;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -42,6 +45,17 @@ export class ActionTypeRegistry {
|
|||
*/
|
||||
public ensureActionTypeEnabled(id: string) {
|
||||
this.actionsConfigUtils.ensureActionTypeEnabled(id);
|
||||
this.licenseState.ensureLicenseForActionType(this.get(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if action type is enabled in the config and a valid license is used.
|
||||
*/
|
||||
public isActionTypeEnabled(id: string) {
|
||||
return (
|
||||
this.actionsConfigUtils.isActionTypeEnabled(id) &&
|
||||
this.licenseState.isLicenseValidForActionType(this.get(id)).isValid === true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -103,7 +117,10 @@ export class ActionTypeRegistry {
|
|||
return Array.from(this.actionTypes).map(([actionTypeId, actionType]) => ({
|
||||
id: actionTypeId,
|
||||
name: actionType.name,
|
||||
enabled: this.actionsConfigUtils.isActionTypeEnabled(actionTypeId),
|
||||
minimumLicenseRequired: actionType.minimumLicenseRequired,
|
||||
enabled: this.isActionTypeEnabled(actionTypeId),
|
||||
enabledInConfig: this.actionsConfigUtils.isActionTypeEnabled(actionTypeId),
|
||||
enabledInLicense: this.licenseState.isLicenseValidForActionType(actionType).isValid === true,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,13 +6,14 @@
|
|||
|
||||
import { schema } from '@kbn/config-schema';
|
||||
|
||||
import { ActionTypeRegistry } from './action_type_registry';
|
||||
import { ActionTypeRegistry, ActionTypeRegistryOpts } from './action_type_registry';
|
||||
import { ActionsClient } from './actions_client';
|
||||
import { ExecutorType } from './types';
|
||||
import { ActionExecutor, TaskRunnerFactory } from './lib';
|
||||
import { ActionExecutor, TaskRunnerFactory, ILicenseState } from './lib';
|
||||
import { taskManagerMock } from '../../task_manager/server/task_manager.mock';
|
||||
import { configUtilsMock } from './actions_config.mock';
|
||||
import { actionsConfigMock } from './actions_config.mock';
|
||||
import { getActionsConfigurationUtilities } from './actions_config';
|
||||
import { licenseStateMock } from './lib/license_state.mock';
|
||||
|
||||
import {
|
||||
elasticsearchServiceMock,
|
||||
|
@ -25,22 +26,25 @@ const scopedClusterClient = elasticsearchServiceMock.createScopedClusterClient()
|
|||
|
||||
const mockTaskManager = taskManagerMock.setup();
|
||||
|
||||
const actionTypeRegistryParams = {
|
||||
taskManager: mockTaskManager,
|
||||
taskRunnerFactory: new TaskRunnerFactory(
|
||||
new ActionExecutor({ isESOUsingEphemeralEncryptionKey: false })
|
||||
),
|
||||
actionsConfigUtils: configUtilsMock,
|
||||
};
|
||||
|
||||
let actionsClient: ActionsClient;
|
||||
let mockedLicenseState: jest.Mocked<ILicenseState>;
|
||||
let actionTypeRegistry: ActionTypeRegistry;
|
||||
let actionTypeRegistryParams: ActionTypeRegistryOpts;
|
||||
const executor: ExecutorType = async options => {
|
||||
return { status: 'ok', actionId: options.actionId };
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
mockedLicenseState = licenseStateMock.create();
|
||||
actionTypeRegistryParams = {
|
||||
taskManager: mockTaskManager,
|
||||
taskRunnerFactory: new TaskRunnerFactory(
|
||||
new ActionExecutor({ isESOUsingEphemeralEncryptionKey: false })
|
||||
),
|
||||
actionsConfigUtils: actionsConfigMock.create(),
|
||||
licenseState: mockedLicenseState,
|
||||
};
|
||||
actionTypeRegistry = new ActionTypeRegistry(actionTypeRegistryParams);
|
||||
actionsClient = new ActionsClient({
|
||||
actionTypeRegistry,
|
||||
|
@ -65,6 +69,7 @@ describe('create()', () => {
|
|||
actionTypeRegistry.register({
|
||||
id: 'my-action-type',
|
||||
name: 'My action type',
|
||||
minimumLicenseRequired: 'basic',
|
||||
executor,
|
||||
});
|
||||
savedObjectsClient.create.mockResolvedValueOnce(savedObjectCreateResult);
|
||||
|
@ -100,6 +105,7 @@ describe('create()', () => {
|
|||
actionTypeRegistry.register({
|
||||
id: 'my-action-type',
|
||||
name: 'My action type',
|
||||
minimumLicenseRequired: 'basic',
|
||||
validate: {
|
||||
config: schema.object({
|
||||
param1: schema.string(),
|
||||
|
@ -140,6 +146,7 @@ describe('create()', () => {
|
|||
actionTypeRegistry.register({
|
||||
id: 'my-action-type',
|
||||
name: 'My action type',
|
||||
minimumLicenseRequired: 'basic',
|
||||
executor,
|
||||
});
|
||||
savedObjectsClient.create.mockResolvedValueOnce({
|
||||
|
@ -210,6 +217,7 @@ describe('create()', () => {
|
|||
new ActionExecutor({ isESOUsingEphemeralEncryptionKey: false })
|
||||
),
|
||||
actionsConfigUtils: localConfigUtils,
|
||||
licenseState: licenseStateMock.create(),
|
||||
};
|
||||
|
||||
actionTypeRegistry = new ActionTypeRegistry(localActionTypeRegistryParams);
|
||||
|
@ -233,6 +241,7 @@ describe('create()', () => {
|
|||
actionTypeRegistry.register({
|
||||
id: 'my-action-type',
|
||||
name: 'My action type',
|
||||
minimumLicenseRequired: 'basic',
|
||||
executor,
|
||||
});
|
||||
savedObjectsClient.create.mockResolvedValueOnce(savedObjectCreateResult);
|
||||
|
@ -250,6 +259,39 @@ describe('create()', () => {
|
|||
`"action type \\"my-action-type\\" is not enabled in the Kibana config xpack.actions.enabledActionTypes"`
|
||||
);
|
||||
});
|
||||
|
||||
test('throws error when ensureActionTypeEnabled throws', async () => {
|
||||
const savedObjectCreateResult = {
|
||||
id: '1',
|
||||
type: 'type',
|
||||
attributes: {
|
||||
name: 'my name',
|
||||
actionTypeId: 'my-action-type',
|
||||
config: {},
|
||||
},
|
||||
references: [],
|
||||
};
|
||||
actionTypeRegistry.register({
|
||||
id: 'my-action-type',
|
||||
name: 'My action type',
|
||||
minimumLicenseRequired: 'basic',
|
||||
executor,
|
||||
});
|
||||
mockedLicenseState.ensureLicenseForActionType.mockImplementation(() => {
|
||||
throw new Error('Fail');
|
||||
});
|
||||
savedObjectsClient.create.mockResolvedValueOnce(savedObjectCreateResult);
|
||||
await expect(
|
||||
actionsClient.create({
|
||||
action: {
|
||||
name: 'my name',
|
||||
actionTypeId: 'my-action-type',
|
||||
config: {},
|
||||
secrets: {},
|
||||
},
|
||||
})
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(`"Fail"`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('get()', () => {
|
||||
|
@ -346,6 +388,7 @@ describe('update()', () => {
|
|||
actionTypeRegistry.register({
|
||||
id: 'my-action-type',
|
||||
name: 'My action type',
|
||||
minimumLicenseRequired: 'basic',
|
||||
executor,
|
||||
});
|
||||
savedObjectsClient.get.mockResolvedValueOnce({
|
||||
|
@ -407,6 +450,7 @@ describe('update()', () => {
|
|||
actionTypeRegistry.register({
|
||||
id: 'my-action-type',
|
||||
name: 'My action type',
|
||||
minimumLicenseRequired: 'basic',
|
||||
validate: {
|
||||
config: schema.object({
|
||||
param1: schema.string(),
|
||||
|
@ -440,6 +484,7 @@ describe('update()', () => {
|
|||
actionTypeRegistry.register({
|
||||
id: 'my-action-type',
|
||||
name: 'My action type',
|
||||
minimumLicenseRequired: 'basic',
|
||||
executor,
|
||||
});
|
||||
savedObjectsClient.get.mockResolvedValueOnce({
|
||||
|
@ -505,4 +550,45 @@ describe('update()', () => {
|
|||
]
|
||||
`);
|
||||
});
|
||||
|
||||
test('throws an error when ensureActionTypeEnabled throws', async () => {
|
||||
actionTypeRegistry.register({
|
||||
id: 'my-action-type',
|
||||
name: 'My action type',
|
||||
minimumLicenseRequired: 'basic',
|
||||
executor,
|
||||
});
|
||||
mockedLicenseState.ensureLicenseForActionType.mockImplementation(() => {
|
||||
throw new Error('Fail');
|
||||
});
|
||||
savedObjectsClient.get.mockResolvedValueOnce({
|
||||
id: '1',
|
||||
type: 'action',
|
||||
attributes: {
|
||||
actionTypeId: 'my-action-type',
|
||||
},
|
||||
references: [],
|
||||
});
|
||||
savedObjectsClient.update.mockResolvedValueOnce({
|
||||
id: 'my-action',
|
||||
type: 'action',
|
||||
attributes: {
|
||||
actionTypeId: 'my-action-type',
|
||||
name: 'my name',
|
||||
config: {},
|
||||
secrets: {},
|
||||
},
|
||||
references: [],
|
||||
});
|
||||
await expect(
|
||||
actionsClient.update({
|
||||
id: 'my-action',
|
||||
action: {
|
||||
name: 'my name',
|
||||
config: {},
|
||||
secrets: {},
|
||||
},
|
||||
})
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(`"Fail"`);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -4,7 +4,6 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import Boom from 'boom';
|
||||
import {
|
||||
IScopedClusterClient,
|
||||
SavedObjectsClientContract,
|
||||
|
@ -93,11 +92,7 @@ export class ActionsClient {
|
|||
const validatedActionTypeConfig = validateConfig(actionType, config);
|
||||
const validatedActionTypeSecrets = validateSecrets(actionType, secrets);
|
||||
|
||||
try {
|
||||
this.actionTypeRegistry.ensureActionTypeEnabled(actionTypeId);
|
||||
} catch (err) {
|
||||
throw Boom.badRequest(err.message);
|
||||
}
|
||||
this.actionTypeRegistry.ensureActionTypeEnabled(actionTypeId);
|
||||
|
||||
const result = await this.savedObjectsClient.create('action', {
|
||||
actionTypeId,
|
||||
|
@ -125,6 +120,8 @@ export class ActionsClient {
|
|||
const validatedActionTypeConfig = validateConfig(actionType, config);
|
||||
const validatedActionTypeSecrets = validateSecrets(actionType, secrets);
|
||||
|
||||
this.actionTypeRegistry.ensureActionTypeEnabled(actionTypeId);
|
||||
|
||||
const result = await this.savedObjectsClient.update('action', id, {
|
||||
actionTypeId,
|
||||
name,
|
||||
|
|
|
@ -6,11 +6,18 @@
|
|||
|
||||
import { ActionsConfigurationUtilities } from './actions_config';
|
||||
|
||||
export const configUtilsMock: ActionsConfigurationUtilities = {
|
||||
isWhitelistedHostname: _ => true,
|
||||
isWhitelistedUri: _ => true,
|
||||
isActionTypeEnabled: _ => true,
|
||||
ensureWhitelistedHostname: _ => {},
|
||||
ensureWhitelistedUri: _ => {},
|
||||
ensureActionTypeEnabled: _ => {},
|
||||
const createActionsConfigMock = () => {
|
||||
const mocked: jest.Mocked<ActionsConfigurationUtilities> = {
|
||||
isWhitelistedHostname: jest.fn().mockReturnValue(true),
|
||||
isWhitelistedUri: jest.fn().mockReturnValue(true),
|
||||
isActionTypeEnabled: jest.fn().mockReturnValue(true),
|
||||
ensureWhitelistedHostname: jest.fn().mockReturnValue({}),
|
||||
ensureWhitelistedUri: jest.fn().mockReturnValue({}),
|
||||
ensureActionTypeEnabled: jest.fn().mockReturnValue({}),
|
||||
};
|
||||
return mocked;
|
||||
};
|
||||
|
||||
export const actionsConfigMock = {
|
||||
create: createActionsConfigMock,
|
||||
};
|
||||
|
|
|
@ -11,6 +11,7 @@ import { curry } from 'lodash';
|
|||
import { pipe } from 'fp-ts/lib/pipeable';
|
||||
|
||||
import { ActionsConfigType } from './types';
|
||||
import { ActionTypeDisabledError } from './lib';
|
||||
|
||||
export enum WhitelistedHosts {
|
||||
Any = '*',
|
||||
|
@ -103,7 +104,7 @@ export function getActionsConfigurationUtilities(
|
|||
},
|
||||
ensureActionTypeEnabled(actionType: string) {
|
||||
if (!isActionTypeEnabled(actionType)) {
|
||||
throw new Error(disabledActionTypeErrorMessage(actionType));
|
||||
throw new ActionTypeDisabledError(disabledActionTypeErrorMessage(actionType), 'config');
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
@ -12,7 +12,7 @@ import { Logger } from '../../../../../src/core/server';
|
|||
import { savedObjectsClientMock } from '../../../../../src/core/server/mocks';
|
||||
|
||||
import { ActionType, ActionTypeExecutorOptions } from '../types';
|
||||
import { configUtilsMock } from '../actions_config.mock';
|
||||
import { actionsConfigMock } from '../actions_config.mock';
|
||||
import { validateConfig, validateSecrets, validateParams } from '../lib';
|
||||
import { createActionTypeRegistry } from './index.test';
|
||||
import { sendEmail } from './lib/send_email';
|
||||
|
@ -37,13 +37,10 @@ const services = {
|
|||
let actionType: ActionType;
|
||||
let mockedLogger: jest.Mocked<Logger>;
|
||||
|
||||
beforeAll(() => {
|
||||
const { actionTypeRegistry } = createActionTypeRegistry();
|
||||
actionType = actionTypeRegistry.get(ACTION_TYPE_ID);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
const { actionTypeRegistry } = createActionTypeRegistry();
|
||||
actionType = actionTypeRegistry.get(ACTION_TYPE_ID);
|
||||
});
|
||||
|
||||
describe('actionTypeRegistry.get() works', () => {
|
||||
|
@ -128,7 +125,7 @@ describe('config validation', () => {
|
|||
actionType = getActionType({
|
||||
logger: mockedLogger,
|
||||
configurationUtilities: {
|
||||
...configUtilsMock,
|
||||
...actionsConfigMock.create(),
|
||||
isWhitelistedHostname: hostname => hostname === NODEMAILER_AOL_SERVICE_HOST,
|
||||
},
|
||||
});
|
||||
|
|
|
@ -118,6 +118,7 @@ export function getActionType(params: GetActionTypeParams): ActionType {
|
|||
const { logger, configurationUtilities } = params;
|
||||
return {
|
||||
id: '.email',
|
||||
minimumLicenseRequired: 'gold',
|
||||
name: i18n.translate('xpack.actions.builtin.emailTitle', {
|
||||
defaultMessage: 'Email',
|
||||
}),
|
||||
|
|
|
@ -36,6 +36,7 @@ const ParamsSchema = schema.object({
|
|||
export function getActionType({ logger }: { logger: Logger }): ActionType {
|
||||
return {
|
||||
id: '.index',
|
||||
minimumLicenseRequired: 'basic',
|
||||
name: i18n.translate('xpack.actions.builtin.esIndexTitle', {
|
||||
defaultMessage: 'Index',
|
||||
}),
|
||||
|
|
|
@ -10,7 +10,8 @@ import { taskManagerMock } from '../../../task_manager/server/task_manager.mock'
|
|||
import { registerBuiltInActionTypes } from './index';
|
||||
import { Logger } from '../../../../../src/core/server';
|
||||
import { loggingServiceMock } from '../../../../../src/core/server/mocks';
|
||||
import { configUtilsMock } from '../actions_config.mock';
|
||||
import { actionsConfigMock } from '../actions_config.mock';
|
||||
import { licenseStateMock } from '../lib/license_state.mock';
|
||||
|
||||
const ACTION_TYPE_IDS = ['.index', '.email', '.pagerduty', '.server-log', '.slack', '.webhook'];
|
||||
|
||||
|
@ -24,12 +25,13 @@ export function createActionTypeRegistry(): {
|
|||
taskRunnerFactory: new TaskRunnerFactory(
|
||||
new ActionExecutor({ isESOUsingEphemeralEncryptionKey: false })
|
||||
),
|
||||
actionsConfigUtils: configUtilsMock,
|
||||
actionsConfigUtils: actionsConfigMock.create(),
|
||||
licenseState: licenseStateMock.create(),
|
||||
});
|
||||
registerBuiltInActionTypes({
|
||||
logger,
|
||||
actionTypeRegistry,
|
||||
actionsConfigUtils: configUtilsMock,
|
||||
actionsConfigUtils: actionsConfigMock.create(),
|
||||
});
|
||||
return { logger, actionTypeRegistry };
|
||||
}
|
||||
|
|
|
@ -15,7 +15,7 @@ import { savedObjectsClientMock } from '../../../../../src/core/server/mocks';
|
|||
import { postPagerduty } from './lib/post_pagerduty';
|
||||
import { createActionTypeRegistry } from './index.test';
|
||||
import { Logger } from '../../../../../src/core/server';
|
||||
import { configUtilsMock } from '../actions_config.mock';
|
||||
import { actionsConfigMock } from '../actions_config.mock';
|
||||
|
||||
const postPagerdutyMock = postPagerduty as jest.Mock;
|
||||
|
||||
|
@ -60,7 +60,7 @@ describe('validateConfig()', () => {
|
|||
actionType = getActionType({
|
||||
logger: mockedLogger,
|
||||
configurationUtilities: {
|
||||
...configUtilsMock,
|
||||
...actionsConfigMock.create(),
|
||||
ensureWhitelistedUri: url => {
|
||||
expect(url).toEqual('https://events.pagerduty.com/v2/enqueue');
|
||||
},
|
||||
|
@ -76,7 +76,7 @@ describe('validateConfig()', () => {
|
|||
actionType = getActionType({
|
||||
logger: mockedLogger,
|
||||
configurationUtilities: {
|
||||
...configUtilsMock,
|
||||
...actionsConfigMock.create(),
|
||||
ensureWhitelistedUri: _ => {
|
||||
throw new Error(`target url is not whitelisted`);
|
||||
},
|
||||
|
|
|
@ -96,6 +96,7 @@ export function getActionType({
|
|||
}): ActionType {
|
||||
return {
|
||||
id: '.pagerduty',
|
||||
minimumLicenseRequired: 'gold',
|
||||
name: i18n.translate('xpack.actions.builtin.pagerdutyTitle', {
|
||||
defaultMessage: 'PagerDuty',
|
||||
}),
|
||||
|
|
|
@ -35,6 +35,7 @@ const ParamsSchema = schema.object({
|
|||
export function getActionType({ logger }: { logger: Logger }): ActionType {
|
||||
return {
|
||||
id: '.server-log',
|
||||
minimumLicenseRequired: 'basic',
|
||||
name: i18n.translate('xpack.actions.builtin.serverLogTitle', {
|
||||
defaultMessage: 'Server log',
|
||||
}),
|
||||
|
|
|
@ -9,7 +9,7 @@ import { ActionType, Services, ActionTypeExecutorOptions } from '../../types';
|
|||
import { validateConfig, validateSecrets, validateParams } from '../../lib';
|
||||
import { savedObjectsClientMock } from '../../../../../../src/core/server/mocks';
|
||||
import { createActionTypeRegistry } from '../index.test';
|
||||
import { configUtilsMock } from '../../actions_config.mock';
|
||||
import { actionsConfigMock } from '../../actions_config.mock';
|
||||
|
||||
import { ACTION_TYPE_ID } from './constants';
|
||||
import * as i18n from './translations';
|
||||
|
@ -109,7 +109,7 @@ describe('validateConfig()', () => {
|
|||
test('should validate and pass when the servicenow url is whitelisted', () => {
|
||||
actionType = getActionType({
|
||||
configurationUtilities: {
|
||||
...configUtilsMock,
|
||||
...actionsConfigMock.create(),
|
||||
ensureWhitelistedUri: url => {
|
||||
expect(url).toEqual(mockOptions.config.apiUrl);
|
||||
},
|
||||
|
@ -122,7 +122,7 @@ describe('validateConfig()', () => {
|
|||
test('config validation returns an error if the specified URL isnt whitelisted', () => {
|
||||
actionType = getActionType({
|
||||
configurationUtilities: {
|
||||
...configUtilsMock,
|
||||
...actionsConfigMock.create(),
|
||||
ensureWhitelistedUri: _ => {
|
||||
throw new Error(`target url is not whitelisted`);
|
||||
},
|
||||
|
|
|
@ -56,6 +56,7 @@ export function getActionType({
|
|||
return {
|
||||
id: ACTION_TYPE_ID,
|
||||
name: i18n.NAME,
|
||||
minimumLicenseRequired: 'platinum',
|
||||
validate: {
|
||||
config: schema.object(ConfigSchemaProps, {
|
||||
validate: curry(validateConfig)(configurationUtilities),
|
||||
|
|
|
@ -8,7 +8,7 @@ import { ActionType, Services, ActionTypeExecutorOptions } from '../types';
|
|||
import { savedObjectsClientMock } from '../../../../../src/core/server/mocks';
|
||||
import { validateParams, validateSecrets } from '../lib';
|
||||
import { getActionType } from './slack';
|
||||
import { configUtilsMock } from '../actions_config.mock';
|
||||
import { actionsConfigMock } from '../actions_config.mock';
|
||||
|
||||
const ACTION_TYPE_ID = '.slack';
|
||||
|
||||
|
@ -22,7 +22,7 @@ let actionType: ActionType;
|
|||
beforeAll(() => {
|
||||
actionType = getActionType({
|
||||
async executor(options: ActionTypeExecutorOptions): Promise<any> {},
|
||||
configurationUtilities: configUtilsMock,
|
||||
configurationUtilities: actionsConfigMock.create(),
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -85,7 +85,7 @@ describe('validateActionTypeSecrets()', () => {
|
|||
test('should validate and pass when the slack webhookUrl is whitelisted', () => {
|
||||
actionType = getActionType({
|
||||
configurationUtilities: {
|
||||
...configUtilsMock,
|
||||
...actionsConfigMock.create(),
|
||||
ensureWhitelistedUri: url => {
|
||||
expect(url).toEqual('https://api.slack.com/');
|
||||
},
|
||||
|
@ -100,7 +100,7 @@ describe('validateActionTypeSecrets()', () => {
|
|||
test('config validation returns an error if the specified URL isnt whitelisted', () => {
|
||||
actionType = getActionType({
|
||||
configurationUtilities: {
|
||||
...configUtilsMock,
|
||||
...actionsConfigMock.create(),
|
||||
ensureWhitelistedHostname: url => {
|
||||
throw new Error(`target hostname is not whitelisted`);
|
||||
},
|
||||
|
@ -135,7 +135,7 @@ describe('execute()', () => {
|
|||
|
||||
actionType = getActionType({
|
||||
executor: mockSlackExecutor,
|
||||
configurationUtilities: configUtilsMock,
|
||||
configurationUtilities: actionsConfigMock.create(),
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -50,6 +50,7 @@ export function getActionType({
|
|||
}): ActionType {
|
||||
return {
|
||||
id: '.slack',
|
||||
minimumLicenseRequired: 'gold',
|
||||
name: i18n.translate('xpack.actions.builtin.slackTitle', {
|
||||
defaultMessage: 'Slack',
|
||||
}),
|
||||
|
|
|
@ -12,7 +12,7 @@ import { getActionType } from './webhook';
|
|||
import { ActionType, Services } from '../types';
|
||||
import { validateConfig, validateSecrets, validateParams } from '../lib';
|
||||
import { savedObjectsClientMock } from '../../../../../src/core/server/mocks';
|
||||
import { configUtilsMock } from '../actions_config.mock';
|
||||
import { actionsConfigMock } from '../actions_config.mock';
|
||||
import { createActionTypeRegistry } from './index.test';
|
||||
import { Logger } from '../../../../../src/core/server';
|
||||
import axios from 'axios';
|
||||
|
@ -164,7 +164,7 @@ describe('config validation', () => {
|
|||
actionType = getActionType({
|
||||
logger: mockedLogger,
|
||||
configurationUtilities: {
|
||||
...configUtilsMock,
|
||||
...actionsConfigMock.create(),
|
||||
ensureWhitelistedUri: _ => {
|
||||
throw new Error(`target url is not whitelisted`);
|
||||
},
|
||||
|
@ -207,7 +207,7 @@ describe('execute()', () => {
|
|||
axiosRequestMock.mockReset();
|
||||
actionType = getActionType({
|
||||
logger: mockedLogger,
|
||||
configurationUtilities: configUtilsMock,
|
||||
configurationUtilities: actionsConfigMock.create(),
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -66,6 +66,7 @@ export function getActionType({
|
|||
}): ActionType {
|
||||
return {
|
||||
id: '.webhook',
|
||||
minimumLicenseRequired: 'gold',
|
||||
name: i18n.translate('xpack.actions.builtin.webhookTitle', {
|
||||
defaultMessage: 'Webhook',
|
||||
}),
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
import { taskManagerMock } from '../../task_manager/server/task_manager.mock';
|
||||
import { createExecuteFunction } from './create_execute_function';
|
||||
import { savedObjectsClientMock } from '../../../../src/core/server/mocks';
|
||||
import { actionTypeRegistryMock } from './action_type_registry.mock';
|
||||
|
||||
const mockTaskManager = taskManagerMock.start();
|
||||
const savedObjectsClient = savedObjectsClientMock.create();
|
||||
|
@ -19,6 +20,7 @@ describe('execute()', () => {
|
|||
const executeFn = createExecuteFunction({
|
||||
getBasePath,
|
||||
taskManager: mockTaskManager,
|
||||
actionTypeRegistry: actionTypeRegistryMock.create(),
|
||||
getScopedSavedObjectsClient: jest.fn().mockReturnValueOnce(savedObjectsClient),
|
||||
isESOUsingEphemeralEncryptionKey: false,
|
||||
});
|
||||
|
@ -73,6 +75,7 @@ describe('execute()', () => {
|
|||
taskManager: mockTaskManager,
|
||||
getScopedSavedObjectsClient,
|
||||
isESOUsingEphemeralEncryptionKey: false,
|
||||
actionTypeRegistry: actionTypeRegistryMock.create(),
|
||||
});
|
||||
savedObjectsClient.get.mockResolvedValueOnce({
|
||||
id: '123',
|
||||
|
@ -121,6 +124,7 @@ describe('execute()', () => {
|
|||
taskManager: mockTaskManager,
|
||||
getScopedSavedObjectsClient,
|
||||
isESOUsingEphemeralEncryptionKey: false,
|
||||
actionTypeRegistry: actionTypeRegistryMock.create(),
|
||||
});
|
||||
savedObjectsClient.get.mockResolvedValueOnce({
|
||||
id: '123',
|
||||
|
@ -166,6 +170,7 @@ describe('execute()', () => {
|
|||
taskManager: mockTaskManager,
|
||||
getScopedSavedObjectsClient,
|
||||
isESOUsingEphemeralEncryptionKey: true,
|
||||
actionTypeRegistry: actionTypeRegistryMock.create(),
|
||||
});
|
||||
await expect(
|
||||
executeFn({
|
||||
|
@ -178,4 +183,36 @@ describe('execute()', () => {
|
|||
`"Unable to execute action due to the Encrypted Saved Objects plugin using an ephemeral encryption key. Please set xpack.encryptedSavedObjects.encryptionKey in kibana.yml"`
|
||||
);
|
||||
});
|
||||
|
||||
test('should ensure action type is enabled', async () => {
|
||||
const mockedActionTypeRegistry = actionTypeRegistryMock.create();
|
||||
const getScopedSavedObjectsClient = jest.fn().mockReturnValueOnce(savedObjectsClient);
|
||||
const executeFn = createExecuteFunction({
|
||||
getBasePath,
|
||||
taskManager: mockTaskManager,
|
||||
getScopedSavedObjectsClient,
|
||||
isESOUsingEphemeralEncryptionKey: false,
|
||||
actionTypeRegistry: mockedActionTypeRegistry,
|
||||
});
|
||||
mockedActionTypeRegistry.ensureActionTypeEnabled.mockImplementation(() => {
|
||||
throw new Error('Fail');
|
||||
});
|
||||
savedObjectsClient.get.mockResolvedValueOnce({
|
||||
id: '123',
|
||||
type: 'action',
|
||||
attributes: {
|
||||
actionTypeId: 'mock-action',
|
||||
},
|
||||
references: [],
|
||||
});
|
||||
|
||||
await expect(
|
||||
executeFn({
|
||||
id: '123',
|
||||
params: { baz: false },
|
||||
spaceId: 'default',
|
||||
apiKey: null,
|
||||
})
|
||||
).rejects.toThrowErrorMatchingInlineSnapshot(`"Fail"`);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -6,13 +6,14 @@
|
|||
|
||||
import { SavedObjectsClientContract } from '../../../../src/core/server';
|
||||
import { TaskManagerStartContract } from '../../task_manager/server';
|
||||
import { GetBasePathFunction, RawAction } from './types';
|
||||
import { GetBasePathFunction, RawAction, ActionTypeRegistryContract } from './types';
|
||||
|
||||
interface CreateExecuteFunctionOptions {
|
||||
taskManager: TaskManagerStartContract;
|
||||
getScopedSavedObjectsClient: (request: any) => SavedObjectsClientContract;
|
||||
getBasePath: GetBasePathFunction;
|
||||
isESOUsingEphemeralEncryptionKey: boolean;
|
||||
actionTypeRegistry: ActionTypeRegistryContract;
|
||||
}
|
||||
|
||||
export interface ExecuteOptions {
|
||||
|
@ -25,6 +26,7 @@ export interface ExecuteOptions {
|
|||
export function createExecuteFunction({
|
||||
getBasePath,
|
||||
taskManager,
|
||||
actionTypeRegistry,
|
||||
getScopedSavedObjectsClient,
|
||||
isESOUsingEphemeralEncryptionKey,
|
||||
}: CreateExecuteFunctionOptions) {
|
||||
|
@ -60,6 +62,9 @@ export function createExecuteFunction({
|
|||
|
||||
const savedObjectsClient = getScopedSavedObjectsClient(fakeRequest);
|
||||
const actionSavedObject = await savedObjectsClient.get<RawAction>('action', id);
|
||||
|
||||
actionTypeRegistry.ensureActionTypeEnabled(actionSavedObject.attributes.actionTypeId);
|
||||
|
||||
const actionTaskParamsRecord = await savedObjectsClient.create('action_task_params', {
|
||||
actionId: id,
|
||||
params,
|
||||
|
|
|
@ -12,6 +12,7 @@ import { encryptedSavedObjectsMock } from '../../../encrypted_saved_objects/serv
|
|||
import { savedObjectsClientMock, loggingServiceMock } from '../../../../../src/core/server/mocks';
|
||||
import { eventLoggerMock } from '../../../event_log/server/mocks';
|
||||
import { spacesServiceMock } from '../../../spaces/server/spaces_service/spaces_service.mock';
|
||||
import { ActionType } from '../types';
|
||||
|
||||
const actionExecutor = new ActionExecutor({ isESOUsingEphemeralEncryptionKey: false });
|
||||
const savedObjectsClient = savedObjectsClientMock.create();
|
||||
|
@ -50,9 +51,10 @@ beforeEach(() => {
|
|||
});
|
||||
|
||||
test('successfully executes', async () => {
|
||||
const actionType = {
|
||||
const actionType: jest.Mocked<ActionType> = {
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
minimumLicenseRequired: 'basic',
|
||||
executor: jest.fn(),
|
||||
};
|
||||
const actionSavedObject = {
|
||||
|
@ -96,9 +98,10 @@ test('successfully executes', async () => {
|
|||
});
|
||||
|
||||
test('provides empty config when config and / or secrets is empty', async () => {
|
||||
const actionType = {
|
||||
const actionType: jest.Mocked<ActionType> = {
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
minimumLicenseRequired: 'basic',
|
||||
executor: jest.fn(),
|
||||
};
|
||||
const actionSavedObject = {
|
||||
|
@ -120,9 +123,10 @@ test('provides empty config when config and / or secrets is empty', async () =>
|
|||
});
|
||||
|
||||
test('throws an error when config is invalid', async () => {
|
||||
const actionType = {
|
||||
const actionType: jest.Mocked<ActionType> = {
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
minimumLicenseRequired: 'basic',
|
||||
validate: {
|
||||
config: schema.object({
|
||||
param1: schema.string(),
|
||||
|
@ -152,9 +156,10 @@ test('throws an error when config is invalid', async () => {
|
|||
});
|
||||
|
||||
test('throws an error when params is invalid', async () => {
|
||||
const actionType = {
|
||||
const actionType: jest.Mocked<ActionType> = {
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
minimumLicenseRequired: 'basic',
|
||||
validate: {
|
||||
params: schema.object({
|
||||
param1: schema.string(),
|
||||
|
@ -190,10 +195,11 @@ test('throws an error when failing to load action through savedObjectsClient', a
|
|||
);
|
||||
});
|
||||
|
||||
test('returns an error if actionType is not enabled', async () => {
|
||||
const actionType = {
|
||||
test('throws an error if actionType is not enabled', async () => {
|
||||
const actionType: jest.Mocked<ActionType> = {
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
minimumLicenseRequired: 'basic',
|
||||
executor: jest.fn(),
|
||||
};
|
||||
const actionSavedObject = {
|
||||
|
@ -210,17 +216,11 @@ test('returns an error if actionType is not enabled', async () => {
|
|||
actionTypeRegistry.ensureActionTypeEnabled.mockImplementationOnce(() => {
|
||||
throw new Error('not enabled for test');
|
||||
});
|
||||
const result = await actionExecutor.execute(executeParams);
|
||||
await expect(actionExecutor.execute(executeParams)).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"not enabled for test"`
|
||||
);
|
||||
|
||||
expect(actionTypeRegistry.ensureActionTypeEnabled).toHaveBeenCalledWith('test');
|
||||
expect(result).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"actionId": "1",
|
||||
"message": "not enabled for test",
|
||||
"retry": false,
|
||||
"status": "error",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
test('throws an error when passing isESOUsingEphemeralEncryptionKey with value of true', async () => {
|
||||
|
|
|
@ -82,11 +82,7 @@ export class ActionExecutor {
|
|||
attributes: { actionTypeId, config, name },
|
||||
} = await services.savedObjectsClient.get<RawAction>('action', actionId);
|
||||
|
||||
try {
|
||||
actionTypeRegistry.ensureActionTypeEnabled(actionTypeId);
|
||||
} catch (err) {
|
||||
return { status: 'error', actionId, message: err.message, retry: false };
|
||||
}
|
||||
actionTypeRegistry.ensureActionTypeEnabled(actionTypeId);
|
||||
|
||||
// Only get encrypted attributes here, the remaining attributes can be fetched in
|
||||
// the savedObjectsClient call
|
||||
|
|
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { KibanaResponseFactory } from '../../../../../../src/core/server';
|
||||
import { ErrorThatHandlesItsOwnResponse } from './types';
|
||||
|
||||
export type ActionTypeDisabledReason =
|
||||
| 'config'
|
||||
| 'license_unavailable'
|
||||
| 'license_invalid'
|
||||
| 'license_expired';
|
||||
|
||||
export class ActionTypeDisabledError extends Error implements ErrorThatHandlesItsOwnResponse {
|
||||
public readonly reason: ActionTypeDisabledReason;
|
||||
|
||||
constructor(message: string, reason: ActionTypeDisabledReason) {
|
||||
super(message);
|
||||
this.reason = reason;
|
||||
}
|
||||
|
||||
public sendResponse(res: KibanaResponseFactory) {
|
||||
return res.forbidden({ body: { message: this.message } });
|
||||
}
|
||||
}
|
15
x-pack/plugins/actions/server/lib/errors/index.ts
Normal file
15
x-pack/plugins/actions/server/lib/errors/index.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { ErrorThatHandlesItsOwnResponse } from './types';
|
||||
|
||||
export function isErrorThatHandlesItsOwnResponse(
|
||||
e: ErrorThatHandlesItsOwnResponse
|
||||
): e is ErrorThatHandlesItsOwnResponse {
|
||||
return typeof (e as ErrorThatHandlesItsOwnResponse).sendResponse === 'function';
|
||||
}
|
||||
|
||||
export { ActionTypeDisabledError, ActionTypeDisabledReason } from './action_type_disabled';
|
11
x-pack/plugins/actions/server/lib/errors/types.ts
Normal file
11
x-pack/plugins/actions/server/lib/errors/types.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { KibanaResponseFactory, IKibanaResponse } from '../../../../../../src/core/server';
|
||||
|
||||
export interface ErrorThatHandlesItsOwnResponse extends Error {
|
||||
sendResponse(res: KibanaResponseFactory): IKibanaResponse;
|
||||
}
|
|
@ -8,3 +8,10 @@ export { ExecutorError } from './executor_error';
|
|||
export { validateParams, validateConfig, validateSecrets } from './validate_with_schema';
|
||||
export { TaskRunnerFactory } from './task_runner_factory';
|
||||
export { ActionExecutor, ActionExecutorContract } from './action_executor';
|
||||
export { ILicenseState, LicenseState } from './license_state';
|
||||
export { verifyApiAccess } from './verify_api_access';
|
||||
export {
|
||||
ActionTypeDisabledError,
|
||||
ActionTypeDisabledReason,
|
||||
isErrorThatHandlesItsOwnResponse,
|
||||
} from './errors';
|
||||
|
|
|
@ -4,35 +4,22 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { of } from 'rxjs';
|
||||
import { LicenseState } from './license_state';
|
||||
import { LICENSE_CHECK_STATE, ILicense } from '../../../licensing/server';
|
||||
import { ILicenseState } from './license_state';
|
||||
import { LICENSE_CHECK_STATE } from '../../../licensing/server';
|
||||
|
||||
export const mockLicenseState = () => {
|
||||
const license: ILicense = {
|
||||
uid: '123',
|
||||
status: 'active',
|
||||
isActive: true,
|
||||
signature: 'sig',
|
||||
isAvailable: true,
|
||||
toJSON: () => ({
|
||||
signature: 'sig',
|
||||
export const createLicenseStateMock = () => {
|
||||
const licenseState: jest.Mocked<ILicenseState> = {
|
||||
clean: jest.fn(),
|
||||
getLicenseInformation: jest.fn(),
|
||||
ensureLicenseForActionType: jest.fn(),
|
||||
isLicenseValidForActionType: jest.fn(),
|
||||
checkLicense: jest.fn().mockResolvedValue({
|
||||
state: LICENSE_CHECK_STATE.Valid,
|
||||
}),
|
||||
getUnavailableReason: () => undefined,
|
||||
hasAtLeast() {
|
||||
return true;
|
||||
},
|
||||
check() {
|
||||
return {
|
||||
state: LICENSE_CHECK_STATE.Valid,
|
||||
};
|
||||
},
|
||||
getFeature() {
|
||||
return {
|
||||
isAvailable: true,
|
||||
isEnabled: true,
|
||||
};
|
||||
},
|
||||
};
|
||||
return new LicenseState(of(license));
|
||||
return licenseState;
|
||||
};
|
||||
|
||||
export const licenseStateMock = {
|
||||
create: createLicenseStateMock,
|
||||
};
|
||||
|
|
|
@ -4,12 +4,13 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import expect from '@kbn/expect';
|
||||
import { LicenseState } from './license_state';
|
||||
import { ActionType } from '../types';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { LicenseState, ILicenseState } from './license_state';
|
||||
import { licensingMock } from '../../../licensing/server/mocks';
|
||||
import { LICENSE_CHECK_STATE } from '../../../licensing/server';
|
||||
import { LICENSE_CHECK_STATE, ILicense } from '../../../licensing/server';
|
||||
|
||||
describe('license_state', () => {
|
||||
describe('checkLicense()', () => {
|
||||
let getRawLicense: any;
|
||||
|
||||
beforeEach(() => {
|
||||
|
@ -29,7 +30,7 @@ describe('license_state', () => {
|
|||
const licensing = licensingMock.createSetup();
|
||||
const licenseState = new LicenseState(licensing.license$);
|
||||
const actionsLicenseInfo = licenseState.checkLicense(getRawLicense());
|
||||
expect(actionsLicenseInfo.enableAppLink).to.be(false);
|
||||
expect(actionsLicenseInfo.enableAppLink).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -46,7 +47,131 @@ describe('license_state', () => {
|
|||
const licensing = licensingMock.createSetup();
|
||||
const licenseState = new LicenseState(licensing.license$);
|
||||
const actionsLicenseInfo = licenseState.checkLicense(getRawLicense());
|
||||
expect(actionsLicenseInfo.showAppLink).to.be(true);
|
||||
expect(actionsLicenseInfo.showAppLink).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isLicenseValidForActionType', () => {
|
||||
let license: BehaviorSubject<ILicense>;
|
||||
let licenseState: ILicenseState;
|
||||
const fooActionType: ActionType = {
|
||||
id: 'foo',
|
||||
name: 'Foo',
|
||||
minimumLicenseRequired: 'gold',
|
||||
executor: async () => {},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
license = new BehaviorSubject(null as any);
|
||||
licenseState = new LicenseState(license);
|
||||
});
|
||||
|
||||
test('should return false when license not defined', () => {
|
||||
expect(licenseState.isLicenseValidForActionType(fooActionType)).toEqual({
|
||||
isValid: false,
|
||||
reason: 'unavailable',
|
||||
});
|
||||
});
|
||||
|
||||
test('should return false when license not available', () => {
|
||||
license.next({ isAvailable: false } as any);
|
||||
expect(licenseState.isLicenseValidForActionType(fooActionType)).toEqual({
|
||||
isValid: false,
|
||||
reason: 'unavailable',
|
||||
});
|
||||
});
|
||||
|
||||
test('should return false when license is expired', () => {
|
||||
const expiredLicense = licensingMock.createLicense({ license: { status: 'expired' } });
|
||||
license.next(expiredLicense);
|
||||
expect(licenseState.isLicenseValidForActionType(fooActionType)).toEqual({
|
||||
isValid: false,
|
||||
reason: 'expired',
|
||||
});
|
||||
});
|
||||
|
||||
test('should return false when license is invalid', () => {
|
||||
const basicLicense = licensingMock.createLicense({
|
||||
license: { status: 'active', type: 'basic' },
|
||||
});
|
||||
license.next(basicLicense);
|
||||
expect(licenseState.isLicenseValidForActionType(fooActionType)).toEqual({
|
||||
isValid: false,
|
||||
reason: 'invalid',
|
||||
});
|
||||
});
|
||||
|
||||
test('should return true when license is valid', () => {
|
||||
const goldLicense = licensingMock.createLicense({
|
||||
license: { status: 'active', type: 'gold' },
|
||||
});
|
||||
license.next(goldLicense);
|
||||
expect(licenseState.isLicenseValidForActionType(fooActionType)).toEqual({
|
||||
isValid: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureLicenseForActionType()', () => {
|
||||
let license: BehaviorSubject<ILicense>;
|
||||
let licenseState: ILicenseState;
|
||||
const fooActionType: ActionType = {
|
||||
id: 'foo',
|
||||
name: 'Foo',
|
||||
minimumLicenseRequired: 'gold',
|
||||
executor: async () => {},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
license = new BehaviorSubject(null as any);
|
||||
licenseState = new LicenseState(license);
|
||||
});
|
||||
|
||||
test('should throw when license not defined', () => {
|
||||
expect(() =>
|
||||
licenseState.ensureLicenseForActionType(fooActionType)
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Action type foo is disabled because license information is not available at this time."`
|
||||
);
|
||||
});
|
||||
|
||||
test('should throw when license not available', () => {
|
||||
license.next({ isAvailable: false } as any);
|
||||
expect(() =>
|
||||
licenseState.ensureLicenseForActionType(fooActionType)
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Action type foo is disabled because license information is not available at this time."`
|
||||
);
|
||||
});
|
||||
|
||||
test('should throw when license is expired', () => {
|
||||
const expiredLicense = licensingMock.createLicense({ license: { status: 'expired' } });
|
||||
license.next(expiredLicense);
|
||||
expect(() =>
|
||||
licenseState.ensureLicenseForActionType(fooActionType)
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Action type foo is disabled because your basic license has expired."`
|
||||
);
|
||||
});
|
||||
|
||||
test('should throw when license is invalid', () => {
|
||||
const basicLicense = licensingMock.createLicense({
|
||||
license: { status: 'active', type: 'basic' },
|
||||
});
|
||||
license.next(basicLicense);
|
||||
expect(() =>
|
||||
licenseState.ensureLicenseForActionType(fooActionType)
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Action type foo is disabled because your basic license does not support it. Please upgrade your license."`
|
||||
);
|
||||
});
|
||||
|
||||
test('should not throw when license is valid', () => {
|
||||
const goldLicense = licensingMock.createLicense({
|
||||
license: { status: 'active', type: 'gold' },
|
||||
});
|
||||
license.next(goldLicense);
|
||||
licenseState.ensureLicenseForActionType(fooActionType);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -9,6 +9,10 @@ import { Observable, Subscription } from 'rxjs';
|
|||
import { assertNever } from '../../../../../src/core/utils';
|
||||
import { ILicense, LICENSE_CHECK_STATE } from '../../../licensing/common/types';
|
||||
import { PLUGIN } from '../constants/plugin';
|
||||
import { ActionType } from '../types';
|
||||
import { ActionTypeDisabledError } from './errors';
|
||||
|
||||
export type ILicenseState = PublicMethodsOf<LicenseState>;
|
||||
|
||||
export interface ActionsLicenseInformation {
|
||||
showAppLink: boolean;
|
||||
|
@ -19,12 +23,14 @@ export interface ActionsLicenseInformation {
|
|||
export class LicenseState {
|
||||
private licenseInformation: ActionsLicenseInformation = this.checkLicense(undefined);
|
||||
private subscription: Subscription;
|
||||
private license?: ILicense;
|
||||
|
||||
constructor(license$: Observable<ILicense>) {
|
||||
this.subscription = license$.subscribe(this.updateInformation.bind(this));
|
||||
}
|
||||
|
||||
private updateInformation(license: ILicense | undefined) {
|
||||
this.license = license;
|
||||
this.licenseInformation = this.checkLicense(license);
|
||||
}
|
||||
|
||||
|
@ -36,6 +42,71 @@ export class LicenseState {
|
|||
return this.licenseInformation;
|
||||
}
|
||||
|
||||
public isLicenseValidForActionType(
|
||||
actionType: ActionType
|
||||
): { isValid: true } | { isValid: false; reason: 'unavailable' | 'expired' | 'invalid' } {
|
||||
if (!this.license?.isAvailable) {
|
||||
return { isValid: false, reason: 'unavailable' };
|
||||
}
|
||||
|
||||
const check = this.license.check(actionType.id, actionType.minimumLicenseRequired);
|
||||
|
||||
switch (check.state) {
|
||||
case LICENSE_CHECK_STATE.Expired:
|
||||
return { isValid: false, reason: 'expired' };
|
||||
case LICENSE_CHECK_STATE.Invalid:
|
||||
return { isValid: false, reason: 'invalid' };
|
||||
case LICENSE_CHECK_STATE.Unavailable:
|
||||
return { isValid: false, reason: 'unavailable' };
|
||||
case LICENSE_CHECK_STATE.Valid:
|
||||
return { isValid: true };
|
||||
default:
|
||||
return assertNever(check.state);
|
||||
}
|
||||
}
|
||||
|
||||
public ensureLicenseForActionType(actionType: ActionType) {
|
||||
const check = this.isLicenseValidForActionType(actionType);
|
||||
|
||||
if (check.isValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (check.reason) {
|
||||
case 'unavailable':
|
||||
throw new ActionTypeDisabledError(
|
||||
i18n.translate('xpack.actions.serverSideErrors.unavailableLicenseErrorMessage', {
|
||||
defaultMessage:
|
||||
'Action type {actionTypeId} is disabled because license information is not available at this time.',
|
||||
values: {
|
||||
actionTypeId: actionType.id,
|
||||
},
|
||||
}),
|
||||
'license_unavailable'
|
||||
);
|
||||
case 'expired':
|
||||
throw new ActionTypeDisabledError(
|
||||
i18n.translate('xpack.actions.serverSideErrors.expirerdLicenseErrorMessage', {
|
||||
defaultMessage:
|
||||
'Action type {actionTypeId} is disabled because your {licenseType} license has expired.',
|
||||
values: { actionTypeId: actionType.id, licenseType: this.license!.type },
|
||||
}),
|
||||
'license_expired'
|
||||
);
|
||||
case 'invalid':
|
||||
throw new ActionTypeDisabledError(
|
||||
i18n.translate('xpack.actions.serverSideErrors.invalidLicenseErrorMessage', {
|
||||
defaultMessage:
|
||||
'Action type {actionTypeId} is disabled because your {licenseType} license does not support it. Please upgrade your license.',
|
||||
values: { actionTypeId: actionType.id, licenseType: this.license!.type },
|
||||
}),
|
||||
'license_invalid'
|
||||
);
|
||||
default:
|
||||
assertNever(check.reason);
|
||||
}
|
||||
}
|
||||
|
||||
public checkLicense(license: ILicense | undefined): ActionsLicenseInformation {
|
||||
if (!license?.isAvailable) {
|
||||
return {
|
||||
|
|
|
@ -14,6 +14,7 @@ import { actionExecutorMock } from './action_executor.mock';
|
|||
import { encryptedSavedObjectsMock } from '../../../encrypted_saved_objects/server/mocks';
|
||||
import { savedObjectsClientMock, loggingServiceMock } from 'src/core/server/mocks';
|
||||
import { eventLoggerMock } from '../../../event_log/server/mocks';
|
||||
import { ActionTypeDisabledError } from './errors';
|
||||
|
||||
const spaceIdToNamespace = jest.fn();
|
||||
const actionTypeRegistry = actionTypeRegistryMock.create();
|
||||
|
@ -63,6 +64,7 @@ const actionExecutorInitializerParams = {
|
|||
};
|
||||
const taskRunnerFactoryInitializerParams = {
|
||||
spaceIdToNamespace,
|
||||
actionTypeRegistry,
|
||||
logger: loggingServiceMock.create().get(),
|
||||
encryptedSavedObjectsPlugin: mockedEncryptedSavedObjectsPlugin,
|
||||
getBasePath: jest.fn().mockReturnValue(undefined),
|
||||
|
@ -308,3 +310,32 @@ test(`doesn't use API key when not provided`, async () => {
|
|||
},
|
||||
});
|
||||
});
|
||||
|
||||
test(`throws an error when license doesn't support the action type`, async () => {
|
||||
const taskRunner = taskRunnerFactory.create({
|
||||
taskInstance: mockedTaskInstance,
|
||||
});
|
||||
|
||||
mockedEncryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockResolvedValueOnce({
|
||||
id: '3',
|
||||
type: 'action_task_params',
|
||||
attributes: {
|
||||
actionId: '2',
|
||||
params: { baz: true },
|
||||
apiKey: Buffer.from('123:abc').toString('base64'),
|
||||
},
|
||||
references: [],
|
||||
});
|
||||
mockedActionExecutor.execute.mockImplementation(() => {
|
||||
throw new ActionTypeDisabledError('Fail', 'license_invalid');
|
||||
});
|
||||
|
||||
try {
|
||||
await taskRunner.run();
|
||||
throw new Error('Should have thrown');
|
||||
} catch (e) {
|
||||
expect(e instanceof ExecutorError).toEqual(true);
|
||||
expect(e.data).toEqual({});
|
||||
expect(e.retry).toEqual(false);
|
||||
}
|
||||
});
|
||||
|
|
|
@ -9,10 +9,18 @@ import { ExecutorError } from './executor_error';
|
|||
import { Logger, CoreStart } from '../../../../../src/core/server';
|
||||
import { RunContext } from '../../../task_manager/server';
|
||||
import { EncryptedSavedObjectsPluginStart } from '../../../encrypted_saved_objects/server';
|
||||
import { ActionTaskParams, GetBasePathFunction, SpaceIdToNamespaceFunction } from '../types';
|
||||
import { ActionTypeDisabledError } from './errors';
|
||||
import {
|
||||
ActionTaskParams,
|
||||
ActionTypeRegistryContract,
|
||||
GetBasePathFunction,
|
||||
SpaceIdToNamespaceFunction,
|
||||
ActionTypeExecutorResult,
|
||||
} from '../types';
|
||||
|
||||
export interface TaskRunnerContext {
|
||||
logger: Logger;
|
||||
actionTypeRegistry: ActionTypeRegistryContract;
|
||||
encryptedSavedObjectsPlugin: EncryptedSavedObjectsPluginStart;
|
||||
spaceIdToNamespace: SpaceIdToNamespaceFunction;
|
||||
getBasePath: GetBasePathFunction;
|
||||
|
@ -85,11 +93,20 @@ export class TaskRunnerFactory {
|
|||
},
|
||||
};
|
||||
|
||||
const executorResult = await actionExecutor.execute({
|
||||
params,
|
||||
actionId,
|
||||
request: fakeRequest,
|
||||
});
|
||||
let executorResult: ActionTypeExecutorResult;
|
||||
try {
|
||||
executorResult = await actionExecutor.execute({
|
||||
params,
|
||||
actionId,
|
||||
request: fakeRequest,
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof ActionTypeDisabledError) {
|
||||
// We'll stop re-trying due to action being forbidden
|
||||
throw new ExecutorError(e.message, {}, false);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
if (executorResult.status === 'error') {
|
||||
// Task manager error handler only kicks in when an error thrown (at this time)
|
||||
|
|
|
@ -14,7 +14,12 @@ const executor: ExecutorType = async options => {
|
|||
};
|
||||
|
||||
test('should validate when there are no validators', () => {
|
||||
const actionType: ActionType = { id: 'foo', name: 'bar', executor };
|
||||
const actionType: ActionType = {
|
||||
id: 'foo',
|
||||
name: 'bar',
|
||||
minimumLicenseRequired: 'basic',
|
||||
executor,
|
||||
};
|
||||
const testValue = { any: ['old', 'thing'] };
|
||||
|
||||
const result = validateConfig(actionType, testValue);
|
||||
|
@ -22,7 +27,13 @@ test('should validate when there are no validators', () => {
|
|||
});
|
||||
|
||||
test('should validate when there are no individual validators', () => {
|
||||
const actionType: ActionType = { id: 'foo', name: 'bar', executor, validate: {} };
|
||||
const actionType: ActionType = {
|
||||
id: 'foo',
|
||||
name: 'bar',
|
||||
minimumLicenseRequired: 'basic',
|
||||
executor,
|
||||
validate: {},
|
||||
};
|
||||
|
||||
let result;
|
||||
const testValue = { any: ['old', 'thing'] };
|
||||
|
@ -42,6 +53,7 @@ test('should validate when validators return incoming value', () => {
|
|||
const actionType: ActionType = {
|
||||
id: 'foo',
|
||||
name: 'bar',
|
||||
minimumLicenseRequired: 'basic',
|
||||
executor,
|
||||
validate: {
|
||||
params: selfValidator,
|
||||
|
@ -69,6 +81,7 @@ test('should validate when validators return different values', () => {
|
|||
const actionType: ActionType = {
|
||||
id: 'foo',
|
||||
name: 'bar',
|
||||
minimumLicenseRequired: 'basic',
|
||||
executor,
|
||||
validate: {
|
||||
params: selfValidator,
|
||||
|
@ -99,6 +112,7 @@ test('should throw with expected error when validators fail', () => {
|
|||
const actionType: ActionType = {
|
||||
id: 'foo',
|
||||
name: 'bar',
|
||||
minimumLicenseRequired: 'basic',
|
||||
executor,
|
||||
validate: {
|
||||
params: erroringValidator,
|
||||
|
@ -127,6 +141,7 @@ test('should work with @kbn/config-schema', () => {
|
|||
const actionType: ActionType = {
|
||||
id: 'foo',
|
||||
name: 'bar',
|
||||
minimumLicenseRequired: 'basic',
|
||||
executor,
|
||||
validate: {
|
||||
params: testSchema,
|
||||
|
|
|
@ -5,9 +5,9 @@
|
|||
*/
|
||||
|
||||
import Boom from 'boom';
|
||||
import { LicenseState } from './license_state';
|
||||
import { ILicenseState } from './license_state';
|
||||
|
||||
export function verifyApiAccess(licenseState: LicenseState) {
|
||||
export function verifyApiAccess(licenseState: ILicenseState) {
|
||||
const licenseCheckResults = licenseState.getLicenseInformation();
|
||||
|
||||
if (licenseCheckResults.showAppLink && licenseCheckResults.enableAppLink) {
|
|
@ -19,6 +19,7 @@ const createSetupMock = () => {
|
|||
const createStartMock = () => {
|
||||
const mock: jest.Mocked<PluginStartContract> = {
|
||||
execute: jest.fn(),
|
||||
isActionTypeEnabled: jest.fn(),
|
||||
getActionsClientWithRequest: jest.fn().mockResolvedValue(actionsClientMock.create()),
|
||||
};
|
||||
return mock;
|
||||
|
|
|
@ -4,7 +4,6 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { ActionsPlugin, ActionsPluginsSetup, ActionsPluginsStart } from './plugin';
|
||||
import { PluginInitializerContext } from '../../../../src/core/server';
|
||||
import { coreMock, httpServerMock } from '../../../../src/core/server/mocks';
|
||||
import { licensingMock } from '../../licensing/server/mocks';
|
||||
|
@ -12,6 +11,13 @@ import { encryptedSavedObjectsMock } from '../../encrypted_saved_objects/server/
|
|||
import { taskManagerMock } from '../../task_manager/server/mocks';
|
||||
import { eventLogMock } from '../../event_log/server/mocks';
|
||||
import { UsageCollectionSetup } from 'src/plugins/usage_collection/server';
|
||||
import { ActionType } from './types';
|
||||
import {
|
||||
ActionsPlugin,
|
||||
ActionsPluginsSetup,
|
||||
ActionsPluginsStart,
|
||||
PluginSetupContract,
|
||||
} from './plugin';
|
||||
|
||||
describe('Actions Plugin', () => {
|
||||
const usageCollectionMock: jest.Mocked<UsageCollectionSetup> = ({
|
||||
|
@ -97,6 +103,54 @@ describe('Actions Plugin', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerType()', () => {
|
||||
let setup: PluginSetupContract;
|
||||
const sampleActionType: ActionType = {
|
||||
id: 'test',
|
||||
name: 'test',
|
||||
minimumLicenseRequired: 'basic',
|
||||
async executor() {},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
setup = await plugin.setup(coreSetup, pluginsSetup);
|
||||
});
|
||||
|
||||
it('should throw error when license type is invalid', async () => {
|
||||
expect(() =>
|
||||
setup.registerType({
|
||||
...sampleActionType,
|
||||
minimumLicenseRequired: 'foo' as any,
|
||||
})
|
||||
).toThrowErrorMatchingInlineSnapshot(`"\\"foo\\" is not a valid license type"`);
|
||||
});
|
||||
|
||||
it('should throw error when license type is less than gold', async () => {
|
||||
expect(() =>
|
||||
setup.registerType({
|
||||
...sampleActionType,
|
||||
minimumLicenseRequired: 'basic',
|
||||
})
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Third party action type \\"test\\" can only set minimumLicenseRequired to a gold license or higher"`
|
||||
);
|
||||
});
|
||||
|
||||
it('should not throw when license type is gold', async () => {
|
||||
setup.registerType({
|
||||
...sampleActionType,
|
||||
minimumLicenseRequired: 'gold',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not throw when license type is higher than gold', async () => {
|
||||
setup.registerType({
|
||||
...sampleActionType,
|
||||
minimumLicenseRequired: 'platinum',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('start()', () => {
|
||||
let plugin: ActionsPlugin;
|
||||
|
|
|
@ -26,11 +26,12 @@ import {
|
|||
} from '../../encrypted_saved_objects/server';
|
||||
import { TaskManagerSetupContract, TaskManagerStartContract } from '../../task_manager/server';
|
||||
import { LicensingPluginSetup } from '../../licensing/server';
|
||||
import { LICENSE_TYPE } from '../../licensing/common/types';
|
||||
import { SpacesPluginSetup, SpacesServiceSetup } from '../../spaces/server';
|
||||
|
||||
import { ActionsConfig } from './config';
|
||||
import { Services } from './types';
|
||||
import { ActionExecutor, TaskRunnerFactory } from './lib';
|
||||
import { Services, ActionType } from './types';
|
||||
import { ActionExecutor, TaskRunnerFactory, LicenseState, ILicenseState } from './lib';
|
||||
import { ActionsClient } from './actions_client';
|
||||
import { ActionTypeRegistry } from './action_type_registry';
|
||||
import { ExecuteOptions } from './create_execute_function';
|
||||
|
@ -49,7 +50,6 @@ import {
|
|||
listActionTypesRoute,
|
||||
executeActionRoute,
|
||||
} from './routes';
|
||||
import { LicenseState } from './lib/license_state';
|
||||
import { IEventLogger, IEventLogService } from '../../event_log/server';
|
||||
import { initializeActionsTelemetry, scheduleActionsTelemetry } from './usage/task';
|
||||
|
||||
|
@ -60,10 +60,11 @@ export const EVENT_LOG_ACTIONS = {
|
|||
};
|
||||
|
||||
export interface PluginSetupContract {
|
||||
registerType: ActionTypeRegistry['register'];
|
||||
registerType: (actionType: ActionType) => void;
|
||||
}
|
||||
|
||||
export interface PluginStartContract {
|
||||
isActionTypeEnabled(id: string): boolean;
|
||||
execute(options: ExecuteOptions): Promise<void>;
|
||||
getActionsClientWithRequest(request: KibanaRequest): Promise<PublicMethodsOf<ActionsClient>>;
|
||||
}
|
||||
|
@ -91,7 +92,7 @@ export class ActionsPlugin implements Plugin<Promise<PluginSetupContract>, Plugi
|
|||
private taskRunnerFactory?: TaskRunnerFactory;
|
||||
private actionTypeRegistry?: ActionTypeRegistry;
|
||||
private actionExecutor?: ActionExecutor;
|
||||
private licenseState: LicenseState | null = null;
|
||||
private licenseState: ILicenseState | null = null;
|
||||
private spaces?: SpacesServiceSetup;
|
||||
private eventLogger?: IEventLogger;
|
||||
private isESOUsingEphemeralEncryptionKey?: boolean;
|
||||
|
@ -115,6 +116,7 @@ export class ActionsPlugin implements Plugin<Promise<PluginSetupContract>, Plugi
|
|||
}
|
||||
|
||||
public async setup(core: CoreSetup, plugins: ActionsPluginsSetup): Promise<PluginSetupContract> {
|
||||
this.licenseState = new LicenseState(plugins.licensing.license$);
|
||||
this.isESOUsingEphemeralEncryptionKey =
|
||||
plugins.encryptedSavedObjects.usingEphemeralEncryptionKey;
|
||||
|
||||
|
@ -156,6 +158,7 @@ export class ActionsPlugin implements Plugin<Promise<PluginSetupContract>, Plugi
|
|||
taskRunnerFactory,
|
||||
taskManager: plugins.taskManager,
|
||||
actionsConfigUtils,
|
||||
licenseState: this.licenseState,
|
||||
});
|
||||
this.taskRunnerFactory = taskRunnerFactory;
|
||||
this.actionTypeRegistry = actionTypeRegistry;
|
||||
|
@ -190,7 +193,6 @@ export class ActionsPlugin implements Plugin<Promise<PluginSetupContract>, Plugi
|
|||
);
|
||||
|
||||
// Routes
|
||||
this.licenseState = new LicenseState(plugins.licensing.license$);
|
||||
const router = core.http.createRouter();
|
||||
createActionRoute(router, this.licenseState);
|
||||
deleteActionRoute(router, this.licenseState);
|
||||
|
@ -201,7 +203,17 @@ export class ActionsPlugin implements Plugin<Promise<PluginSetupContract>, Plugi
|
|||
executeActionRoute(router, this.licenseState, actionExecutor);
|
||||
|
||||
return {
|
||||
registerType: actionTypeRegistry.register.bind(actionTypeRegistry),
|
||||
registerType: (actionType: ActionType) => {
|
||||
if (!(actionType.minimumLicenseRequired in LICENSE_TYPE)) {
|
||||
throw new Error(`"${actionType.minimumLicenseRequired}" is not a valid license type`);
|
||||
}
|
||||
if (LICENSE_TYPE[actionType.minimumLicenseRequired] < LICENSE_TYPE.gold) {
|
||||
throw new Error(
|
||||
`Third party action type "${actionType.id}" can only set minimumLicenseRequired to a gold license or higher`
|
||||
);
|
||||
}
|
||||
actionTypeRegistry.register(actionType);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -227,6 +239,7 @@ export class ActionsPlugin implements Plugin<Promise<PluginSetupContract>, Plugi
|
|||
|
||||
taskRunnerFactory!.initialize({
|
||||
logger,
|
||||
actionTypeRegistry: actionTypeRegistry!,
|
||||
encryptedSavedObjectsPlugin: plugins.encryptedSavedObjects,
|
||||
getBasePath: this.getBasePath,
|
||||
spaceIdToNamespace: this.spaceIdToNamespace,
|
||||
|
@ -238,10 +251,14 @@ export class ActionsPlugin implements Plugin<Promise<PluginSetupContract>, Plugi
|
|||
return {
|
||||
execute: createExecuteFunction({
|
||||
taskManager: plugins.taskManager,
|
||||
actionTypeRegistry: actionTypeRegistry!,
|
||||
getScopedSavedObjectsClient: core.savedObjects.getScopedClient,
|
||||
getBasePath: this.getBasePath,
|
||||
isESOUsingEphemeralEncryptionKey: isESOUsingEphemeralEncryptionKey!,
|
||||
}),
|
||||
isActionTypeEnabled: id => {
|
||||
return this.actionTypeRegistry!.isActionTypeEnabled(id);
|
||||
},
|
||||
// Ability to get an actions client from legacy code
|
||||
async getActionsClientWithRequest(request: KibanaRequest) {
|
||||
if (isESOUsingEphemeralEncryptionKey === true) {
|
||||
|
|
|
@ -5,11 +5,11 @@
|
|||
*/
|
||||
import { createActionRoute } from './create';
|
||||
import { mockRouter, RouterMock } from '../../../../../src/core/server/http/router/router.mock';
|
||||
import { mockLicenseState } from '../lib/license_state.mock';
|
||||
import { verifyApiAccess } from '../lib/license_api_access';
|
||||
import { licenseStateMock } from '../lib/license_state.mock';
|
||||
import { verifyApiAccess, ActionTypeDisabledError } from '../lib';
|
||||
import { mockHandlerArguments } from './_mock_handler_arguments';
|
||||
|
||||
jest.mock('../lib/license_api_access.ts', () => ({
|
||||
jest.mock('../lib/verify_api_access.ts', () => ({
|
||||
verifyApiAccess: jest.fn(),
|
||||
}));
|
||||
|
||||
|
@ -19,7 +19,7 @@ beforeEach(() => {
|
|||
|
||||
describe('createActionRoute', () => {
|
||||
it('creates an action with proper parameters', async () => {
|
||||
const licenseState = mockLicenseState();
|
||||
const licenseState = licenseStateMock.create();
|
||||
const router: RouterMock = mockRouter.create();
|
||||
|
||||
createActionRoute(router, licenseState);
|
||||
|
@ -82,7 +82,7 @@ describe('createActionRoute', () => {
|
|||
});
|
||||
|
||||
it('ensures the license allows creating actions', async () => {
|
||||
const licenseState = mockLicenseState();
|
||||
const licenseState = licenseStateMock.create();
|
||||
const router: RouterMock = mockRouter.create();
|
||||
|
||||
createActionRoute(router, licenseState);
|
||||
|
@ -106,7 +106,7 @@ describe('createActionRoute', () => {
|
|||
});
|
||||
|
||||
it('ensures the license check prevents creating actions', async () => {
|
||||
const licenseState = mockLicenseState();
|
||||
const licenseState = licenseStateMock.create();
|
||||
const router: RouterMock = mockRouter.create();
|
||||
|
||||
(verifyApiAccess as jest.Mock).mockImplementation(() => {
|
||||
|
@ -132,4 +132,23 @@ describe('createActionRoute', () => {
|
|||
|
||||
expect(verifyApiAccess).toHaveBeenCalledWith(licenseState);
|
||||
});
|
||||
|
||||
it('ensures the action type gets validated for the license', async () => {
|
||||
const licenseState = licenseStateMock.create();
|
||||
const router: RouterMock = mockRouter.create();
|
||||
|
||||
createActionRoute(router, licenseState);
|
||||
|
||||
const [, handler] = router.post.mock.calls[0];
|
||||
|
||||
const actionsClient = {
|
||||
create: jest.fn().mockRejectedValue(new ActionTypeDisabledError('Fail', 'license_invalid')),
|
||||
};
|
||||
|
||||
const [context, req, res] = mockHandlerArguments({ actionsClient }, {}, ['ok', 'forbidden']);
|
||||
|
||||
await handler(context, req, res);
|
||||
|
||||
expect(res.forbidden).toHaveBeenCalledWith({ body: { message: 'Fail' } });
|
||||
});
|
||||
});
|
||||
|
|
|
@ -13,8 +13,7 @@ import {
|
|||
KibanaResponseFactory,
|
||||
} from 'kibana/server';
|
||||
import { ActionResult } from '../types';
|
||||
import { LicenseState } from '../lib/license_state';
|
||||
import { verifyApiAccess } from '../lib/license_api_access';
|
||||
import { ILicenseState, verifyApiAccess, isErrorThatHandlesItsOwnResponse } from '../lib';
|
||||
|
||||
export const bodySchema = schema.object({
|
||||
name: schema.string(),
|
||||
|
@ -23,7 +22,7 @@ export const bodySchema = schema.object({
|
|||
secrets: schema.recordOf(schema.string(), schema.any(), { defaultValue: {} }),
|
||||
});
|
||||
|
||||
export const createActionRoute = (router: IRouter, licenseState: LicenseState) => {
|
||||
export const createActionRoute = (router: IRouter, licenseState: ILicenseState) => {
|
||||
router.post(
|
||||
{
|
||||
path: `/api/action`,
|
||||
|
@ -46,10 +45,17 @@ export const createActionRoute = (router: IRouter, licenseState: LicenseState) =
|
|||
}
|
||||
const actionsClient = context.actions.getActionsClient();
|
||||
const action = req.body;
|
||||
const actionRes: ActionResult = await actionsClient.create({ action });
|
||||
return res.ok({
|
||||
body: actionRes,
|
||||
});
|
||||
try {
|
||||
const actionRes: ActionResult = await actionsClient.create({ action });
|
||||
return res.ok({
|
||||
body: actionRes,
|
||||
});
|
||||
} catch (e) {
|
||||
if (isErrorThatHandlesItsOwnResponse(e)) {
|
||||
return e.sendResponse(res);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
})
|
||||
);
|
||||
};
|
||||
|
|
|
@ -5,11 +5,11 @@
|
|||
*/
|
||||
import { deleteActionRoute } from './delete';
|
||||
import { mockRouter, RouterMock } from '../../../../../src/core/server/http/router/router.mock';
|
||||
import { mockLicenseState } from '../lib/license_state.mock';
|
||||
import { verifyApiAccess } from '../lib/license_api_access';
|
||||
import { licenseStateMock } from '../lib/license_state.mock';
|
||||
import { verifyApiAccess } from '../lib';
|
||||
import { mockHandlerArguments } from './_mock_handler_arguments';
|
||||
|
||||
jest.mock('../lib/license_api_access.ts', () => ({
|
||||
jest.mock('../lib/verify_api_access.ts', () => ({
|
||||
verifyApiAccess: jest.fn(),
|
||||
}));
|
||||
|
||||
|
@ -19,7 +19,7 @@ beforeEach(() => {
|
|||
|
||||
describe('deleteActionRoute', () => {
|
||||
it('deletes an action with proper parameters', async () => {
|
||||
const licenseState = mockLicenseState();
|
||||
const licenseState = licenseStateMock.create();
|
||||
const router: RouterMock = mockRouter.create();
|
||||
|
||||
deleteActionRoute(router, licenseState);
|
||||
|
@ -64,7 +64,7 @@ describe('deleteActionRoute', () => {
|
|||
});
|
||||
|
||||
it('ensures the license allows deleting actions', async () => {
|
||||
const licenseState = mockLicenseState();
|
||||
const licenseState = licenseStateMock.create();
|
||||
const router: RouterMock = mockRouter.create();
|
||||
|
||||
deleteActionRoute(router, licenseState);
|
||||
|
@ -85,7 +85,7 @@ describe('deleteActionRoute', () => {
|
|||
});
|
||||
|
||||
it('ensures the license check prevents deleting actions', async () => {
|
||||
const licenseState = mockLicenseState();
|
||||
const licenseState = licenseStateMock.create();
|
||||
const router: RouterMock = mockRouter.create();
|
||||
|
||||
(verifyApiAccess as jest.Mock).mockImplementation(() => {
|
||||
|
|
|
@ -17,14 +17,13 @@ import {
|
|||
IKibanaResponse,
|
||||
KibanaResponseFactory,
|
||||
} from 'kibana/server';
|
||||
import { LicenseState } from '../lib/license_state';
|
||||
import { verifyApiAccess } from '../lib/license_api_access';
|
||||
import { ILicenseState, verifyApiAccess } from '../lib';
|
||||
|
||||
const paramSchema = schema.object({
|
||||
id: schema.string(),
|
||||
});
|
||||
|
||||
export const deleteActionRoute = (router: IRouter, licenseState: LicenseState) => {
|
||||
export const deleteActionRoute = (router: IRouter, licenseState: ILicenseState) => {
|
||||
router.delete(
|
||||
{
|
||||
path: `/api/action/{id}`,
|
||||
|
|
|
@ -6,12 +6,11 @@
|
|||
|
||||
import { executeActionRoute } from './execute';
|
||||
import { mockRouter, RouterMock } from '../../../../../src/core/server/http/router/router.mock';
|
||||
import { mockLicenseState } from '../lib/license_state.mock';
|
||||
import { verifyApiAccess } from '../lib/license_api_access';
|
||||
import { licenseStateMock } from '../lib/license_state.mock';
|
||||
import { mockHandlerArguments } from './_mock_handler_arguments';
|
||||
import { ActionExecutorContract } from '../lib';
|
||||
import { ActionExecutorContract, verifyApiAccess, ActionTypeDisabledError } from '../lib';
|
||||
|
||||
jest.mock('../lib/license_api_access.ts', () => ({
|
||||
jest.mock('../lib/verify_api_access.ts', () => ({
|
||||
verifyApiAccess: jest.fn(),
|
||||
}));
|
||||
|
||||
|
@ -21,7 +20,7 @@ beforeEach(() => {
|
|||
|
||||
describe('executeActionRoute', () => {
|
||||
it('executes an action with proper parameters', async () => {
|
||||
const licenseState = mockLicenseState();
|
||||
const licenseState = licenseStateMock.create();
|
||||
const router: RouterMock = mockRouter.create();
|
||||
|
||||
const [context, req, res] = mockHandlerArguments(
|
||||
|
@ -77,7 +76,7 @@ describe('executeActionRoute', () => {
|
|||
});
|
||||
|
||||
it('returns a "204 NO CONTENT" when the executor returns a nullish value', async () => {
|
||||
const licenseState = mockLicenseState();
|
||||
const licenseState = licenseStateMock.create();
|
||||
const router: RouterMock = mockRouter.create();
|
||||
|
||||
const [context, req, res] = mockHandlerArguments(
|
||||
|
@ -115,7 +114,7 @@ describe('executeActionRoute', () => {
|
|||
});
|
||||
|
||||
it('ensures the license allows action execution', async () => {
|
||||
const licenseState = mockLicenseState();
|
||||
const licenseState = licenseStateMock.create();
|
||||
const router: RouterMock = mockRouter.create();
|
||||
|
||||
const [context, req, res] = mockHandlerArguments(
|
||||
|
@ -147,7 +146,7 @@ describe('executeActionRoute', () => {
|
|||
});
|
||||
|
||||
it('ensures the license check prevents action execution', async () => {
|
||||
const licenseState = mockLicenseState();
|
||||
const licenseState = licenseStateMock.create();
|
||||
const router: RouterMock = mockRouter.create();
|
||||
|
||||
(verifyApiAccess as jest.Mock).mockImplementation(() => {
|
||||
|
@ -181,4 +180,33 @@ describe('executeActionRoute', () => {
|
|||
|
||||
expect(verifyApiAccess).toHaveBeenCalledWith(licenseState);
|
||||
});
|
||||
|
||||
it('ensures the action type gets validated for the license', async () => {
|
||||
const licenseState = licenseStateMock.create();
|
||||
const router: RouterMock = mockRouter.create();
|
||||
|
||||
const [context, req, res] = mockHandlerArguments(
|
||||
{},
|
||||
{
|
||||
body: {},
|
||||
params: {},
|
||||
},
|
||||
['ok', 'forbidden']
|
||||
);
|
||||
|
||||
const actionExecutor = {
|
||||
initialize: jest.fn(),
|
||||
execute: jest.fn().mockImplementation(() => {
|
||||
throw new ActionTypeDisabledError('Fail', 'license_invalid');
|
||||
}),
|
||||
} as jest.Mocked<ActionExecutorContract>;
|
||||
|
||||
executeActionRoute(router, licenseState, actionExecutor);
|
||||
|
||||
const [, handler] = router.post.mock.calls[0];
|
||||
|
||||
await handler(context, req, res);
|
||||
|
||||
expect(res.forbidden).toHaveBeenCalledWith({ body: { message: 'Fail' } });
|
||||
});
|
||||
});
|
||||
|
|
|
@ -11,8 +11,7 @@ import {
|
|||
IKibanaResponse,
|
||||
KibanaResponseFactory,
|
||||
} from 'kibana/server';
|
||||
import { LicenseState } from '../lib/license_state';
|
||||
import { verifyApiAccess } from '../lib/license_api_access';
|
||||
import { ILicenseState, verifyApiAccess, isErrorThatHandlesItsOwnResponse } from '../lib';
|
||||
|
||||
import { ActionExecutorContract } from '../lib';
|
||||
import { ActionTypeExecutorResult } from '../types';
|
||||
|
@ -27,7 +26,7 @@ const bodySchema = schema.object({
|
|||
|
||||
export const executeActionRoute = (
|
||||
router: IRouter,
|
||||
licenseState: LicenseState,
|
||||
licenseState: ILicenseState,
|
||||
actionExecutor: ActionExecutorContract
|
||||
) => {
|
||||
router.post(
|
||||
|
@ -49,16 +48,23 @@ export const executeActionRoute = (
|
|||
verifyApiAccess(licenseState);
|
||||
const { params } = req.body;
|
||||
const { id } = req.params;
|
||||
const body: ActionTypeExecutorResult = await actionExecutor.execute({
|
||||
params,
|
||||
request: req,
|
||||
actionId: id,
|
||||
});
|
||||
return body
|
||||
? res.ok({
|
||||
body,
|
||||
})
|
||||
: res.noContent();
|
||||
try {
|
||||
const body: ActionTypeExecutorResult = await actionExecutor.execute({
|
||||
params,
|
||||
request: req,
|
||||
actionId: id,
|
||||
});
|
||||
return body
|
||||
? res.ok({
|
||||
body,
|
||||
})
|
||||
: res.noContent();
|
||||
} catch (e) {
|
||||
if (isErrorThatHandlesItsOwnResponse(e)) {
|
||||
return e.sendResponse(res);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
})
|
||||
);
|
||||
};
|
||||
|
|
|
@ -6,11 +6,11 @@
|
|||
|
||||
import { findActionRoute } from './find';
|
||||
import { mockRouter, RouterMock } from '../../../../../src/core/server/http/router/router.mock';
|
||||
import { mockLicenseState } from '../lib/license_state.mock';
|
||||
import { verifyApiAccess } from '../lib/license_api_access';
|
||||
import { licenseStateMock } from '../lib/license_state.mock';
|
||||
import { verifyApiAccess } from '../lib';
|
||||
import { mockHandlerArguments } from './_mock_handler_arguments';
|
||||
|
||||
jest.mock('../lib/license_api_access.ts', () => ({
|
||||
jest.mock('../lib/verify_api_access.ts', () => ({
|
||||
verifyApiAccess: jest.fn(),
|
||||
}));
|
||||
|
||||
|
@ -20,7 +20,7 @@ beforeEach(() => {
|
|||
|
||||
describe('findActionRoute', () => {
|
||||
it('finds actions with proper parameters', async () => {
|
||||
const licenseState = mockLicenseState();
|
||||
const licenseState = licenseStateMock.create();
|
||||
const router: RouterMock = mockRouter.create();
|
||||
|
||||
findActionRoute(router, licenseState);
|
||||
|
@ -93,7 +93,7 @@ describe('findActionRoute', () => {
|
|||
});
|
||||
|
||||
it('ensures the license allows finding actions', async () => {
|
||||
const licenseState = mockLicenseState();
|
||||
const licenseState = licenseStateMock.create();
|
||||
const router: RouterMock = mockRouter.create();
|
||||
|
||||
findActionRoute(router, licenseState);
|
||||
|
@ -123,7 +123,7 @@ describe('findActionRoute', () => {
|
|||
});
|
||||
|
||||
it('ensures the license check prevents finding actions', async () => {
|
||||
const licenseState = mockLicenseState();
|
||||
const licenseState = licenseStateMock.create();
|
||||
const router: RouterMock = mockRouter.create();
|
||||
|
||||
(verifyApiAccess as jest.Mock).mockImplementation(() => {
|
||||
|
|
|
@ -13,8 +13,7 @@ import {
|
|||
KibanaResponseFactory,
|
||||
} from 'kibana/server';
|
||||
import { FindOptions } from '../../../alerting/server';
|
||||
import { LicenseState } from '../lib/license_state';
|
||||
import { verifyApiAccess } from '../lib/license_api_access';
|
||||
import { ILicenseState, verifyApiAccess } from '../lib';
|
||||
|
||||
// config definition
|
||||
const querySchema = schema.object({
|
||||
|
@ -41,7 +40,7 @@ const querySchema = schema.object({
|
|||
filter: schema.maybe(schema.string()),
|
||||
});
|
||||
|
||||
export const findActionRoute = (router: IRouter, licenseState: LicenseState) => {
|
||||
export const findActionRoute = (router: IRouter, licenseState: ILicenseState) => {
|
||||
router.get(
|
||||
{
|
||||
path: `/api/action/_find`,
|
||||
|
|
|
@ -6,11 +6,11 @@
|
|||
|
||||
import { getActionRoute } from './get';
|
||||
import { mockRouter, RouterMock } from '../../../../../src/core/server/http/router/router.mock';
|
||||
import { mockLicenseState } from '../lib/license_state.mock';
|
||||
import { verifyApiAccess } from '../lib/license_api_access';
|
||||
import { licenseStateMock } from '../lib/license_state.mock';
|
||||
import { verifyApiAccess } from '../lib';
|
||||
import { mockHandlerArguments } from './_mock_handler_arguments';
|
||||
|
||||
jest.mock('../lib/license_api_access.ts', () => ({
|
||||
jest.mock('../lib/verify_api_access.ts', () => ({
|
||||
verifyApiAccess: jest.fn(),
|
||||
}));
|
||||
|
||||
|
@ -20,7 +20,7 @@ beforeEach(() => {
|
|||
|
||||
describe('getActionRoute', () => {
|
||||
it('gets an action with proper parameters', async () => {
|
||||
const licenseState = mockLicenseState();
|
||||
const licenseState = licenseStateMock.create();
|
||||
const router: RouterMock = mockRouter.create();
|
||||
|
||||
getActionRoute(router, licenseState);
|
||||
|
@ -74,7 +74,7 @@ describe('getActionRoute', () => {
|
|||
});
|
||||
|
||||
it('ensures the license allows getting actions', async () => {
|
||||
const licenseState = mockLicenseState();
|
||||
const licenseState = licenseStateMock.create();
|
||||
const router: RouterMock = mockRouter.create();
|
||||
|
||||
getActionRoute(router, licenseState);
|
||||
|
@ -104,7 +104,7 @@ describe('getActionRoute', () => {
|
|||
});
|
||||
|
||||
it('ensures the license check prevents getting actions', async () => {
|
||||
const licenseState = mockLicenseState();
|
||||
const licenseState = licenseStateMock.create();
|
||||
const router: RouterMock = mockRouter.create();
|
||||
|
||||
(verifyApiAccess as jest.Mock).mockImplementation(() => {
|
||||
|
|
|
@ -12,14 +12,13 @@ import {
|
|||
IKibanaResponse,
|
||||
KibanaResponseFactory,
|
||||
} from 'kibana/server';
|
||||
import { LicenseState } from '../lib/license_state';
|
||||
import { verifyApiAccess } from '../lib/license_api_access';
|
||||
import { ILicenseState, verifyApiAccess } from '../lib';
|
||||
|
||||
const paramSchema = schema.object({
|
||||
id: schema.string(),
|
||||
});
|
||||
|
||||
export const getActionRoute = (router: IRouter, licenseState: LicenseState) => {
|
||||
export const getActionRoute = (router: IRouter, licenseState: ILicenseState) => {
|
||||
router.get(
|
||||
{
|
||||
path: `/api/action/{id}`,
|
||||
|
|
|
@ -6,11 +6,11 @@
|
|||
|
||||
import { listActionTypesRoute } from './list_action_types';
|
||||
import { mockRouter, RouterMock } from '../../../../../src/core/server/http/router/router.mock';
|
||||
import { mockLicenseState } from '../lib/license_state.mock';
|
||||
import { verifyApiAccess } from '../lib/license_api_access';
|
||||
import { licenseStateMock } from '../lib/license_state.mock';
|
||||
import { verifyApiAccess } from '../lib';
|
||||
import { mockHandlerArguments } from './_mock_handler_arguments';
|
||||
|
||||
jest.mock('../lib/license_api_access.ts', () => ({
|
||||
jest.mock('../lib/verify_api_access.ts', () => ({
|
||||
verifyApiAccess: jest.fn(),
|
||||
}));
|
||||
|
||||
|
@ -20,7 +20,7 @@ beforeEach(() => {
|
|||
|
||||
describe('listActionTypesRoute', () => {
|
||||
it('lists action types with proper parameters', async () => {
|
||||
const licenseState = mockLicenseState();
|
||||
const licenseState = licenseStateMock.create();
|
||||
const router: RouterMock = mockRouter.create();
|
||||
|
||||
listActionTypesRoute(router, licenseState);
|
||||
|
@ -66,7 +66,7 @@ describe('listActionTypesRoute', () => {
|
|||
});
|
||||
|
||||
it('ensures the license allows listing action types', async () => {
|
||||
const licenseState = mockLicenseState();
|
||||
const licenseState = licenseStateMock.create();
|
||||
const router: RouterMock = mockRouter.create();
|
||||
|
||||
listActionTypesRoute(router, licenseState);
|
||||
|
@ -104,7 +104,7 @@ describe('listActionTypesRoute', () => {
|
|||
});
|
||||
|
||||
it('ensures the license check prevents listing action types', async () => {
|
||||
const licenseState = mockLicenseState();
|
||||
const licenseState = licenseStateMock.create();
|
||||
const router: RouterMock = mockRouter.create();
|
||||
|
||||
(verifyApiAccess as jest.Mock).mockImplementation(() => {
|
||||
|
|
|
@ -11,10 +11,9 @@ import {
|
|||
IKibanaResponse,
|
||||
KibanaResponseFactory,
|
||||
} from 'kibana/server';
|
||||
import { LicenseState } from '../lib/license_state';
|
||||
import { verifyApiAccess } from '../lib/license_api_access';
|
||||
import { ILicenseState, verifyApiAccess } from '../lib';
|
||||
|
||||
export const listActionTypesRoute = (router: IRouter, licenseState: LicenseState) => {
|
||||
export const listActionTypesRoute = (router: IRouter, licenseState: ILicenseState) => {
|
||||
router.get(
|
||||
{
|
||||
path: `/api/action/types`,
|
||||
|
|
|
@ -5,11 +5,11 @@
|
|||
*/
|
||||
import { updateActionRoute } from './update';
|
||||
import { mockRouter, RouterMock } from '../../../../../src/core/server/http/router/router.mock';
|
||||
import { mockLicenseState } from '../lib/license_state.mock';
|
||||
import { verifyApiAccess } from '../lib/license_api_access';
|
||||
import { licenseStateMock } from '../lib/license_state.mock';
|
||||
import { verifyApiAccess, ActionTypeDisabledError } from '../lib';
|
||||
import { mockHandlerArguments } from './_mock_handler_arguments';
|
||||
|
||||
jest.mock('../lib/license_api_access.ts', () => ({
|
||||
jest.mock('../lib/verify_api_access.ts', () => ({
|
||||
verifyApiAccess: jest.fn(),
|
||||
}));
|
||||
|
||||
|
@ -19,7 +19,7 @@ beforeEach(() => {
|
|||
|
||||
describe('updateActionRoute', () => {
|
||||
it('updates an action with proper parameters', async () => {
|
||||
const licenseState = mockLicenseState();
|
||||
const licenseState = licenseStateMock.create();
|
||||
const router: RouterMock = mockRouter.create();
|
||||
|
||||
updateActionRoute(router, licenseState);
|
||||
|
@ -85,7 +85,7 @@ describe('updateActionRoute', () => {
|
|||
});
|
||||
|
||||
it('ensures the license allows deleting actions', async () => {
|
||||
const licenseState = mockLicenseState();
|
||||
const licenseState = licenseStateMock.create();
|
||||
const router: RouterMock = mockRouter.create();
|
||||
|
||||
updateActionRoute(router, licenseState);
|
||||
|
@ -124,7 +124,7 @@ describe('updateActionRoute', () => {
|
|||
});
|
||||
|
||||
it('ensures the license check prevents deleting actions', async () => {
|
||||
const licenseState = mockLicenseState();
|
||||
const licenseState = licenseStateMock.create();
|
||||
const router: RouterMock = mockRouter.create();
|
||||
|
||||
(verifyApiAccess as jest.Mock).mockImplementation(() => {
|
||||
|
@ -165,4 +165,26 @@ describe('updateActionRoute', () => {
|
|||
|
||||
expect(verifyApiAccess).toHaveBeenCalledWith(licenseState);
|
||||
});
|
||||
|
||||
it('ensures the action type gets validated for the license', async () => {
|
||||
const licenseState = licenseStateMock.create();
|
||||
const router: RouterMock = mockRouter.create();
|
||||
|
||||
updateActionRoute(router, licenseState);
|
||||
|
||||
const [, handler] = router.put.mock.calls[0];
|
||||
|
||||
const actionsClient = {
|
||||
update: jest.fn().mockRejectedValue(new ActionTypeDisabledError('Fail', 'license_invalid')),
|
||||
};
|
||||
|
||||
const [context, req, res] = mockHandlerArguments({ actionsClient }, { params: {}, body: {} }, [
|
||||
'ok',
|
||||
'forbidden',
|
||||
]);
|
||||
|
||||
await handler(context, req, res);
|
||||
|
||||
expect(res.forbidden).toHaveBeenCalledWith({ body: { message: 'Fail' } });
|
||||
});
|
||||
});
|
||||
|
|
|
@ -12,8 +12,7 @@ import {
|
|||
IKibanaResponse,
|
||||
KibanaResponseFactory,
|
||||
} from 'kibana/server';
|
||||
import { LicenseState } from '../lib/license_state';
|
||||
import { verifyApiAccess } from '../lib/license_api_access';
|
||||
import { ILicenseState, verifyApiAccess, isErrorThatHandlesItsOwnResponse } from '../lib';
|
||||
|
||||
const paramSchema = schema.object({
|
||||
id: schema.string(),
|
||||
|
@ -25,7 +24,7 @@ const bodySchema = schema.object({
|
|||
secrets: schema.recordOf(schema.string(), schema.any(), { defaultValue: {} }),
|
||||
});
|
||||
|
||||
export const updateActionRoute = (router: IRouter, licenseState: LicenseState) => {
|
||||
export const updateActionRoute = (router: IRouter, licenseState: ILicenseState) => {
|
||||
router.put(
|
||||
{
|
||||
path: `/api/action/{id}`,
|
||||
|
@ -49,12 +48,20 @@ export const updateActionRoute = (router: IRouter, licenseState: LicenseState) =
|
|||
const actionsClient = context.actions.getActionsClient();
|
||||
const { id } = req.params;
|
||||
const { name, config, secrets } = req.body;
|
||||
return res.ok({
|
||||
body: await actionsClient.update({
|
||||
id,
|
||||
action: { name, config, secrets },
|
||||
}),
|
||||
});
|
||||
|
||||
try {
|
||||
return res.ok({
|
||||
body: await actionsClient.update({
|
||||
id,
|
||||
action: { name, config, secrets },
|
||||
}),
|
||||
});
|
||||
} catch (e) {
|
||||
if (isErrorThatHandlesItsOwnResponse(e)) {
|
||||
return e.sendResponse(res);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
})
|
||||
);
|
||||
};
|
||||
|
|
|
@ -8,6 +8,7 @@ import { SavedObjectsClientContract, SavedObjectAttributes } from '../../../../s
|
|||
import { ActionTypeRegistry } from './action_type_registry';
|
||||
import { PluginSetupContract, PluginStartContract } from './plugin';
|
||||
import { ActionsClient } from './actions_client';
|
||||
import { LicenseType } from '../../licensing/common/types';
|
||||
|
||||
export type WithoutQueryAndParams<T> = Pick<T, Exclude<keyof T, 'query' | 'params'>>;
|
||||
export type GetServicesFunction = (request: any) => Services;
|
||||
|
@ -84,6 +85,7 @@ export interface ActionType {
|
|||
id: string;
|
||||
name: string;
|
||||
maxAttempts?: number;
|
||||
minimumLicenseRequired: LicenseType;
|
||||
validate?: {
|
||||
params?: ValidatorType;
|
||||
config?: ValidatorType;
|
||||
|
|
|
@ -206,7 +206,7 @@ export class AlertingPlugin {
|
|||
logger,
|
||||
getServices: this.getServicesFactory(core.savedObjects),
|
||||
spaceIdToNamespace: this.spaceIdToNamespace,
|
||||
executeAction: plugins.actions.execute,
|
||||
actionsPlugin: plugins.actions,
|
||||
encryptedSavedObjectsPlugin: plugins.encryptedSavedObjects,
|
||||
getBasePath: this.getBasePath,
|
||||
});
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
import { AlertType } from '../types';
|
||||
import { createExecutionHandler } from './create_execution_handler';
|
||||
import { loggingServiceMock } from '../../../../../src/core/server/mocks';
|
||||
import { actionsMock } from '../../../actions/server/mocks';
|
||||
|
||||
const alertType: AlertType = {
|
||||
id: 'test',
|
||||
|
@ -20,7 +21,7 @@ const alertType: AlertType = {
|
|||
};
|
||||
|
||||
const createExecutionHandlerParams = {
|
||||
executeAction: jest.fn(),
|
||||
actionsPlugin: actionsMock.createStart(),
|
||||
spaceId: 'default',
|
||||
alertId: '1',
|
||||
alertName: 'name-of-alert',
|
||||
|
@ -45,9 +46,12 @@ const createExecutionHandlerParams = {
|
|||
],
|
||||
};
|
||||
|
||||
beforeEach(() => jest.resetAllMocks());
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
createExecutionHandlerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue(true);
|
||||
});
|
||||
|
||||
test('calls executeAction per selected action', async () => {
|
||||
test('calls actionsPlugin.execute per selected action', async () => {
|
||||
const executionHandler = createExecutionHandler(createExecutionHandlerParams);
|
||||
await executionHandler({
|
||||
actionGroup: 'default',
|
||||
|
@ -55,8 +59,8 @@ test('calls executeAction per selected action', async () => {
|
|||
context: {},
|
||||
alertInstanceId: '2',
|
||||
});
|
||||
expect(createExecutionHandlerParams.executeAction).toHaveBeenCalledTimes(1);
|
||||
expect(createExecutionHandlerParams.executeAction.mock.calls[0]).toMatchInlineSnapshot(`
|
||||
expect(createExecutionHandlerParams.actionsPlugin.execute).toHaveBeenCalledTimes(1);
|
||||
expect(createExecutionHandlerParams.actionsPlugin.execute.mock.calls[0]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"apiKey": "MTIzOmFiYw==",
|
||||
|
@ -73,7 +77,46 @@ test('calls executeAction per selected action', async () => {
|
|||
`);
|
||||
});
|
||||
|
||||
test('limits executeAction per action group', async () => {
|
||||
test(`doesn't call actionsPlugin.execute for disabled actionTypes`, async () => {
|
||||
// Mock two calls, one for check against actions[0] and the second for actions[1]
|
||||
createExecutionHandlerParams.actionsPlugin.isActionTypeEnabled.mockReturnValueOnce(false);
|
||||
createExecutionHandlerParams.actionsPlugin.isActionTypeEnabled.mockReturnValueOnce(true);
|
||||
const executionHandler = createExecutionHandler({
|
||||
...createExecutionHandlerParams,
|
||||
actions: [
|
||||
...createExecutionHandlerParams.actions,
|
||||
{
|
||||
id: '2',
|
||||
group: 'default',
|
||||
actionTypeId: 'test2',
|
||||
params: {
|
||||
foo: true,
|
||||
contextVal: 'My other {{context.value}} goes here',
|
||||
stateVal: 'My other {{state.value}} goes here',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
await executionHandler({
|
||||
actionGroup: 'default',
|
||||
state: {},
|
||||
context: {},
|
||||
alertInstanceId: '2',
|
||||
});
|
||||
expect(createExecutionHandlerParams.actionsPlugin.execute).toHaveBeenCalledTimes(1);
|
||||
expect(createExecutionHandlerParams.actionsPlugin.execute).toHaveBeenCalledWith({
|
||||
id: '2',
|
||||
params: {
|
||||
foo: true,
|
||||
contextVal: 'My other goes here',
|
||||
stateVal: 'My other goes here',
|
||||
},
|
||||
spaceId: 'default',
|
||||
apiKey: createExecutionHandlerParams.apiKey,
|
||||
});
|
||||
});
|
||||
|
||||
test('limits actionsPlugin.execute per action group', async () => {
|
||||
const executionHandler = createExecutionHandler(createExecutionHandlerParams);
|
||||
await executionHandler({
|
||||
actionGroup: 'other-group',
|
||||
|
@ -81,7 +124,7 @@ test('limits executeAction per action group', async () => {
|
|||
context: {},
|
||||
alertInstanceId: '2',
|
||||
});
|
||||
expect(createExecutionHandlerParams.executeAction).toMatchInlineSnapshot(`[MockFunction]`);
|
||||
expect(createExecutionHandlerParams.actionsPlugin.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('context attribute gets parameterized', async () => {
|
||||
|
@ -92,8 +135,8 @@ test('context attribute gets parameterized', async () => {
|
|||
state: {},
|
||||
alertInstanceId: '2',
|
||||
});
|
||||
expect(createExecutionHandlerParams.executeAction).toHaveBeenCalledTimes(1);
|
||||
expect(createExecutionHandlerParams.executeAction.mock.calls[0]).toMatchInlineSnapshot(`
|
||||
expect(createExecutionHandlerParams.actionsPlugin.execute).toHaveBeenCalledTimes(1);
|
||||
expect(createExecutionHandlerParams.actionsPlugin.execute.mock.calls[0]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"apiKey": "MTIzOmFiYw==",
|
||||
|
@ -118,8 +161,8 @@ test('state attribute gets parameterized', async () => {
|
|||
state: { value: 'state-val' },
|
||||
alertInstanceId: '2',
|
||||
});
|
||||
expect(createExecutionHandlerParams.executeAction).toHaveBeenCalledTimes(1);
|
||||
expect(createExecutionHandlerParams.executeAction.mock.calls[0]).toMatchInlineSnapshot(`
|
||||
expect(createExecutionHandlerParams.actionsPlugin.execute).toHaveBeenCalledTimes(1);
|
||||
expect(createExecutionHandlerParams.actionsPlugin.execute.mock.calls[0]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"apiKey": "MTIzOmFiYw==",
|
||||
|
|
|
@ -14,7 +14,7 @@ interface CreateExecutionHandlerOptions {
|
|||
alertId: string;
|
||||
alertName: string;
|
||||
tags?: string[];
|
||||
executeAction: ActionsPluginStartContract['execute'];
|
||||
actionsPlugin: ActionsPluginStartContract;
|
||||
actions: AlertAction[];
|
||||
spaceId: string;
|
||||
apiKey: string | null;
|
||||
|
@ -34,7 +34,7 @@ export function createExecutionHandler({
|
|||
alertId,
|
||||
alertName,
|
||||
tags,
|
||||
executeAction,
|
||||
actionsPlugin,
|
||||
actions: alertActions,
|
||||
spaceId,
|
||||
apiKey,
|
||||
|
@ -64,12 +64,18 @@ export function createExecutionHandler({
|
|||
};
|
||||
});
|
||||
for (const action of actions) {
|
||||
await executeAction({
|
||||
id: action.id,
|
||||
params: action.params,
|
||||
spaceId,
|
||||
apiKey,
|
||||
});
|
||||
if (actionsPlugin.isActionTypeEnabled(action.actionTypeId)) {
|
||||
await actionsPlugin.execute({
|
||||
id: action.id,
|
||||
params: action.params,
|
||||
spaceId,
|
||||
apiKey,
|
||||
});
|
||||
} else {
|
||||
logger.warn(
|
||||
`Alert "${alertId}" skipped scheduling action "${action.id}" because it is disabled`
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -12,6 +12,8 @@ import { TaskRunnerContext } from './task_runner_factory';
|
|||
import { TaskRunner } from './task_runner';
|
||||
import { encryptedSavedObjectsMock } from '../../../../plugins/encrypted_saved_objects/server/mocks';
|
||||
import { savedObjectsClientMock, loggingServiceMock } from '../../../../../src/core/server/mocks';
|
||||
import { PluginStartContract as ActionsPluginStart } from '../../../actions/server';
|
||||
import { actionsMock } from '../../../actions/server/mocks';
|
||||
|
||||
const alertType = {
|
||||
id: 'test',
|
||||
|
@ -55,9 +57,11 @@ describe('Task Runner', () => {
|
|||
savedObjectsClient,
|
||||
};
|
||||
|
||||
const taskRunnerFactoryInitializerParams: jest.Mocked<TaskRunnerContext> = {
|
||||
const taskRunnerFactoryInitializerParams: jest.Mocked<TaskRunnerContext> & {
|
||||
actionsPlugin: jest.Mocked<ActionsPluginStart>;
|
||||
} = {
|
||||
getServices: jest.fn().mockReturnValue(services),
|
||||
executeAction: jest.fn(),
|
||||
actionsPlugin: actionsMock.createStart(),
|
||||
encryptedSavedObjectsPlugin,
|
||||
logger: loggingServiceMock.create().get(),
|
||||
spaceIdToNamespace: jest.fn().mockReturnValue(undefined),
|
||||
|
@ -154,7 +158,8 @@ describe('Task Runner', () => {
|
|||
expect(call.services).toBeTruthy();
|
||||
});
|
||||
|
||||
test('executeAction is called per alert instance that is scheduled', async () => {
|
||||
test('actionsPlugin.execute is called per alert instance that is scheduled', async () => {
|
||||
taskRunnerFactoryInitializerParams.actionsPlugin.isActionTypeEnabled.mockReturnValue(true);
|
||||
alertType.executor.mockImplementation(
|
||||
({ services: executorServices }: AlertExecutorOptions) => {
|
||||
executorServices.alertInstanceFactory('1').scheduleActions('default');
|
||||
|
@ -175,8 +180,9 @@ describe('Task Runner', () => {
|
|||
references: [],
|
||||
});
|
||||
await taskRunner.run();
|
||||
expect(taskRunnerFactoryInitializerParams.executeAction).toHaveBeenCalledTimes(1);
|
||||
expect(taskRunnerFactoryInitializerParams.executeAction.mock.calls[0]).toMatchInlineSnapshot(`
|
||||
expect(taskRunnerFactoryInitializerParams.actionsPlugin.execute).toHaveBeenCalledTimes(1);
|
||||
expect(taskRunnerFactoryInitializerParams.actionsPlugin.execute.mock.calls[0])
|
||||
.toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"apiKey": "MTIzOmFiYw==",
|
||||
|
|
|
@ -119,7 +119,7 @@ export class TaskRunner {
|
|||
alertName,
|
||||
tags,
|
||||
logger: this.logger,
|
||||
executeAction: this.context.executeAction,
|
||||
actionsPlugin: this.context.actionsPlugin,
|
||||
apiKey,
|
||||
actions: actionsWithIds,
|
||||
spaceId,
|
||||
|
|
|
@ -9,6 +9,7 @@ import { ConcreteTaskInstance, TaskStatus } from '../../../../plugins/task_manag
|
|||
import { TaskRunnerContext, TaskRunnerFactory } from './task_runner_factory';
|
||||
import { encryptedSavedObjectsMock } from '../../../../plugins/encrypted_saved_objects/server/mocks';
|
||||
import { savedObjectsClientMock, loggingServiceMock } from '../../../../../src/core/server/mocks';
|
||||
import { actionsMock } from '../../../actions/server/mocks';
|
||||
|
||||
const alertType = {
|
||||
id: 'test',
|
||||
|
@ -56,7 +57,7 @@ describe('Task Runner Factory', () => {
|
|||
|
||||
const taskRunnerFactoryInitializerParams: jest.Mocked<TaskRunnerContext> = {
|
||||
getServices: jest.fn().mockReturnValue(services),
|
||||
executeAction: jest.fn(),
|
||||
actionsPlugin: actionsMock.createStart(),
|
||||
encryptedSavedObjectsPlugin,
|
||||
logger: loggingServiceMock.create().get(),
|
||||
spaceIdToNamespace: jest.fn().mockReturnValue(undefined),
|
||||
|
|
|
@ -18,7 +18,7 @@ import { TaskRunner } from './task_runner';
|
|||
export interface TaskRunnerContext {
|
||||
logger: Logger;
|
||||
getServices: GetServicesFunction;
|
||||
executeAction: ActionsPluginStartContract['execute'];
|
||||
actionsPlugin: ActionsPluginStartContract;
|
||||
encryptedSavedObjectsPlugin: EncryptedSavedObjectsPluginStart;
|
||||
spaceIdToNamespace: SpaceIdToNamespaceFunction;
|
||||
getBasePath: GetBasePathFunction;
|
||||
|
|
|
@ -25,6 +25,9 @@ describe('loadActionTypes', () => {
|
|||
id: 'test',
|
||||
name: 'Test',
|
||||
enabled: true,
|
||||
enabledInConfig: true,
|
||||
enabledInLicense: true,
|
||||
minimumLicenseRequired: 'basic',
|
||||
},
|
||||
];
|
||||
http.get.mockResolvedValueOnce(resolvedValue);
|
||||
|
|
|
@ -0,0 +1,74 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { ActionType } from '../../types';
|
||||
import { actionTypeCompare } from './action_type_compare';
|
||||
|
||||
test('should sort enabled action types first', async () => {
|
||||
const actionTypes: ActionType[] = [
|
||||
{
|
||||
id: '1',
|
||||
minimumLicenseRequired: 'basic',
|
||||
name: 'first',
|
||||
enabled: true,
|
||||
enabledInConfig: true,
|
||||
enabledInLicense: true,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
minimumLicenseRequired: 'gold',
|
||||
name: 'second',
|
||||
enabled: false,
|
||||
enabledInConfig: true,
|
||||
enabledInLicense: false,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
minimumLicenseRequired: 'basic',
|
||||
name: 'third',
|
||||
enabled: true,
|
||||
enabledInConfig: true,
|
||||
enabledInLicense: true,
|
||||
},
|
||||
];
|
||||
const result = [...actionTypes].sort(actionTypeCompare);
|
||||
expect(result[0]).toEqual(actionTypes[0]);
|
||||
expect(result[1]).toEqual(actionTypes[2]);
|
||||
expect(result[2]).toEqual(actionTypes[1]);
|
||||
});
|
||||
|
||||
test('should sort by name when all enabled', async () => {
|
||||
const actionTypes: ActionType[] = [
|
||||
{
|
||||
id: '1',
|
||||
minimumLicenseRequired: 'basic',
|
||||
name: 'third',
|
||||
enabled: true,
|
||||
enabledInConfig: true,
|
||||
enabledInLicense: true,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
minimumLicenseRequired: 'basic',
|
||||
name: 'first',
|
||||
enabled: true,
|
||||
enabledInConfig: true,
|
||||
enabledInLicense: true,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
minimumLicenseRequired: 'basic',
|
||||
name: 'second',
|
||||
enabled: true,
|
||||
enabledInConfig: true,
|
||||
enabledInLicense: true,
|
||||
},
|
||||
];
|
||||
const result = [...actionTypes].sort(actionTypeCompare);
|
||||
expect(result[0]).toEqual(actionTypes[1]);
|
||||
expect(result[1]).toEqual(actionTypes[2]);
|
||||
expect(result[2]).toEqual(actionTypes[0]);
|
||||
});
|
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { ActionType } from '../../types';
|
||||
|
||||
export function actionTypeCompare(a: ActionType, b: ActionType) {
|
||||
if (a.enabled === true && b.enabled === false) {
|
||||
return -1;
|
||||
}
|
||||
if (a.enabled === false && b.enabled === true) {
|
||||
return 1;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
.actCheckActionTypeEnabled__disabledActionWarningCard {
|
||||
background-color: $euiColorLightestShade;
|
||||
}
|
||||
|
||||
.actAccordionActionForm {
|
||||
.euiCard {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,88 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { ActionType } from '../../types';
|
||||
import { checkActionTypeEnabled } from './check_action_type_enabled';
|
||||
|
||||
test(`returns isEnabled:true when action type isn't provided`, async () => {
|
||||
expect(checkActionTypeEnabled()).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"isEnabled": true,
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
test('returns isEnabled:true when action type is enabled', async () => {
|
||||
const actionType: ActionType = {
|
||||
id: '1',
|
||||
minimumLicenseRequired: 'basic',
|
||||
name: 'my action',
|
||||
enabled: true,
|
||||
enabledInConfig: true,
|
||||
enabledInLicense: true,
|
||||
};
|
||||
expect(checkActionTypeEnabled(actionType)).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"isEnabled": true,
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
test('returns isEnabled:false when action type is disabled by license', async () => {
|
||||
const actionType: ActionType = {
|
||||
id: '1',
|
||||
minimumLicenseRequired: 'basic',
|
||||
name: 'my action',
|
||||
enabled: false,
|
||||
enabledInConfig: true,
|
||||
enabledInLicense: false,
|
||||
};
|
||||
expect(checkActionTypeEnabled(actionType)).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"isEnabled": false,
|
||||
"message": "This connector is disabled because it requires a basic license.",
|
||||
"messageCard": <EuiCard
|
||||
className="actCheckActionTypeEnabled__disabledActionWarningCard"
|
||||
description="To re-enable this action, please upgrade your license."
|
||||
title="This feature requires a basic license."
|
||||
titleSize="xs"
|
||||
>
|
||||
<ForwardRef
|
||||
href="https://www.elastic.co/subscriptions"
|
||||
target="_blank"
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="View license options"
|
||||
id="xpack.triggersActionsUI.sections.alertForm.actionTypeDisabledByLicenseLinkTitle"
|
||||
values={Object {}}
|
||||
/>
|
||||
</ForwardRef>
|
||||
</EuiCard>,
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
test('returns isEnabled:false when action type is disabled by config', async () => {
|
||||
const actionType: ActionType = {
|
||||
id: '1',
|
||||
minimumLicenseRequired: 'basic',
|
||||
name: 'my action',
|
||||
enabled: false,
|
||||
enabledInConfig: false,
|
||||
enabledInLicense: true,
|
||||
};
|
||||
expect(checkActionTypeEnabled(actionType)).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"isEnabled": false,
|
||||
"message": "This connector is disabled by the Kibana configuration.",
|
||||
"messageCard": <EuiCard
|
||||
className="actCheckActionTypeEnabled__disabledActionWarningCard"
|
||||
description=""
|
||||
title="This feature is disabled by the Kibana configuration."
|
||||
/>,
|
||||
}
|
||||
`);
|
||||
});
|
|
@ -0,0 +1,93 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { EuiCard, EuiLink } from '@elastic/eui';
|
||||
import { ActionType } from '../../types';
|
||||
import { VIEW_LICENSE_OPTIONS_LINK } from '../../common/constants';
|
||||
import './check_action_type_enabled.scss';
|
||||
|
||||
export interface IsEnabledResult {
|
||||
isEnabled: true;
|
||||
}
|
||||
export interface IsDisabledResult {
|
||||
isEnabled: false;
|
||||
message: string;
|
||||
messageCard: JSX.Element;
|
||||
}
|
||||
|
||||
export function checkActionTypeEnabled(
|
||||
actionType?: ActionType
|
||||
): IsEnabledResult | IsDisabledResult {
|
||||
if (actionType?.enabledInLicense === false) {
|
||||
return {
|
||||
isEnabled: false,
|
||||
message: i18n.translate(
|
||||
'xpack.triggersActionsUI.checkActionTypeEnabled.actionTypeDisabledByLicenseMessage',
|
||||
{
|
||||
defaultMessage:
|
||||
'This connector is disabled because it requires a {minimumLicenseRequired} license.',
|
||||
values: {
|
||||
minimumLicenseRequired: actionType.minimumLicenseRequired,
|
||||
},
|
||||
}
|
||||
),
|
||||
messageCard: (
|
||||
<EuiCard
|
||||
titleSize="xs"
|
||||
title={i18n.translate(
|
||||
'xpack.triggersActionsUI.sections.alertForm.actionTypeDisabledByLicenseMessageTitle',
|
||||
{
|
||||
defaultMessage: 'This feature requires a {minimumLicenseRequired} license.',
|
||||
values: {
|
||||
minimumLicenseRequired: actionType.minimumLicenseRequired,
|
||||
},
|
||||
}
|
||||
)}
|
||||
// The "re-enable" terminology is used here because this message is used when an alert
|
||||
// action was previously enabled and needs action to be re-enabled.
|
||||
description={i18n.translate(
|
||||
'xpack.triggersActionsUI.sections.alertForm.actionTypeDisabledByLicenseMessageDescription',
|
||||
{ defaultMessage: 'To re-enable this action, please upgrade your license.' }
|
||||
)}
|
||||
className="actCheckActionTypeEnabled__disabledActionWarningCard"
|
||||
children={
|
||||
<EuiLink href={VIEW_LICENSE_OPTIONS_LINK} target="_blank">
|
||||
<FormattedMessage
|
||||
defaultMessage="View license options"
|
||||
id="xpack.triggersActionsUI.sections.alertForm.actionTypeDisabledByLicenseLinkTitle"
|
||||
/>
|
||||
</EuiLink>
|
||||
}
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (actionType?.enabledInConfig === false) {
|
||||
return {
|
||||
isEnabled: false,
|
||||
message: i18n.translate(
|
||||
'xpack.triggersActionsUI.checkActionTypeEnabled.actionTypeDisabledByConfigMessage',
|
||||
{ defaultMessage: 'This connector is disabled by the Kibana configuration.' }
|
||||
),
|
||||
messageCard: (
|
||||
<EuiCard
|
||||
title={i18n.translate(
|
||||
'xpack.triggersActionsUI.sections.alertForm.actionTypeDisabledByConfigMessageTitle',
|
||||
{ defaultMessage: 'This feature is disabled by the Kibana configuration.' }
|
||||
)}
|
||||
description=""
|
||||
className="actCheckActionTypeEnabled__disabledActionWarningCard"
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return { isEnabled: true };
|
||||
}
|
|
@ -39,6 +39,36 @@ describe('action_form', () => {
|
|||
actionParamsFields: null,
|
||||
};
|
||||
|
||||
const disabledByConfigActionType = {
|
||||
id: 'disabled-by-config',
|
||||
iconClass: 'test',
|
||||
selectMessage: 'test',
|
||||
validateConnector: (): ValidationResult => {
|
||||
return { errors: {} };
|
||||
},
|
||||
validateParams: (): ValidationResult => {
|
||||
const validationResult = { errors: {} };
|
||||
return validationResult;
|
||||
},
|
||||
actionConnectorFields: null,
|
||||
actionParamsFields: null,
|
||||
};
|
||||
|
||||
const disabledByLicenseActionType = {
|
||||
id: 'disabled-by-license',
|
||||
iconClass: 'test',
|
||||
selectMessage: 'test',
|
||||
validateConnector: (): ValidationResult => {
|
||||
return { errors: {} };
|
||||
},
|
||||
validateParams: (): ValidationResult => {
|
||||
const validationResult = { errors: {} };
|
||||
return validationResult;
|
||||
},
|
||||
actionConnectorFields: null,
|
||||
actionParamsFields: null,
|
||||
};
|
||||
|
||||
describe('action_form in alert', () => {
|
||||
let wrapper: ReactWrapper<any>;
|
||||
|
||||
|
@ -49,7 +79,11 @@ describe('action_form', () => {
|
|||
http: mockes.http,
|
||||
actionTypeRegistry: actionTypeRegistry as any,
|
||||
};
|
||||
actionTypeRegistry.list.mockReturnValue([actionType]);
|
||||
actionTypeRegistry.list.mockReturnValue([
|
||||
actionType,
|
||||
disabledByConfigActionType,
|
||||
disabledByLicenseActionType,
|
||||
]);
|
||||
actionTypeRegistry.has.mockReturnValue(true);
|
||||
|
||||
const initialAlert = ({
|
||||
|
@ -92,8 +126,38 @@ describe('action_form', () => {
|
|||
actionTypeRegistry={deps!.actionTypeRegistry}
|
||||
defaultActionMessage={'Alert [{{ctx.metadata.name}}] has exceeded the threshold'}
|
||||
actionTypes={[
|
||||
{ id: actionType.id, name: 'Test', enabled: true },
|
||||
{ id: '.index', name: 'Index', enabled: true },
|
||||
{
|
||||
id: actionType.id,
|
||||
name: 'Test',
|
||||
enabled: true,
|
||||
enabledInConfig: true,
|
||||
enabledInLicense: true,
|
||||
minimumLicenseRequired: 'basic',
|
||||
},
|
||||
{
|
||||
id: '.index',
|
||||
name: 'Index',
|
||||
enabled: true,
|
||||
enabledInConfig: true,
|
||||
enabledInLicense: true,
|
||||
minimumLicenseRequired: 'basic',
|
||||
},
|
||||
{
|
||||
id: 'disabled-by-config',
|
||||
name: 'Disabled by config',
|
||||
enabled: false,
|
||||
enabledInConfig: false,
|
||||
enabledInLicense: true,
|
||||
minimumLicenseRequired: 'gold',
|
||||
},
|
||||
{
|
||||
id: 'disabled-by-license',
|
||||
name: 'Disabled by license',
|
||||
enabled: false,
|
||||
enabledInConfig: true,
|
||||
enabledInLicense: false,
|
||||
minimumLicenseRequired: 'gold',
|
||||
},
|
||||
]}
|
||||
toastNotifications={deps!.toastNotifications}
|
||||
/>
|
||||
|
@ -112,6 +176,32 @@ describe('action_form', () => {
|
|||
`[data-test-subj="${actionType.id}-ActionTypeSelectOption"]`
|
||||
);
|
||||
expect(actionOption.exists()).toBeTruthy();
|
||||
expect(
|
||||
wrapper
|
||||
.find(`EuiToolTip [data-test-subj="${actionType.id}-ActionTypeSelectOption"]`)
|
||||
.exists()
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
it(`doesn't render action types disabled by config`, async () => {
|
||||
await setup();
|
||||
const actionOption = wrapper.find(
|
||||
`[data-test-subj="disabled-by-config-ActionTypeSelectOption"]`
|
||||
);
|
||||
expect(actionOption.exists()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('renders action types disabled by license', async () => {
|
||||
await setup();
|
||||
const actionOption = wrapper.find(
|
||||
`[data-test-subj="disabled-by-license-ActionTypeSelectOption"]`
|
||||
);
|
||||
expect(actionOption.exists()).toBeTruthy();
|
||||
expect(
|
||||
wrapper
|
||||
.find('EuiToolTip [data-test-subj="disabled-by-license-ActionTypeSelectOption"]')
|
||||
.exists()
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -21,6 +21,9 @@ import {
|
|||
EuiButtonIcon,
|
||||
EuiEmptyPrompt,
|
||||
EuiButtonEmpty,
|
||||
EuiToolTip,
|
||||
EuiIconTip,
|
||||
EuiLink,
|
||||
} from '@elastic/eui';
|
||||
import { HttpSetup, ToastsApi } from 'kibana/public';
|
||||
import { loadActionTypes, loadAllActions } from '../../lib/action_connector_api';
|
||||
|
@ -35,6 +38,9 @@ import {
|
|||
import { SectionLoading } from '../../components/section_loading';
|
||||
import { ConnectorAddModal } from './connector_add_modal';
|
||||
import { TypeRegistry } from '../../type_registry';
|
||||
import { actionTypeCompare } from '../../lib/action_type_compare';
|
||||
import { checkActionTypeEnabled } from '../../lib/check_action_type_enabled';
|
||||
import { VIEW_LICENSE_OPTIONS_LINK } from '../../../common/constants';
|
||||
|
||||
interface ActionAccordionFormProps {
|
||||
actions: AlertAction[];
|
||||
|
@ -51,6 +57,7 @@ interface ActionAccordionFormProps {
|
|||
actionTypes?: ActionType[];
|
||||
messageVariables?: string[];
|
||||
defaultActionMessage?: string;
|
||||
setHasActionsDisabled?: (value: boolean) => void;
|
||||
}
|
||||
|
||||
interface ActiveActionConnectorState {
|
||||
|
@ -70,6 +77,7 @@ export const ActionForm = ({
|
|||
messageVariables,
|
||||
defaultActionMessage,
|
||||
toastNotifications,
|
||||
setHasActionsDisabled,
|
||||
}: ActionAccordionFormProps) => {
|
||||
const [addModalVisible, setAddModalVisibility] = useState<boolean>(false);
|
||||
const [activeActionItem, setActiveActionItem] = useState<ActiveActionConnectorState | undefined>(
|
||||
|
@ -91,6 +99,10 @@ export const ActionForm = ({
|
|||
index[actionTypeItem.id] = actionTypeItem;
|
||||
}
|
||||
setActionTypesIndex(index);
|
||||
const hasActionsDisabled = actions.some(action => !index[action.actionTypeId].enabled);
|
||||
if (setHasActionsDisabled) {
|
||||
setHasActionsDisabled(hasActionsDisabled);
|
||||
}
|
||||
} catch (e) {
|
||||
if (toastNotifications) {
|
||||
toastNotifications.addDanger({
|
||||
|
@ -179,60 +191,12 @@ export const ActionForm = ({
|
|||
const ParamsFieldsComponent = actionTypeRegistered.actionParamsFields;
|
||||
const actionParamsErrors: { errors: IErrorObject } =
|
||||
Object.keys(actionsErrors).length > 0 ? actionsErrors[actionItem.id] : { errors: {} };
|
||||
const checkEnabledResult = checkActionTypeEnabled(
|
||||
actionTypesIndex && actionTypesIndex[actionConnector.actionTypeId]
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiAccordion
|
||||
initialIsOpen={true}
|
||||
key={index}
|
||||
id={index.toString()}
|
||||
className="euiAccordionForm"
|
||||
buttonContentClassName="euiAccordionForm__button"
|
||||
data-test-subj={`alertActionAccordion-${defaultActionGroupId}`}
|
||||
buttonContent={
|
||||
<EuiFlexGroup gutterSize="s" alignItems="center">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiIcon type={actionTypeRegistered.iconClass} size="m" />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiTitle size="s">
|
||||
<h5>
|
||||
<FormattedMessage
|
||||
defaultMessage="Action: {actionConnectorName}"
|
||||
id="xpack.triggersActionsUI.sections.alertForm.selectAlertActionTypeEditTitle"
|
||||
values={{
|
||||
actionConnectorName: actionConnector.name,
|
||||
}}
|
||||
/>
|
||||
</h5>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
}
|
||||
extraAction={
|
||||
<EuiButtonIcon
|
||||
iconType="cross"
|
||||
color="danger"
|
||||
className="euiAccordionForm__extraAction"
|
||||
aria-label={i18n.translate(
|
||||
'xpack.triggersActionsUI.sections.alertForm.accordion.deleteIconAriaLabel',
|
||||
{
|
||||
defaultMessage: 'Delete',
|
||||
}
|
||||
)}
|
||||
onClick={() => {
|
||||
const updatedActions = actions.filter(
|
||||
(item: AlertAction) => item.id !== actionItem.id
|
||||
);
|
||||
setAlertProperty(updatedActions);
|
||||
setIsAddActionPanelOpen(
|
||||
updatedActions.filter((item: AlertAction) => item.id !== actionItem.id).length === 0
|
||||
);
|
||||
setActiveActionItem(undefined);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
paddingSize="l"
|
||||
>
|
||||
const accordionContent = checkEnabledResult.isEnabled ? (
|
||||
<Fragment>
|
||||
<EuiFlexGroup component="div">
|
||||
<EuiFlexItem>
|
||||
<EuiFormRow
|
||||
|
@ -287,6 +251,86 @@ export const ActionForm = ({
|
|||
defaultMessage={defaultActionMessage ?? undefined}
|
||||
/>
|
||||
) : null}
|
||||
</Fragment>
|
||||
) : (
|
||||
checkEnabledResult.messageCard
|
||||
);
|
||||
|
||||
return (
|
||||
<EuiAccordion
|
||||
initialIsOpen={true}
|
||||
key={index}
|
||||
id={index.toString()}
|
||||
className="actAccordionActionForm"
|
||||
buttonContentClassName="actAccordionActionForm__button"
|
||||
data-test-subj={`alertActionAccordion-${defaultActionGroupId}`}
|
||||
buttonContent={
|
||||
<EuiFlexGroup gutterSize="s" alignItems="center">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiIcon type={actionTypeRegistered.iconClass} size="m" />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiTitle size="s">
|
||||
<h5>
|
||||
<EuiFlexGroup gutterSize="s">
|
||||
<EuiFlexItem grow={false}>
|
||||
<FormattedMessage
|
||||
defaultMessage="Action: {actionConnectorName}"
|
||||
id="xpack.triggersActionsUI.sections.alertForm.selectAlertActionTypeEditTitle"
|
||||
values={{
|
||||
actionConnectorName: actionConnector.name,
|
||||
}}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
{checkEnabledResult.isEnabled === false && (
|
||||
<Fragment>
|
||||
<EuiIconTip
|
||||
type="alert"
|
||||
color="danger"
|
||||
content={i18n.translate(
|
||||
'xpack.triggersActionsUI.sections.alertForm.actionDisabledTitle',
|
||||
{
|
||||
defaultMessage: 'This action is disabled',
|
||||
}
|
||||
)}
|
||||
position="right"
|
||||
/>
|
||||
</Fragment>
|
||||
)}
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</h5>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
}
|
||||
extraAction={
|
||||
<EuiButtonIcon
|
||||
iconType="cross"
|
||||
color="danger"
|
||||
className="actAccordionActionForm__extraAction"
|
||||
aria-label={i18n.translate(
|
||||
'xpack.triggersActionsUI.sections.alertForm.accordion.deleteIconAriaLabel',
|
||||
{
|
||||
defaultMessage: 'Delete',
|
||||
}
|
||||
)}
|
||||
onClick={() => {
|
||||
const updatedActions = actions.filter(
|
||||
(item: AlertAction) => item.id !== actionItem.id
|
||||
);
|
||||
setAlertProperty(updatedActions);
|
||||
setIsAddActionPanelOpen(
|
||||
updatedActions.filter((item: AlertAction) => item.id !== actionItem.id).length === 0
|
||||
);
|
||||
setActiveActionItem(undefined);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
paddingSize="l"
|
||||
>
|
||||
{accordionContent}
|
||||
</EuiAccordion>
|
||||
);
|
||||
};
|
||||
|
@ -302,8 +346,8 @@ export const ActionForm = ({
|
|||
initialIsOpen={true}
|
||||
key={index}
|
||||
id={index.toString()}
|
||||
className="euiAccordionForm"
|
||||
buttonContentClassName="euiAccordionForm__button"
|
||||
className="actAccordionActionForm"
|
||||
buttonContentClassName="actAccordionActionForm__button"
|
||||
data-test-subj={`alertActionAccordion-${defaultActionGroupId}`}
|
||||
buttonContent={
|
||||
<EuiFlexGroup gutterSize="s" alignItems="center">
|
||||
|
@ -329,7 +373,7 @@ export const ActionForm = ({
|
|||
<EuiButtonIcon
|
||||
iconType="cross"
|
||||
color="danger"
|
||||
className="euiAccordionForm__extraAction"
|
||||
className="actAccordionActionForm__extraAction"
|
||||
aria-label={i18n.translate(
|
||||
'xpack.triggersActionsUI.sections.alertForm.accordion.deleteIconAriaLabel',
|
||||
{
|
||||
|
@ -427,20 +471,46 @@ export const ActionForm = ({
|
|||
}
|
||||
}
|
||||
|
||||
const actionTypeNodes = actionTypesIndex
|
||||
? actionTypeRegistry.list().map(function(item, index) {
|
||||
return actionTypesIndex[item.id] ? (
|
||||
let actionTypeNodes: JSX.Element[] | null = null;
|
||||
let hasDisabledByLicenseActionTypes = false;
|
||||
if (actionTypesIndex) {
|
||||
actionTypeNodes = actionTypeRegistry
|
||||
.list()
|
||||
.filter(
|
||||
item => actionTypesIndex[item.id] && actionTypesIndex[item.id].enabledInConfig === true
|
||||
)
|
||||
.sort((a, b) => actionTypeCompare(actionTypesIndex[a.id], actionTypesIndex[b.id]))
|
||||
.map(function(item, index) {
|
||||
const actionType = actionTypesIndex[item.id];
|
||||
const checkEnabledResult = checkActionTypeEnabled(actionTypesIndex[item.id]);
|
||||
if (!actionType.enabledInLicense) {
|
||||
hasDisabledByLicenseActionTypes = true;
|
||||
}
|
||||
|
||||
const keyPadItem = (
|
||||
<EuiKeyPadMenuItem
|
||||
key={index}
|
||||
isDisabled={!checkEnabledResult.isEnabled}
|
||||
data-test-subj={`${item.id}-ActionTypeSelectOption`}
|
||||
label={actionTypesIndex[item.id].name}
|
||||
onClick={() => addActionType(item)}
|
||||
>
|
||||
<EuiIcon size="xl" type={item.iconClass} />
|
||||
</EuiKeyPadMenuItem>
|
||||
) : null;
|
||||
})
|
||||
: null;
|
||||
);
|
||||
|
||||
return (
|
||||
<Fragment key={`keypad-${item.id}`}>
|
||||
{checkEnabledResult.isEnabled && keyPadItem}
|
||||
{checkEnabledResult.isEnabled === false && (
|
||||
<EuiToolTip position="top" content={checkEnabledResult.message}>
|
||||
{keyPadItem}
|
||||
</EuiToolTip>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
|
@ -467,14 +537,36 @@ export const ActionForm = ({
|
|||
) : null}
|
||||
{isAddActionPanelOpen ? (
|
||||
<Fragment>
|
||||
<EuiTitle size="xs">
|
||||
<h5 id="alertActionTypeTitle">
|
||||
<FormattedMessage
|
||||
defaultMessage="Actions: Select an action type"
|
||||
id="xpack.triggersActionsUI.sections.alertForm.selectAlertActionTypeTitle"
|
||||
/>
|
||||
</h5>
|
||||
</EuiTitle>
|
||||
<EuiFlexGroup id="alertActionTypeTitle" justifyContent="spaceBetween">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiTitle size="xs">
|
||||
<h5>
|
||||
<FormattedMessage
|
||||
defaultMessage="Actions: Select an action type"
|
||||
id="xpack.triggersActionsUI.sections.alertForm.selectAlertActionTypeTitle"
|
||||
/>
|
||||
</h5>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
{hasDisabledByLicenseActionTypes && (
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiTitle size="xs">
|
||||
<h5>
|
||||
<EuiLink
|
||||
href={VIEW_LICENSE_OPTIONS_LINK}
|
||||
target="_blank"
|
||||
className="actActionForm__getMoreActionsLink"
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="Get more actions"
|
||||
id="xpack.triggersActionsUI.sections.actionForm.getMoreActionsTitle"
|
||||
/>
|
||||
</EuiLink>
|
||||
</h5>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer />
|
||||
<EuiFlexGroup gutterSize="s" wrap>
|
||||
{isLoadingActionTypes ? (
|
||||
|
|
|
@ -77,6 +77,9 @@ describe('connector_add_flyout', () => {
|
|||
id: actionType.id,
|
||||
enabled: true,
|
||||
name: 'Test',
|
||||
enabledInConfig: true,
|
||||
enabledInLicense: true,
|
||||
minimumLicenseRequired: 'basic',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
@ -85,4 +88,107 @@ describe('connector_add_flyout', () => {
|
|||
|
||||
expect(wrapper.find('[data-test-subj="my-action-type-card"]').exists()).toBeTruthy();
|
||||
});
|
||||
|
||||
it(`doesn't renders action types that are disabled via config`, () => {
|
||||
const onActionTypeChange = jest.fn();
|
||||
const actionType = {
|
||||
id: 'my-action-type',
|
||||
iconClass: 'test',
|
||||
selectMessage: 'test',
|
||||
validateConnector: (): ValidationResult => {
|
||||
return { errors: {} };
|
||||
},
|
||||
validateParams: (): ValidationResult => {
|
||||
const validationResult = { errors: {} };
|
||||
return validationResult;
|
||||
},
|
||||
actionConnectorFields: null,
|
||||
actionParamsFields: null,
|
||||
};
|
||||
actionTypeRegistry.get.mockReturnValueOnce(actionType);
|
||||
|
||||
const wrapper = mountWithIntl(
|
||||
<ActionsConnectorsContextProvider
|
||||
value={{
|
||||
http: deps!.http,
|
||||
actionTypeRegistry: deps!.actionTypeRegistry,
|
||||
capabilities: deps!.capabilities,
|
||||
toastNotifications: deps!.toastNotifications,
|
||||
reloadConnectors: () => {
|
||||
return new Promise<void>(() => {});
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ActionTypeMenu
|
||||
onActionTypeChange={onActionTypeChange}
|
||||
actionTypes={[
|
||||
{
|
||||
id: actionType.id,
|
||||
enabled: false,
|
||||
name: 'Test',
|
||||
enabledInConfig: false,
|
||||
enabledInLicense: true,
|
||||
minimumLicenseRequired: 'gold',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</ActionsConnectorsContextProvider>
|
||||
);
|
||||
|
||||
expect(wrapper.find('[data-test-subj="my-action-type-card"]').exists()).toBeFalsy();
|
||||
});
|
||||
|
||||
it(`renders action types as disabled when disabled by license`, () => {
|
||||
const onActionTypeChange = jest.fn();
|
||||
const actionType = {
|
||||
id: 'my-action-type',
|
||||
iconClass: 'test',
|
||||
selectMessage: 'test',
|
||||
validateConnector: (): ValidationResult => {
|
||||
return { errors: {} };
|
||||
},
|
||||
validateParams: (): ValidationResult => {
|
||||
const validationResult = { errors: {} };
|
||||
return validationResult;
|
||||
},
|
||||
actionConnectorFields: null,
|
||||
actionParamsFields: null,
|
||||
};
|
||||
actionTypeRegistry.get.mockReturnValueOnce(actionType);
|
||||
|
||||
const wrapper = mountWithIntl(
|
||||
<ActionsConnectorsContextProvider
|
||||
value={{
|
||||
http: deps!.http,
|
||||
actionTypeRegistry: deps!.actionTypeRegistry,
|
||||
capabilities: deps!.capabilities,
|
||||
toastNotifications: deps!.toastNotifications,
|
||||
reloadConnectors: () => {
|
||||
return new Promise<void>(() => {});
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ActionTypeMenu
|
||||
onActionTypeChange={onActionTypeChange}
|
||||
actionTypes={[
|
||||
{
|
||||
id: actionType.id,
|
||||
enabled: false,
|
||||
name: 'Test',
|
||||
enabledInConfig: true,
|
||||
enabledInLicense: false,
|
||||
minimumLicenseRequired: 'gold',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</ActionsConnectorsContextProvider>
|
||||
);
|
||||
|
||||
const element = wrapper.find('[data-test-subj="my-action-type-card"]');
|
||||
expect(element.exists()).toBeTruthy();
|
||||
expect(element.first().prop('betaBadgeLabel')).toEqual('Upgrade');
|
||||
expect(element.first().prop('betaBadgeTooltipContent')).toEqual(
|
||||
'This connector is disabled because it requires a gold license.'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -4,18 +4,25 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { EuiFlexItem, EuiCard, EuiIcon, EuiFlexGrid } from '@elastic/eui';
|
||||
import { EuiFlexItem, EuiCard, EuiIcon, EuiFlexGrid, EuiSpacer } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { ActionType, ActionTypeIndex } from '../../../types';
|
||||
import { loadActionTypes } from '../../lib/action_connector_api';
|
||||
import { useActionsConnectorsContext } from '../../context/actions_connectors_context';
|
||||
import { actionTypeCompare } from '../../lib/action_type_compare';
|
||||
import { checkActionTypeEnabled } from '../../lib/check_action_type_enabled';
|
||||
|
||||
interface Props {
|
||||
onActionTypeChange: (actionType: ActionType) => void;
|
||||
actionTypes?: ActionType[];
|
||||
setHasActionsDisabledByLicense?: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export const ActionTypeMenu = ({ onActionTypeChange, actionTypes }: Props) => {
|
||||
export const ActionTypeMenu = ({
|
||||
onActionTypeChange,
|
||||
actionTypes,
|
||||
setHasActionsDisabledByLicense,
|
||||
}: Props) => {
|
||||
const { http, toastNotifications, actionTypeRegistry } = useActionsConnectorsContext();
|
||||
const [actionTypesIndex, setActionTypesIndex] = useState<ActionTypeIndex | undefined>(undefined);
|
||||
|
||||
|
@ -28,6 +35,12 @@ export const ActionTypeMenu = ({ onActionTypeChange, actionTypes }: Props) => {
|
|||
index[actionTypeItem.id] = actionTypeItem;
|
||||
}
|
||||
setActionTypesIndex(index);
|
||||
if (setHasActionsDisabledByLicense) {
|
||||
const hasActionsDisabledByLicense = availableActionTypes.some(
|
||||
action => !index[action.id].enabledInLicense
|
||||
);
|
||||
setHasActionsDisabledByLicense(hasActionsDisabledByLicense);
|
||||
}
|
||||
} catch (e) {
|
||||
if (toastNotifications) {
|
||||
toastNotifications.addDanger({
|
||||
|
@ -43,33 +56,54 @@ export const ActionTypeMenu = ({ onActionTypeChange, actionTypes }: Props) => {
|
|||
}, []);
|
||||
|
||||
const registeredActionTypes = Object.entries(actionTypesIndex ?? [])
|
||||
.filter(([index]) => actionTypeRegistry.has(index))
|
||||
.map(([index, actionType]) => {
|
||||
const actionTypeModel = actionTypeRegistry.get(index);
|
||||
.filter(([id, details]) => actionTypeRegistry.has(id) && details.enabledInConfig === true)
|
||||
.map(([id, actionType]) => {
|
||||
const actionTypeModel = actionTypeRegistry.get(id);
|
||||
return {
|
||||
iconClass: actionTypeModel ? actionTypeModel.iconClass : '',
|
||||
selectMessage: actionTypeModel ? actionTypeModel.selectMessage : '',
|
||||
actionType,
|
||||
name: actionType.name,
|
||||
typeName: index.replace('.', ''),
|
||||
typeName: id.replace('.', ''),
|
||||
};
|
||||
});
|
||||
|
||||
const cardNodes = registeredActionTypes
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.sort((a, b) => actionTypeCompare(a.actionType, b.actionType))
|
||||
.map((item, index) => {
|
||||
return (
|
||||
<EuiFlexItem key={index}>
|
||||
<EuiCard
|
||||
data-test-subj={`${item.actionType.id}-card`}
|
||||
icon={<EuiIcon size="xl" type={item.iconClass} />}
|
||||
title={item.name}
|
||||
description={item.selectMessage}
|
||||
onClick={() => onActionTypeChange(item.actionType)}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
const checkEnabledResult = checkActionTypeEnabled(item.actionType);
|
||||
const card = (
|
||||
<EuiCard
|
||||
titleSize="xs"
|
||||
data-test-subj={`${item.actionType.id}-card`}
|
||||
icon={<EuiIcon size="l" type={item.iconClass} />}
|
||||
title={item.name}
|
||||
description={item.selectMessage}
|
||||
isDisabled={!checkEnabledResult.isEnabled}
|
||||
onClick={() => onActionTypeChange(item.actionType)}
|
||||
betaBadgeLabel={
|
||||
checkEnabledResult.isEnabled
|
||||
? undefined
|
||||
: i18n.translate(
|
||||
'xpack.triggersActionsUI.sections.actionsConnectorsList.upgradeBadge',
|
||||
{ defaultMessage: 'Upgrade' }
|
||||
)
|
||||
}
|
||||
betaBadgeTooltipContent={
|
||||
checkEnabledResult.isEnabled ? undefined : checkEnabledResult.message
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
return <EuiFlexItem key={index}>{card}</EuiFlexItem>;
|
||||
});
|
||||
|
||||
return <EuiFlexGrid columns={2}>{cardNodes}</EuiFlexGrid>;
|
||||
return (
|
||||
<div className="actConnectorsListGrid">
|
||||
<EuiSpacer size="s" />
|
||||
<EuiFlexGrid gutterSize="xl" columns={3}>
|
||||
{cardNodes}
|
||||
</EuiFlexGrid>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -79,6 +79,9 @@ describe('connector_add_flyout', () => {
|
|||
id: actionType.id,
|
||||
enabled: true,
|
||||
name: 'Test',
|
||||
enabledInConfig: true,
|
||||
enabledInLicense: true,
|
||||
minimumLicenseRequired: 'basic',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
|
|
@ -18,6 +18,9 @@ import {
|
|||
EuiButton,
|
||||
EuiFlyoutBody,
|
||||
EuiBetaBadge,
|
||||
EuiCallOut,
|
||||
EuiLink,
|
||||
EuiSpacer,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { ActionTypeMenu } from './action_type_menu';
|
||||
|
@ -27,6 +30,7 @@ import { connectorReducer } from './connector_reducer';
|
|||
import { hasSaveActionsCapability } from '../../lib/capabilities';
|
||||
import { createActionConnector } from '../../lib/action_connector_api';
|
||||
import { useActionsConnectorsContext } from '../../context/actions_connectors_context';
|
||||
import { VIEW_LICENSE_OPTIONS_LINK } from '../../../common/constants';
|
||||
|
||||
export interface ConnectorAddFlyoutProps {
|
||||
addFlyoutVisible: boolean;
|
||||
|
@ -48,6 +52,7 @@ export const ConnectorAddFlyout = ({
|
|||
reloadConnectors,
|
||||
} = useActionsConnectorsContext();
|
||||
const [actionType, setActionType] = useState<ActionType | undefined>(undefined);
|
||||
const [hasActionsDisabledByLicense, setHasActionsDisabledByLicense] = useState<boolean>(false);
|
||||
|
||||
// hooks
|
||||
const initialConnector = {
|
||||
|
@ -86,7 +91,11 @@ export const ConnectorAddFlyout = ({
|
|||
let actionTypeModel;
|
||||
if (!actionType) {
|
||||
currentForm = (
|
||||
<ActionTypeMenu onActionTypeChange={onActionTypeChange} actionTypes={actionTypes} />
|
||||
<ActionTypeMenu
|
||||
onActionTypeChange={onActionTypeChange}
|
||||
actionTypes={actionTypes}
|
||||
setHasActionsDisabledByLicense={setHasActionsDisabledByLicense}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
actionTypeModel = actionTypeRegistry.get(actionType.id);
|
||||
|
@ -204,7 +213,11 @@ export const ConnectorAddFlyout = ({
|
|||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlyoutHeader>
|
||||
<EuiFlyoutBody>{currentForm}</EuiFlyoutBody>
|
||||
<EuiFlyoutBody
|
||||
banner={!actionType && hasActionsDisabledByLicense && upgradeYourLicenseCallOut}
|
||||
>
|
||||
{currentForm}
|
||||
</EuiFlyoutBody>
|
||||
|
||||
<EuiFlyoutFooter>
|
||||
<EuiFlexGroup justifyContent="spaceBetween">
|
||||
|
@ -252,3 +265,24 @@ export const ConnectorAddFlyout = ({
|
|||
</EuiFlyout>
|
||||
);
|
||||
};
|
||||
|
||||
const upgradeYourLicenseCallOut = (
|
||||
<EuiCallOut
|
||||
title={i18n.translate(
|
||||
'xpack.triggersActionsUI.sections.actionConnectorAdd.upgradeYourPlanBannerTitle',
|
||||
{ defaultMessage: 'Upgrade your plan to access more connector types' }
|
||||
)}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="xpack.triggersActionsUI.sections.actionConnectorAdd.upgradeYourPlanBannerMessage"
|
||||
defaultMessage="With an upgraded license, you have the option to connect to more 3rd party services."
|
||||
/>
|
||||
<EuiSpacer size="xs" />
|
||||
<EuiLink href={VIEW_LICENSE_OPTIONS_LINK} target="_blank">
|
||||
<FormattedMessage
|
||||
id="xpack.triggersActionsUI.sections.actionConnectorAdd.upgradeYourPlanBannerLinkTitle"
|
||||
defaultMessage="Upgrade now"
|
||||
/>
|
||||
</EuiLink>
|
||||
</EuiCallOut>
|
||||
);
|
||||
|
|
|
@ -8,7 +8,7 @@ import { mountWithIntl } from 'test_utils/enzyme_helpers';
|
|||
import { coreMock } from '../../../../../../../src/core/public/mocks';
|
||||
import { ConnectorAddModal } from './connector_add_modal';
|
||||
import { actionTypeRegistryMock } from '../../action_type_registry.mock';
|
||||
import { ValidationResult } from '../../../types';
|
||||
import { ValidationResult, ActionType } from '../../../types';
|
||||
import { ActionsConnectorsContextValue } from '../../context/actions_connectors_context';
|
||||
const actionTypeRegistry = actionTypeRegistryMock.create();
|
||||
|
||||
|
@ -54,10 +54,13 @@ describe('connector_add_modal', () => {
|
|||
actionTypeRegistry.get.mockReturnValueOnce(actionTypeModel);
|
||||
actionTypeRegistry.has.mockReturnValue(true);
|
||||
|
||||
const actionType = {
|
||||
const actionType: ActionType = {
|
||||
id: 'my-action-type',
|
||||
name: 'test',
|
||||
enabled: true,
|
||||
enabledInConfig: true,
|
||||
enabledInLicense: true,
|
||||
minimumLicenseRequired: 'basic',
|
||||
};
|
||||
|
||||
const wrapper = deps
|
||||
|
|
|
@ -1,3 +1,15 @@
|
|||
.actConnectorsList__logo + .actConnectorsList__logo {
|
||||
margin-left: $euiSize;
|
||||
}
|
||||
|
||||
.actConnectorsList__tableRowDisabled {
|
||||
background-color: $euiColorLightestShade;
|
||||
|
||||
.actConnectorsList__tableCellDisabled {
|
||||
color: $euiColorDarkShade;
|
||||
}
|
||||
|
||||
.euiLink + .euiIcon {
|
||||
margin-left: $euiSizeXS;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -136,10 +136,12 @@ describe('actions_connectors_list component with items', () => {
|
|||
{
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'test2',
|
||||
name: 'Test2',
|
||||
enabled: true,
|
||||
},
|
||||
]);
|
||||
|
||||
|
@ -375,6 +377,117 @@ describe('actions_connectors_list with show only capability', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('actions_connectors_list component with disabled items', () => {
|
||||
let wrapper: ReactWrapper<any>;
|
||||
|
||||
beforeAll(async () => {
|
||||
const { loadAllActions, loadActionTypes } = jest.requireMock(
|
||||
'../../../lib/action_connector_api'
|
||||
);
|
||||
loadAllActions.mockResolvedValueOnce({
|
||||
page: 1,
|
||||
perPage: 10000,
|
||||
total: 2,
|
||||
data: [
|
||||
{
|
||||
id: '1',
|
||||
actionTypeId: 'test',
|
||||
description: 'My test',
|
||||
referencedByCount: 1,
|
||||
config: {},
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
actionTypeId: 'test2',
|
||||
description: 'My test 2',
|
||||
referencedByCount: 1,
|
||||
config: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
loadActionTypes.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
enabled: false,
|
||||
enabledInConfig: false,
|
||||
enabledInLicense: true,
|
||||
},
|
||||
{
|
||||
id: 'test2',
|
||||
name: 'Test2',
|
||||
enabled: false,
|
||||
enabledInConfig: true,
|
||||
enabledInLicense: false,
|
||||
},
|
||||
]);
|
||||
|
||||
const mockes = coreMock.createSetup();
|
||||
const [
|
||||
{
|
||||
chrome,
|
||||
docLinks,
|
||||
application: { capabilities, navigateToApp },
|
||||
},
|
||||
] = await mockes.getStartServices();
|
||||
const deps = {
|
||||
chrome,
|
||||
docLinks,
|
||||
dataPlugin: dataPluginMock.createStartContract(),
|
||||
charts: chartPluginMock.createStartContract(),
|
||||
toastNotifications: mockes.notifications.toasts,
|
||||
injectedMetadata: mockes.injectedMetadata,
|
||||
http: mockes.http,
|
||||
uiSettings: mockes.uiSettings,
|
||||
navigateToApp,
|
||||
capabilities: {
|
||||
...capabilities,
|
||||
siem: {
|
||||
'actions:show': true,
|
||||
'actions:save': true,
|
||||
'actions:delete': true,
|
||||
},
|
||||
},
|
||||
setBreadcrumbs: jest.fn(),
|
||||
actionTypeRegistry: {
|
||||
get() {
|
||||
return null;
|
||||
},
|
||||
} as any,
|
||||
alertTypeRegistry: {} as any,
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
wrapper = mountWithIntl(
|
||||
<AppContextProvider appDeps={deps}>
|
||||
<ActionsConnectorsList />
|
||||
</AppContextProvider>
|
||||
);
|
||||
});
|
||||
|
||||
await waitForRender(wrapper);
|
||||
|
||||
expect(loadAllActions).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders table of connectors', () => {
|
||||
expect(wrapper.find('EuiInMemoryTable')).toHaveLength(1);
|
||||
expect(wrapper.find('EuiTableRow')).toHaveLength(2);
|
||||
expect(
|
||||
wrapper
|
||||
.find('EuiTableRow')
|
||||
.at(0)
|
||||
.prop('className')
|
||||
).toEqual('actConnectorsList__tableRowDisabled');
|
||||
expect(
|
||||
wrapper
|
||||
.find('EuiTableRow')
|
||||
.at(1)
|
||||
.prop('className')
|
||||
).toEqual('actConnectorsList__tableRowDisabled');
|
||||
});
|
||||
});
|
||||
|
||||
async function waitForRender(wrapper: ReactWrapper<any, any>) {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
|
|
@ -15,17 +15,19 @@ import {
|
|||
EuiTitle,
|
||||
EuiLink,
|
||||
EuiLoadingSpinner,
|
||||
EuiIconTip,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import { useAppDependencies } from '../../../app_context';
|
||||
import { loadAllActions, loadActionTypes } from '../../../lib/action_connector_api';
|
||||
import { ActionConnector, ActionConnectorTableItem, ActionTypeIndex } from '../../../../types';
|
||||
import { ConnectorAddFlyout, ConnectorEditFlyout } from '../../action_connector_form';
|
||||
import { hasDeleteActionsCapability, hasSaveActionsCapability } from '../../../lib/capabilities';
|
||||
import { DeleteConnectorsModal } from '../../../components/delete_connectors_modal';
|
||||
import { ActionsConnectorsContextProvider } from '../../../context/actions_connectors_context';
|
||||
import { checkActionTypeEnabled } from '../../../lib/check_action_type_enabled';
|
||||
import './actions_connectors_list.scss';
|
||||
import { ActionConnector, ActionConnectorTableItem, ActionTypeIndex } from '../../../../types';
|
||||
|
||||
export const ActionsConnectorsList: React.FunctionComponent = () => {
|
||||
const { http, toastNotifications, capabilities, actionTypeRegistry } = useAppDependencies();
|
||||
|
@ -139,11 +141,33 @@ export const ActionsConnectorsList: React.FunctionComponent = () => {
|
|||
sortable: false,
|
||||
truncateText: true,
|
||||
render: (value: string, item: ActionConnectorTableItem) => {
|
||||
return (
|
||||
<EuiLink data-test-subj={`edit${item.id}`} onClick={() => editItem(item)} key={item.id}>
|
||||
const checkEnabledResult = checkActionTypeEnabled(
|
||||
actionTypesIndex && actionTypesIndex[item.actionTypeId]
|
||||
);
|
||||
|
||||
const link = (
|
||||
<EuiLink
|
||||
data-test-subj={`edit${item.id}`}
|
||||
onClick={() => editItem(item)}
|
||||
key={item.id}
|
||||
disabled={actionTypesIndex ? !actionTypesIndex[item.actionTypeId].enabled : true}
|
||||
>
|
||||
{value}
|
||||
</EuiLink>
|
||||
);
|
||||
|
||||
return checkEnabledResult.isEnabled ? (
|
||||
link
|
||||
) : (
|
||||
<Fragment>
|
||||
{link}
|
||||
<EuiIconTip
|
||||
type="questionInCircle"
|
||||
content={checkEnabledResult.message}
|
||||
position="right"
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
|
@ -211,11 +235,19 @@ export const ActionsConnectorsList: React.FunctionComponent = () => {
|
|||
sorting={true}
|
||||
itemId="id"
|
||||
columns={actionsTableColumns}
|
||||
rowProps={() => ({
|
||||
rowProps={(item: ActionConnectorTableItem) => ({
|
||||
className:
|
||||
!actionTypesIndex || !actionTypesIndex[item.actionTypeId].enabled
|
||||
? 'actConnectorsList__tableRowDisabled'
|
||||
: '',
|
||||
'data-test-subj': 'connectors-row',
|
||||
})}
|
||||
cellProps={() => ({
|
||||
cellProps={(item: ActionConnectorTableItem) => ({
|
||||
'data-test-subj': 'cell',
|
||||
className:
|
||||
!actionTypesIndex || !actionTypesIndex[item.actionTypeId].enabled
|
||||
? 'actConnectorsList__tableCellDisabled'
|
||||
: '',
|
||||
})}
|
||||
data-test-subj="actionsTable"
|
||||
pagination={true}
|
||||
|
|
|
@ -124,6 +124,9 @@ describe('alert_details', () => {
|
|||
id: '.server-log',
|
||||
name: 'Server log',
|
||||
enabled: true,
|
||||
enabledInConfig: true,
|
||||
enabledInLicense: true,
|
||||
minimumLicenseRequired: 'basic',
|
||||
},
|
||||
];
|
||||
|
||||
|
@ -173,11 +176,17 @@ describe('alert_details', () => {
|
|||
id: '.server-log',
|
||||
name: 'Server log',
|
||||
enabled: true,
|
||||
enabledInConfig: true,
|
||||
enabledInLicense: true,
|
||||
minimumLicenseRequired: 'basic',
|
||||
},
|
||||
{
|
||||
id: '.email',
|
||||
name: 'Send email',
|
||||
enabled: true,
|
||||
enabledInConfig: true,
|
||||
enabledInLicense: true,
|
||||
minimumLicenseRequired: 'basic',
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
import React, { useCallback, useReducer, useState } from 'react';
|
||||
import React, { Fragment, useCallback, useReducer, useState } from 'react';
|
||||
import { FormattedMessage } from '@kbn/i18n/react';
|
||||
import {
|
||||
EuiTitle,
|
||||
|
@ -17,6 +17,8 @@ import {
|
|||
EuiFlyoutBody,
|
||||
EuiPortal,
|
||||
EuiBetaBadge,
|
||||
EuiCallOut,
|
||||
EuiSpacer,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { useAlertsContext } from '../../context/alerts_context';
|
||||
|
@ -38,6 +40,7 @@ export const AlertEdit = ({
|
|||
}: AlertEditProps) => {
|
||||
const [{ alert }, dispatch] = useReducer(alertReducer, { alert: initialAlert });
|
||||
const [isSaving, setIsSaving] = useState<boolean>(false);
|
||||
const [hasActionsDisabled, setHasActionsDisabled] = useState<boolean>(false);
|
||||
|
||||
const {
|
||||
reloadAlerts,
|
||||
|
@ -141,7 +144,27 @@ export const AlertEdit = ({
|
|||
</EuiTitle>
|
||||
</EuiFlyoutHeader>
|
||||
<EuiFlyoutBody>
|
||||
<AlertForm alert={alert} dispatch={dispatch} errors={errors} canChangeTrigger={false} />
|
||||
{hasActionsDisabled && (
|
||||
<Fragment>
|
||||
<EuiCallOut
|
||||
size="s"
|
||||
color="danger"
|
||||
iconType="alert"
|
||||
title={i18n.translate(
|
||||
'xpack.triggersActionsUI.sections.alertEdit.disabledActionsWarningTitle',
|
||||
{ defaultMessage: 'This alert has actions that are disabled' }
|
||||
)}
|
||||
/>
|
||||
<EuiSpacer />
|
||||
</Fragment>
|
||||
)}
|
||||
<AlertForm
|
||||
alert={alert}
|
||||
dispatch={dispatch}
|
||||
errors={errors}
|
||||
canChangeTrigger={false}
|
||||
setHasActionsDisabled={setHasActionsDisabled}
|
||||
/>
|
||||
</EuiFlyoutBody>
|
||||
<EuiFlyoutFooter>
|
||||
<EuiFlexGroup justifyContent="spaceBetween">
|
||||
|
|
|
@ -74,9 +74,16 @@ interface AlertFormProps {
|
|||
dispatch: React.Dispatch<AlertReducerAction>;
|
||||
errors: IErrorObject;
|
||||
canChangeTrigger?: boolean; // to hide Change trigger button
|
||||
setHasActionsDisabled?: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export const AlertForm = ({ alert, canChangeTrigger = true, dispatch, errors }: AlertFormProps) => {
|
||||
export const AlertForm = ({
|
||||
alert,
|
||||
canChangeTrigger = true,
|
||||
dispatch,
|
||||
errors,
|
||||
setHasActionsDisabled,
|
||||
}: AlertFormProps) => {
|
||||
const alertsContext = useAlertsContext();
|
||||
const { http, toastNotifications, alertTypeRegistry, actionTypeRegistry } = alertsContext;
|
||||
|
||||
|
@ -218,6 +225,7 @@ export const AlertForm = ({ alert, canChangeTrigger = true, dispatch, errors }:
|
|||
{defaultActionGroupId ? (
|
||||
<ActionForm
|
||||
actions={alert.actions}
|
||||
setHasActionsDisabled={setHasActionsDisabled}
|
||||
messageVariables={
|
||||
alertTypesIndex && alertTypesIndex[alert.alertTypeId]
|
||||
? actionVariablesFromAlertType(alertTypesIndex[alert.alertTypeId]).map(av => av.name)
|
||||
|
|
|
@ -216,31 +216,25 @@ export const AlertsList: React.FunctionComponent = () => {
|
|||
'data-test-subj': 'alertsTableCell-interval',
|
||||
},
|
||||
{
|
||||
field: '',
|
||||
name: '',
|
||||
width: '50px',
|
||||
actions: canSave
|
||||
? [
|
||||
{
|
||||
render: (item: AlertTableItem) => {
|
||||
return alertTypeRegistry.has(item.alertTypeId) ? (
|
||||
<EuiLink
|
||||
data-test-subj="alertsTableCell-editLink"
|
||||
color="primary"
|
||||
onClick={() => editItem(item)}
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="Edit"
|
||||
id="xpack.triggersActionsUI.sections.alertsList.alertsListTable.columns.editLinkTitle"
|
||||
/>
|
||||
</EuiLink>
|
||||
) : (
|
||||
<></>
|
||||
);
|
||||
},
|
||||
},
|
||||
]
|
||||
: [],
|
||||
render(item: AlertTableItem) {
|
||||
if (!canSave || !alertTypeRegistry.has(item.alertTypeId)) {
|
||||
return;
|
||||
}
|
||||
return (
|
||||
<EuiLink
|
||||
data-test-subj="alertsTableCell-editLink"
|
||||
color="primary"
|
||||
onClick={() => editItem(item)}
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage="Edit"
|
||||
id="xpack.triggersActionsUI.sections.alertsList.alertsListTable.columns.editLinkTitle"
|
||||
/>
|
||||
</EuiLink>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: '',
|
||||
|
|
|
@ -7,3 +7,5 @@
|
|||
export { COMPARATORS, builtInComparators } from './comparators';
|
||||
export { AGGREGATION_TYPES, builtInAggregationTypes } from './aggregation_types';
|
||||
export { builtInGroupByTypes } from './group_by_types';
|
||||
|
||||
export const VIEW_LICENSE_OPTIONS_LINK = 'https://www.elastic.co/subscriptions';
|
||||
|
|
|
@ -11,7 +11,7 @@ export { AlertsContextProvider } from './application/context/alerts_context';
|
|||
export { ActionsConnectorsContextProvider } from './application/context/actions_connectors_context';
|
||||
export { AlertAdd } from './application/sections/alert_form';
|
||||
export { ActionForm } from './application/sections/action_connector_form';
|
||||
export { AlertAction, Alert, AlertTypeModel } from './types';
|
||||
export { AlertAction, Alert, AlertTypeModel, ActionType } from './types';
|
||||
export {
|
||||
ConnectorAddFlyout,
|
||||
ConnectorEditFlyout,
|
||||
|
|
|
@ -13,6 +13,7 @@ const onlyNotInCoverageTests = [
|
|||
require.resolve('../test/functional/config_security_basic.js'),
|
||||
require.resolve('../test/api_integration/config_security_basic.js'),
|
||||
require.resolve('../test/api_integration/config.js'),
|
||||
require.resolve('../test/alerting_api_integration/basic/config.ts'),
|
||||
require.resolve('../test/alerting_api_integration/spaces_only/config.ts'),
|
||||
require.resolve('../test/alerting_api_integration/security_and_spaces/config.ts'),
|
||||
require.resolve('../test/detection_engine_api_integration/security_and_spaces/config.ts'),
|
||||
|
|
14
x-pack/test/alerting_api_integration/basic/config.ts
Normal file
14
x-pack/test/alerting_api_integration/basic/config.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { createTestConfig } from '../common/config';
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default createTestConfig('basic', {
|
||||
disabledPlugins: [],
|
||||
license: 'basic',
|
||||
ssl: true,
|
||||
});
|
|
@ -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;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default function emailTest({ getService }: FtrProviderContext) {
|
||||
const supertest = getService('supertest');
|
||||
|
||||
describe('create email action', () => {
|
||||
it('should return 403 when creating an email action', async () => {
|
||||
await supertest
|
||||
.post('/api/action')
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({
|
||||
name: 'An email action',
|
||||
actionTypeId: '.email',
|
||||
config: {
|
||||
service: '__json',
|
||||
from: 'bob@example.com',
|
||||
},
|
||||
secrets: {
|
||||
user: 'bob',
|
||||
password: 'supersecret',
|
||||
},
|
||||
})
|
||||
.expect(403, {
|
||||
statusCode: 403,
|
||||
error: 'Forbidden',
|
||||
message:
|
||||
'Action type .email is disabled because your basic license does not support it. Please upgrade your license.',
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default function indexTest({ getService }: FtrProviderContext) {
|
||||
const supertest = getService('supertest');
|
||||
|
||||
describe('index action', () => {
|
||||
it('should return 200 when creating an index action', async () => {
|
||||
// create action with no config
|
||||
await supertest
|
||||
.post('/api/action')
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({
|
||||
name: 'An index action',
|
||||
actionTypeId: '.index',
|
||||
config: {
|
||||
index: 'foo',
|
||||
},
|
||||
secrets: {},
|
||||
})
|
||||
.expect(200);
|
||||
});
|
||||
});
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default function pagerdutyTest({ getService }: FtrProviderContext) {
|
||||
const supertest = getService('supertest');
|
||||
|
||||
describe('pagerduty action', () => {
|
||||
it('should return 403 when creating a pagerduty action', async () => {
|
||||
await supertest
|
||||
.post('/api/action')
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({
|
||||
name: 'A pagerduty action',
|
||||
actionTypeId: '.pagerduty',
|
||||
config: {
|
||||
apiUrl: 'http://localhost',
|
||||
},
|
||||
secrets: {
|
||||
routingKey: 'pager-duty-routing-key',
|
||||
},
|
||||
})
|
||||
.expect(403, {
|
||||
statusCode: 403,
|
||||
error: 'Forbidden',
|
||||
message:
|
||||
'Action type .pagerduty is disabled because your basic license does not support it. Please upgrade your license.',
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default function serverLogTest({ getService }: FtrProviderContext) {
|
||||
const supertest = getService('supertest');
|
||||
const esArchiver = getService('esArchiver');
|
||||
|
||||
describe('server-log action', () => {
|
||||
after(() => esArchiver.unload('empty_kibana'));
|
||||
|
||||
it('should return 200 when creating a server-log action', async () => {
|
||||
await supertest
|
||||
.post('/api/action')
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({
|
||||
name: 'A server.log action',
|
||||
actionTypeId: '.server-log',
|
||||
})
|
||||
.expect(200);
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
|
||||
|
||||
import {
|
||||
getExternalServiceSimulatorPath,
|
||||
ExternalServiceSimulator,
|
||||
} from '../../../../common/fixtures/plugins/actions';
|
||||
|
||||
// node ../scripts/functional_test_runner.js --grep "Actions.servicenddd" --config=test/alerting_api_integration/security_and_spaces/config.ts
|
||||
|
||||
const mapping = [
|
||||
{
|
||||
source: 'title',
|
||||
target: 'description',
|
||||
actionType: 'nothing',
|
||||
},
|
||||
{
|
||||
source: 'description',
|
||||
target: 'short_description',
|
||||
actionType: 'nothing',
|
||||
},
|
||||
{
|
||||
source: 'comments',
|
||||
target: 'comments',
|
||||
actionType: 'nothing',
|
||||
},
|
||||
];
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default function servicenowTest({ getService }: FtrProviderContext) {
|
||||
const supertest = getService('supertest');
|
||||
const kibanaServer = getService('kibanaServer');
|
||||
const mockServiceNow = {
|
||||
config: {
|
||||
apiUrl: 'www.servicenowisinkibanaactions.com',
|
||||
casesConfiguration: { mapping: [...mapping] },
|
||||
},
|
||||
secrets: {
|
||||
password: 'elastic',
|
||||
username: 'changeme',
|
||||
},
|
||||
params: {
|
||||
comments: 'hello cool service now incident',
|
||||
short_description: 'this is a cool service now incident',
|
||||
},
|
||||
};
|
||||
describe('servicenow', () => {
|
||||
let servicenowSimulatorURL: string = '<could not determine kibana url>';
|
||||
|
||||
// need to wait for kibanaServer to settle ...
|
||||
before(() => {
|
||||
servicenowSimulatorURL = kibanaServer.resolveUrl(
|
||||
getExternalServiceSimulatorPath(ExternalServiceSimulator.SERVICENOW)
|
||||
);
|
||||
});
|
||||
|
||||
it('should return 403 when creating a servicenow action', async () => {
|
||||
await supertest
|
||||
.post('/api/action')
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({
|
||||
name: 'A servicenow action',
|
||||
actionTypeId: '.servicenow',
|
||||
config: {
|
||||
apiUrl: servicenowSimulatorURL,
|
||||
casesConfiguration: { ...mockServiceNow.config.casesConfiguration },
|
||||
},
|
||||
secrets: mockServiceNow.secrets,
|
||||
})
|
||||
.expect(403, {
|
||||
statusCode: 403,
|
||||
error: 'Forbidden',
|
||||
message:
|
||||
'Action type .servicenow is disabled because your basic license does not support it. Please upgrade your license.',
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
|
||||
|
||||
import {
|
||||
getExternalServiceSimulatorPath,
|
||||
ExternalServiceSimulator,
|
||||
} from '../../../../common/fixtures/plugins/actions';
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default function slackTest({ getService }: FtrProviderContext) {
|
||||
const supertest = getService('supertest');
|
||||
const kibanaServer = getService('kibanaServer');
|
||||
|
||||
describe('slack action', () => {
|
||||
let slackSimulatorURL: string = '<could not determine kibana url>';
|
||||
|
||||
// need to wait for kibanaServer to settle ...
|
||||
before(() => {
|
||||
slackSimulatorURL = kibanaServer.resolveUrl(
|
||||
getExternalServiceSimulatorPath(ExternalServiceSimulator.SLACK)
|
||||
);
|
||||
});
|
||||
|
||||
it('should return 403 when creating a slack action', async () => {
|
||||
await supertest
|
||||
.post('/api/action')
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({
|
||||
name: 'A slack action',
|
||||
actionTypeId: '.slack',
|
||||
secrets: {
|
||||
webhookUrl: slackSimulatorURL,
|
||||
},
|
||||
})
|
||||
.expect(403, {
|
||||
statusCode: 403,
|
||||
error: 'Forbidden',
|
||||
message:
|
||||
'Action type .slack is disabled because your basic license does not support it. Please upgrade your license.',
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
|
@ -0,0 +1,51 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
|
||||
import {
|
||||
getExternalServiceSimulatorPath,
|
||||
ExternalServiceSimulator,
|
||||
} from '../../../../common/fixtures/plugins/actions';
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default function webhookTest({ getService }: FtrProviderContext) {
|
||||
const supertest = getService('supertest');
|
||||
const kibanaServer = getService('kibanaServer');
|
||||
|
||||
describe('webhook action', () => {
|
||||
let webhookSimulatorURL: string = '<could not determine kibana url>';
|
||||
|
||||
// need to wait for kibanaServer to settle ...
|
||||
before(() => {
|
||||
webhookSimulatorURL = kibanaServer.resolveUrl(
|
||||
getExternalServiceSimulatorPath(ExternalServiceSimulator.WEBHOOK)
|
||||
);
|
||||
});
|
||||
|
||||
it('should return 403 when creating a webhook action', async () => {
|
||||
await supertest
|
||||
.post('/api/action')
|
||||
.set('kbn-xsrf', 'test')
|
||||
.send({
|
||||
name: 'A generic Webhook action',
|
||||
actionTypeId: '.webhook',
|
||||
secrets: {
|
||||
user: 'username',
|
||||
password: 'mypassphrase',
|
||||
},
|
||||
config: {
|
||||
url: webhookSimulatorURL,
|
||||
},
|
||||
})
|
||||
.expect(403, {
|
||||
statusCode: 403,
|
||||
error: 'Forbidden',
|
||||
message:
|
||||
'Action type .webhook is disabled because your basic license does not support it. Please upgrade your license.',
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { FtrProviderContext } from '../../../common/ftr_provider_context';
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default function actionsTests({ loadTestFile }: FtrProviderContext) {
|
||||
describe('Actions', () => {
|
||||
loadTestFile(require.resolve('./builtin_action_types/email'));
|
||||
loadTestFile(require.resolve('./builtin_action_types/es_index'));
|
||||
loadTestFile(require.resolve('./builtin_action_types/pagerduty'));
|
||||
loadTestFile(require.resolve('./builtin_action_types/server_log'));
|
||||
loadTestFile(require.resolve('./builtin_action_types/servicenow'));
|
||||
loadTestFile(require.resolve('./builtin_action_types/slack'));
|
||||
loadTestFile(require.resolve('./builtin_action_types/webhook'));
|
||||
});
|
||||
}
|
19
x-pack/test/alerting_api_integration/basic/tests/index.ts
Normal file
19
x-pack/test/alerting_api_integration/basic/tests/index.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { FtrProviderContext } from '../../common/ftr_provider_context';
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default function alertingApiIntegrationTests({
|
||||
loadTestFile,
|
||||
getService,
|
||||
}: FtrProviderContext) {
|
||||
describe('alerting api integration basic license', function() {
|
||||
this.tags('ciGroup3');
|
||||
|
||||
loadTestFile(require.resolve('./actions'));
|
||||
});
|
||||
}
|
|
@ -62,7 +62,8 @@ export function createTestConfig(name: string, options: CreateTestConfigOptions)
|
|||
ssl,
|
||||
serverArgs: [
|
||||
`xpack.license.self_generated.type=${license}`,
|
||||
`xpack.security.enabled=${!disabledPlugins.includes('security') && license === 'trial'}`,
|
||||
`xpack.security.enabled=${!disabledPlugins.includes('security') &&
|
||||
['trial', 'basic'].includes(license)}`,
|
||||
],
|
||||
},
|
||||
kbnTestServer: {
|
||||
|
|
|
@ -43,6 +43,7 @@ export default function(kibana: any) {
|
|||
const notEnabledActionType: ActionType = {
|
||||
id: 'test.not-enabled',
|
||||
name: 'Test: Not Enabled',
|
||||
minimumLicenseRequired: 'gold',
|
||||
async executor() {
|
||||
return { status: 'ok', actionId: '' };
|
||||
},
|
||||
|
|
|
@ -42,6 +42,7 @@ export default function(kibana: any) {
|
|||
const noopActionType: ActionType = {
|
||||
id: 'test.noop',
|
||||
name: 'Test: Noop',
|
||||
minimumLicenseRequired: 'gold',
|
||||
async executor() {
|
||||
return { status: 'ok', actionId: '' };
|
||||
},
|
||||
|
@ -49,6 +50,7 @@ export default function(kibana: any) {
|
|||
const indexRecordActionType: ActionType = {
|
||||
id: 'test.index-record',
|
||||
name: 'Test: Index Record',
|
||||
minimumLicenseRequired: 'gold',
|
||||
validate: {
|
||||
params: schema.object({
|
||||
index: schema.string(),
|
||||
|
@ -80,6 +82,7 @@ export default function(kibana: any) {
|
|||
const failingActionType: ActionType = {
|
||||
id: 'test.failing',
|
||||
name: 'Test: Failing',
|
||||
minimumLicenseRequired: 'gold',
|
||||
validate: {
|
||||
params: schema.object({
|
||||
index: schema.string(),
|
||||
|
@ -104,6 +107,7 @@ export default function(kibana: any) {
|
|||
const rateLimitedActionType: ActionType = {
|
||||
id: 'test.rate-limit',
|
||||
name: 'Test: Rate Limit',
|
||||
minimumLicenseRequired: 'gold',
|
||||
maxAttempts: 2,
|
||||
validate: {
|
||||
params: schema.object({
|
||||
|
@ -133,6 +137,7 @@ export default function(kibana: any) {
|
|||
const authorizationActionType: ActionType = {
|
||||
id: 'test.authorization',
|
||||
name: 'Test: Authorization',
|
||||
minimumLicenseRequired: 'gold',
|
||||
validate: {
|
||||
params: schema.object({
|
||||
callClusterAuthorizationIndex: schema.string(),
|
||||
|
|
|
@ -205,10 +205,10 @@ export default function createActionTests({ getService }: FtrProviderContext) {
|
|||
break;
|
||||
case 'superuser at space1':
|
||||
case 'space_1_all at space1':
|
||||
expect(response.statusCode).to.eql(400);
|
||||
expect(response.statusCode).to.eql(403);
|
||||
expect(response.body).to.eql({
|
||||
statusCode: 400,
|
||||
error: 'Bad Request',
|
||||
statusCode: 403,
|
||||
error: 'Forbidden',
|
||||
message:
|
||||
'action type "test.not-enabled" is not enabled in the Kibana config xpack.actions.enabledActionTypes',
|
||||
});
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue