mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 01:13:23 -04:00
[RAM] 142183 create bulk delete on rules (#142466)
* create base for bulk delete route * add bulk delete method * add bulkDelete method for task manager * first version of retry if conflicts for bulk delete * move waitBeforeNextRetry to separate file * add happy path for unit and integration tests * rewrite retry_if_bulk_delete and add tests * fix happy path integration test * add some tests and fix bugs * make bulk_delete endpoint external * give up on types for endpoint arguments * add unit tests for new bulk delete route * add unit tests for bulk delete rules clients method * add integrational tests * api integration test running * add integrational tests * use bulk edit constant in log audit * unskip skiped test * fix conditional statement for taskIds * api integration for bulkDelete is done * small code style changes * delete comments and rename types * get rid of pmap without async * delete not used part of return * add audit logs for all deleted rules * add unit tests for audit logs * delete extra comments and rename constant * delete extra tests * fix audit logs * restrict amount of passed ids to 1000 * fix audit logs again * fix alerting security tests * test case when user pass more that 1000 ids * fix writing and case when you send no args * fix line in the text * delete extra test * fix type for rules we passing to bulk delete * add catch RuleTypeDisabledError * wait before next retry func tests * fix tests for retry function * fix bulk delete rule client tests * add to api return task ids failed to be deleted and wrap task manager call in try catch * fix type for task manager Co-authored-by: Xavier Mouligneau <xavier.mouligneau@elastic.co> Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
b733dd067d
commit
6266f5ab48
27 changed files with 1915 additions and 36 deletions
|
@ -51,6 +51,7 @@ export enum WriteOperations {
|
|||
UnmuteAlert = 'unmuteAlert',
|
||||
Snooze = 'snooze',
|
||||
BulkEdit = 'bulkEdit',
|
||||
BulkDelete = 'bulkDelete',
|
||||
Unsnooze = 'unsnooze',
|
||||
}
|
||||
|
||||
|
|
126
x-pack/plugins/alerting/server/routes/bulk_delete_rules.test.ts
Normal file
126
x-pack/plugins/alerting/server/routes/bulk_delete_rules.test.ts
Normal file
|
@ -0,0 +1,126 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { httpServiceMock } from '@kbn/core/server/mocks';
|
||||
|
||||
import { bulkDeleteRulesRoute } from './bulk_delete_rules';
|
||||
import { licenseStateMock } from '../lib/license_state.mock';
|
||||
import { mockHandlerArguments } from './_mock_handler_arguments';
|
||||
import { rulesClientMock } from '../rules_client.mock';
|
||||
import { RuleTypeDisabledError } from '../lib/errors/rule_type_disabled';
|
||||
import { verifyApiAccess } from '../lib/license_api_access';
|
||||
|
||||
const rulesClient = rulesClientMock.create();
|
||||
|
||||
jest.mock('../lib/license_api_access', () => ({
|
||||
verifyApiAccess: jest.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('bulkDeleteRulesRoute', () => {
|
||||
const bulkDeleteRequest = { filter: '' };
|
||||
const bulkDeleteResult = { errors: [], total: 1, taskIdsFailedToBeDeleted: [] };
|
||||
|
||||
it('should delete rules with proper parameters', async () => {
|
||||
const licenseState = licenseStateMock.create();
|
||||
const router = httpServiceMock.createRouter();
|
||||
|
||||
bulkDeleteRulesRoute({ router, licenseState });
|
||||
|
||||
const [config, handler] = router.patch.mock.calls[0];
|
||||
|
||||
expect(config.path).toBe('/internal/alerting/rules/_bulk_delete');
|
||||
|
||||
rulesClient.bulkDeleteRules.mockResolvedValueOnce(bulkDeleteResult);
|
||||
|
||||
const [context, req, res] = mockHandlerArguments(
|
||||
{ rulesClient },
|
||||
{
|
||||
body: bulkDeleteRequest,
|
||||
},
|
||||
['ok']
|
||||
);
|
||||
|
||||
expect(await handler(context, req, res)).toEqual({
|
||||
body: bulkDeleteResult,
|
||||
});
|
||||
|
||||
expect(rulesClient.bulkDeleteRules).toHaveBeenCalledTimes(1);
|
||||
expect(rulesClient.bulkDeleteRules.mock.calls[0]).toEqual([bulkDeleteRequest]);
|
||||
|
||||
expect(res.ok).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ensures the license allows bulk deleting rules', async () => {
|
||||
const licenseState = licenseStateMock.create();
|
||||
const router = httpServiceMock.createRouter();
|
||||
|
||||
rulesClient.bulkDeleteRules.mockResolvedValueOnce(bulkDeleteResult);
|
||||
|
||||
bulkDeleteRulesRoute({ router, licenseState });
|
||||
|
||||
const [, handler] = router.patch.mock.calls[0];
|
||||
|
||||
const [context, req, res] = mockHandlerArguments(
|
||||
{ rulesClient },
|
||||
{
|
||||
body: bulkDeleteRequest,
|
||||
}
|
||||
);
|
||||
|
||||
await handler(context, req, res);
|
||||
|
||||
expect(verifyApiAccess).toHaveBeenCalledWith(licenseState);
|
||||
});
|
||||
|
||||
it('ensures the license check prevents bulk deleting rules', async () => {
|
||||
const licenseState = licenseStateMock.create();
|
||||
const router = httpServiceMock.createRouter();
|
||||
|
||||
(verifyApiAccess as jest.Mock).mockImplementation(() => {
|
||||
throw new Error('Failure');
|
||||
});
|
||||
|
||||
bulkDeleteRulesRoute({ router, licenseState });
|
||||
|
||||
const [, handler] = router.patch.mock.calls[0];
|
||||
|
||||
const [context, req, res] = mockHandlerArguments(
|
||||
{ rulesClient },
|
||||
{
|
||||
body: bulkDeleteRequest,
|
||||
}
|
||||
);
|
||||
|
||||
expect(handler(context, req, res)).rejects.toMatchInlineSnapshot(`[Error: Failure]`);
|
||||
});
|
||||
|
||||
it('ensures the rule type gets validated for the license', async () => {
|
||||
const licenseState = licenseStateMock.create();
|
||||
const router = httpServiceMock.createRouter();
|
||||
|
||||
bulkDeleteRulesRoute({ router, licenseState });
|
||||
|
||||
const [, handler] = router.patch.mock.calls[0];
|
||||
|
||||
rulesClient.bulkDeleteRules.mockRejectedValue(
|
||||
new RuleTypeDisabledError('Fail', 'license_invalid')
|
||||
);
|
||||
|
||||
const [context, req, res] = mockHandlerArguments({ rulesClient }, { params: {}, body: {} }, [
|
||||
'ok',
|
||||
'forbidden',
|
||||
]);
|
||||
|
||||
await handler(context, req, res);
|
||||
|
||||
expect(res.forbidden).toHaveBeenCalledWith({ body: { message: 'Fail' } });
|
||||
});
|
||||
});
|
50
x-pack/plugins/alerting/server/routes/bulk_delete_rules.ts
Normal file
50
x-pack/plugins/alerting/server/routes/bulk_delete_rules.ts
Normal file
|
@ -0,0 +1,50 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { schema } from '@kbn/config-schema';
|
||||
import { IRouter } from '@kbn/core/server';
|
||||
import { verifyAccessAndContext, handleDisabledApiKeysError } from './lib';
|
||||
import { ILicenseState, RuleTypeDisabledError } from '../lib';
|
||||
import { AlertingRequestHandlerContext, INTERNAL_BASE_ALERTING_API_PATH } from '../types';
|
||||
|
||||
export const bulkDeleteRulesRoute = ({
|
||||
router,
|
||||
licenseState,
|
||||
}: {
|
||||
router: IRouter<AlertingRequestHandlerContext>;
|
||||
licenseState: ILicenseState;
|
||||
}) => {
|
||||
router.patch(
|
||||
{
|
||||
path: `${INTERNAL_BASE_ALERTING_API_PATH}/rules/_bulk_delete`,
|
||||
validate: {
|
||||
body: schema.object({
|
||||
filter: schema.maybe(schema.string()),
|
||||
ids: schema.maybe(schema.arrayOf(schema.string(), { minSize: 1, maxSize: 1000 })),
|
||||
}),
|
||||
},
|
||||
},
|
||||
handleDisabledApiKeysError(
|
||||
router.handleLegacyErrors(
|
||||
verifyAccessAndContext(licenseState, async (context, req, res) => {
|
||||
const rulesClient = (await context.alerting).getRulesClient();
|
||||
const { filter, ids } = req.body;
|
||||
|
||||
try {
|
||||
const result = await rulesClient.bulkDeleteRules({ filter, ids });
|
||||
return res.ok({ body: result });
|
||||
} catch (e) {
|
||||
if (e instanceof RuleTypeDisabledError) {
|
||||
return e.sendResponse(res);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
};
|
|
@ -38,6 +38,7 @@ import { bulkEditInternalRulesRoute } from './bulk_edit_rules';
|
|||
import { snoozeRuleRoute } from './snooze_rule';
|
||||
import { unsnoozeRuleRoute } from './unsnooze_rule';
|
||||
import { runSoonRoute } from './run_soon';
|
||||
import { bulkDeleteRulesRoute } from './bulk_delete_rules';
|
||||
|
||||
export interface RouteOptions {
|
||||
router: IRouter<AlertingRequestHandlerContext>;
|
||||
|
@ -76,6 +77,7 @@ export function defineRoutes(opts: RouteOptions) {
|
|||
unmuteAlertRoute(router, licenseState);
|
||||
updateRuleApiKeyRoute(router, licenseState);
|
||||
bulkEditInternalRulesRoute(router, licenseState);
|
||||
bulkDeleteRulesRoute({ router, licenseState });
|
||||
snoozeRuleRoute(router, licenseState);
|
||||
unsnoozeRuleRoute(router, licenseState);
|
||||
runSoonRoute(router, licenseState);
|
||||
|
|
|
@ -36,6 +36,7 @@ const createRulesClientMock = () => {
|
|||
getActionErrorLog: jest.fn(),
|
||||
getSpaceId: jest.fn(),
|
||||
bulkEdit: jest.fn(),
|
||||
bulkDeleteRules: jest.fn(),
|
||||
snooze: jest.fn(),
|
||||
unsnooze: jest.fn(),
|
||||
calculateIsSnoozedUntil: jest.fn(),
|
||||
|
|
|
@ -8,5 +8,6 @@
|
|||
export { mapSortField } from './map_sort_field';
|
||||
export { validateOperationOnAttributes } from './validate_attributes';
|
||||
export { retryIfBulkEditConflicts } from './retry_if_bulk_edit_conflicts';
|
||||
export { retryIfBulkDeleteConflicts } from './retry_if_bulk_delete_conflicts';
|
||||
export { applyBulkEditOperation } from './apply_bulk_edit_operation';
|
||||
export { buildKueryNodeFilter } from './build_kuery_node_filter';
|
||||
|
|
|
@ -0,0 +1,100 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { KueryNode } from '@kbn/es-query';
|
||||
import { loggingSystemMock } from '@kbn/core/server/mocks';
|
||||
|
||||
import { retryIfBulkDeleteConflicts } from './retry_if_bulk_delete_conflicts';
|
||||
import { RETRY_IF_CONFLICTS_ATTEMPTS } from './wait_before_next_retry';
|
||||
|
||||
const mockFilter: KueryNode = {
|
||||
type: 'function',
|
||||
value: 'mock',
|
||||
};
|
||||
|
||||
const mockLogger = loggingSystemMock.create().get();
|
||||
|
||||
const mockSuccessfulResult = {
|
||||
apiKeysToInvalidate: ['apiKey1'],
|
||||
errors: [],
|
||||
taskIdsToDelete: ['taskId1'],
|
||||
};
|
||||
|
||||
const error409 = {
|
||||
message: 'some fake message',
|
||||
status: 409,
|
||||
rule: {
|
||||
id: 'fake_rule_id',
|
||||
name: 'fake rule name',
|
||||
},
|
||||
};
|
||||
|
||||
const getOperationConflictsTimes = (times: number) => {
|
||||
return async () => {
|
||||
conflictOperationMock();
|
||||
times--;
|
||||
if (times >= 0) {
|
||||
return {
|
||||
apiKeysToInvalidate: [],
|
||||
taskIdsToDelete: [],
|
||||
errors: [error409],
|
||||
};
|
||||
}
|
||||
return mockSuccessfulResult;
|
||||
};
|
||||
};
|
||||
|
||||
const OperationSuccessful = async () => mockSuccessfulResult;
|
||||
const conflictOperationMock = jest.fn();
|
||||
|
||||
describe('retryIfBulkDeleteConflicts', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
test('should work when operation is successful', async () => {
|
||||
const result = await retryIfBulkDeleteConflicts(mockLogger, OperationSuccessful, mockFilter);
|
||||
|
||||
expect(result).toEqual(mockSuccessfulResult);
|
||||
});
|
||||
|
||||
test('should throw error when operation fails', async () => {
|
||||
await expect(
|
||||
retryIfBulkDeleteConflicts(
|
||||
mockLogger,
|
||||
async () => {
|
||||
throw Error('Test failure');
|
||||
},
|
||||
mockFilter
|
||||
)
|
||||
).rejects.toThrowError('Test failure');
|
||||
});
|
||||
|
||||
test(`should return conflict errors when number of retries exceeds ${RETRY_IF_CONFLICTS_ATTEMPTS}`, async () => {
|
||||
const result = await retryIfBulkDeleteConflicts(
|
||||
mockLogger,
|
||||
getOperationConflictsTimes(RETRY_IF_CONFLICTS_ATTEMPTS + 1),
|
||||
mockFilter
|
||||
);
|
||||
|
||||
expect(result.errors).toEqual([error409]);
|
||||
expect(mockLogger.warn).toBeCalledWith('Bulk delete rules conflicts, exceeded retries');
|
||||
});
|
||||
|
||||
for (let i = 1; i <= RETRY_IF_CONFLICTS_ATTEMPTS; i++) {
|
||||
test(`should work when operation conflicts ${i} times`, async () => {
|
||||
const result = await retryIfBulkDeleteConflicts(
|
||||
mockLogger,
|
||||
getOperationConflictsTimes(i),
|
||||
mockFilter
|
||||
);
|
||||
|
||||
expect(conflictOperationMock.mock.calls.length).toBe(i + 1);
|
||||
expect(result).toStrictEqual(mockSuccessfulResult);
|
||||
});
|
||||
}
|
||||
});
|
|
@ -0,0 +1,130 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import pMap from 'p-map';
|
||||
import { chunk } from 'lodash';
|
||||
import { KueryNode } from '@kbn/es-query';
|
||||
import { Logger } from '@kbn/core/server';
|
||||
import { convertRuleIdsToKueryNode } from '../../lib';
|
||||
import { BulkDeleteError } from '../rules_client';
|
||||
import { waitBeforeNextRetry, RETRY_IF_CONFLICTS_ATTEMPTS } from './wait_before_next_retry';
|
||||
|
||||
const MAX_RULES_IDS_IN_RETRY = 1000;
|
||||
|
||||
export type BulkDeleteOperation = (filter: KueryNode | null) => Promise<{
|
||||
apiKeysToInvalidate: string[];
|
||||
errors: BulkDeleteError[];
|
||||
taskIdsToDelete: string[];
|
||||
}>;
|
||||
|
||||
interface ReturnRetry {
|
||||
apiKeysToInvalidate: string[];
|
||||
errors: BulkDeleteError[];
|
||||
taskIdsToDelete: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Retries BulkDelete requests
|
||||
* If in response are presents conflicted savedObjects(409 statusCode), this util constructs filter with failed SO ids and retries bulkDelete operation until
|
||||
* all SO updated or number of retries exceeded
|
||||
* @param logger
|
||||
* @param bulkEditOperation
|
||||
* @param filter - KueryNode filter
|
||||
* @param retries - number of retries left
|
||||
* @param accApiKeysToInvalidate - accumulated apiKeys that need to be invalidated
|
||||
* @param accErrors - accumulated conflict errors
|
||||
* @param accTaskIdsToDelete - accumulated task ids
|
||||
* @returns Promise<ReturnRetry>
|
||||
*/
|
||||
export const retryIfBulkDeleteConflicts = async (
|
||||
logger: Logger,
|
||||
bulkDeleteOperation: BulkDeleteOperation,
|
||||
filter: KueryNode | null,
|
||||
retries: number = RETRY_IF_CONFLICTS_ATTEMPTS,
|
||||
accApiKeysToInvalidate: string[] = [],
|
||||
accErrors: BulkDeleteError[] = [],
|
||||
accTaskIdsToDelete: string[] = []
|
||||
): Promise<ReturnRetry> => {
|
||||
try {
|
||||
const {
|
||||
apiKeysToInvalidate: currentApiKeysToInvalidate,
|
||||
errors: currentErrors,
|
||||
taskIdsToDelete: currentTaskIdsToDelete,
|
||||
} = await bulkDeleteOperation(filter);
|
||||
|
||||
const apiKeysToInvalidate = [...accApiKeysToInvalidate, ...currentApiKeysToInvalidate];
|
||||
const taskIdsToDelete = [...accTaskIdsToDelete, ...currentTaskIdsToDelete];
|
||||
const errors =
|
||||
retries <= 0
|
||||
? [...accErrors, ...currentErrors]
|
||||
: [...accErrors, ...currentErrors.filter((error) => error.status !== 409)];
|
||||
|
||||
const ruleIdsWithConflictError = currentErrors.reduce<string[]>((acc, error) => {
|
||||
if (error.status === 409) {
|
||||
return [...acc, error.rule.id];
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
if (ruleIdsWithConflictError.length === 0) {
|
||||
return {
|
||||
apiKeysToInvalidate,
|
||||
errors,
|
||||
taskIdsToDelete,
|
||||
};
|
||||
}
|
||||
|
||||
if (retries <= 0) {
|
||||
logger.warn('Bulk delete rules conflicts, exceeded retries');
|
||||
|
||||
return {
|
||||
apiKeysToInvalidate,
|
||||
errors,
|
||||
taskIdsToDelete,
|
||||
};
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`Bulk delete rules conflicts, retrying ..., ${ruleIdsWithConflictError.length} saved objects conflicted`
|
||||
);
|
||||
|
||||
await waitBeforeNextRetry(retries);
|
||||
|
||||
// here, we construct filter query with ids. But, due to a fact that number of conflicted saved objects can exceed few thousands we can encounter following error:
|
||||
// "all shards failed: search_phase_execution_exception: [query_shard_exception] Reason: failed to create query: maxClauseCount is set to 2621"
|
||||
// That's why we chunk processing ids into pieces by size equals to MAX_RULES_IDS_IN_RETRY
|
||||
return (
|
||||
await pMap(
|
||||
chunk(ruleIdsWithConflictError, MAX_RULES_IDS_IN_RETRY),
|
||||
async (queryIds) =>
|
||||
retryIfBulkDeleteConflicts(
|
||||
logger,
|
||||
bulkDeleteOperation,
|
||||
convertRuleIdsToKueryNode(queryIds),
|
||||
retries - 1,
|
||||
apiKeysToInvalidate,
|
||||
errors,
|
||||
taskIdsToDelete
|
||||
),
|
||||
{
|
||||
concurrency: 1,
|
||||
}
|
||||
)
|
||||
).reduce<ReturnRetry>(
|
||||
(acc, item) => {
|
||||
return {
|
||||
apiKeysToInvalidate: [...acc.apiKeysToInvalidate, ...item.apiKeysToInvalidate],
|
||||
errors: [...acc.errors, ...item.errors],
|
||||
taskIdsToDelete: [...acc.taskIdsToDelete, ...item.taskIdsToDelete],
|
||||
};
|
||||
},
|
||||
{ apiKeysToInvalidate: [], errors: [], taskIdsToDelete: [] }
|
||||
);
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
};
|
|
@ -6,10 +6,8 @@
|
|||
*/
|
||||
import { KueryNode } from '@kbn/es-query';
|
||||
|
||||
import {
|
||||
retryIfBulkEditConflicts,
|
||||
RetryForConflictsAttempts,
|
||||
} from './retry_if_bulk_edit_conflicts';
|
||||
import { retryIfBulkEditConflicts } from './retry_if_bulk_edit_conflicts';
|
||||
import { RETRY_IF_CONFLICTS_ATTEMPTS } from './wait_before_next_retry';
|
||||
import { loggingSystemMock } from '@kbn/core/server/mocks';
|
||||
|
||||
const mockFilter: KueryNode = {
|
||||
|
@ -112,11 +110,11 @@ describe('retryIfBulkEditConflicts', () => {
|
|||
).rejects.toThrowError('Test failure');
|
||||
});
|
||||
|
||||
test(`should return conflict errors when number of retries exceeds ${RetryForConflictsAttempts}`, async () => {
|
||||
test(`should return conflict errors when number of retries exceeds ${RETRY_IF_CONFLICTS_ATTEMPTS}`, async () => {
|
||||
const result = await retryIfBulkEditConflicts(
|
||||
mockLogger,
|
||||
mockOperationName,
|
||||
getOperationConflictsTimes(RetryForConflictsAttempts + 1),
|
||||
getOperationConflictsTimes(RETRY_IF_CONFLICTS_ATTEMPTS + 1),
|
||||
mockFilter
|
||||
);
|
||||
|
||||
|
@ -132,7 +130,7 @@ describe('retryIfBulkEditConflicts', () => {
|
|||
expect(mockLogger.warn).toBeCalledWith(`${mockOperationName} conflicts, exceeded retries`);
|
||||
});
|
||||
|
||||
for (let i = 1; i <= RetryForConflictsAttempts; i++) {
|
||||
for (let i = 1; i <= RETRY_IF_CONFLICTS_ATTEMPTS; i++) {
|
||||
test(`should work when operation conflicts ${i} times`, async () => {
|
||||
const result = await retryIfBulkEditConflicts(
|
||||
mockLogger,
|
||||
|
|
|
@ -12,15 +12,7 @@ import { Logger, SavedObjectsBulkUpdateObject, SavedObjectsUpdateResponse } from
|
|||
import { convertRuleIdsToKueryNode } from '../../lib';
|
||||
import { BulkEditError } from '../rules_client';
|
||||
import { RawRule } from '../../types';
|
||||
|
||||
// number of times to retry when conflicts occur
|
||||
export const RetryForConflictsAttempts = 2;
|
||||
|
||||
// milliseconds to wait before retrying when conflicts occur
|
||||
// note: we considered making this random, to help avoid a stampede, but
|
||||
// with 1 retry it probably doesn't matter, and adding randomness could
|
||||
// make it harder to diagnose issues
|
||||
const RetryForConflictsDelay = 250;
|
||||
import { waitBeforeNextRetry, RETRY_IF_CONFLICTS_ATTEMPTS } from './wait_before_next_retry';
|
||||
|
||||
// max number of failed SO ids in one retry filter
|
||||
const MaxIdsNumberInRetryFilter = 1000;
|
||||
|
@ -57,7 +49,7 @@ export const retryIfBulkEditConflicts = async (
|
|||
name: string,
|
||||
bulkEditOperation: BulkEditOperation,
|
||||
filter: KueryNode | null,
|
||||
retries: number = RetryForConflictsAttempts,
|
||||
retries: number = RETRY_IF_CONFLICTS_ATTEMPTS,
|
||||
accApiKeysToInvalidate: string[] = [],
|
||||
accResults: Array<SavedObjectsUpdateResponse<RawRule>> = [],
|
||||
accErrors: BulkEditError[] = []
|
||||
|
@ -154,13 +146,3 @@ export const retryIfBulkEditConflicts = async (
|
|||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
// exponential delay before retry with adding random delay
|
||||
async function waitBeforeNextRetry(retries: number): Promise<void> {
|
||||
const exponentialDelayMultiplier = 1 + (RetryForConflictsAttempts - retries) ** 2;
|
||||
const randomDelayMs = Math.floor(Math.random() * 100);
|
||||
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, RetryForConflictsDelay * exponentialDelayMultiplier + randomDelayMs)
|
||||
);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import {
|
||||
getExponentialDelayMultiplier,
|
||||
randomDelayMs,
|
||||
RETRY_IF_CONFLICTS_DELAY,
|
||||
RETRY_IF_CONFLICTS_ATTEMPTS,
|
||||
waitBeforeNextRetry,
|
||||
} from './wait_before_next_retry';
|
||||
|
||||
describe('waitBeforeNextRetry', () => {
|
||||
const randomDelayPart = 0.1;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(global.Math, 'random').mockReturnValue(randomDelayPart);
|
||||
jest.spyOn(window, 'setTimeout');
|
||||
});
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
for (let i = 1; i <= RETRY_IF_CONFLICTS_ATTEMPTS; i++) {
|
||||
it(`should set timout for ${i} tries`, async () => {
|
||||
await waitBeforeNextRetry(i);
|
||||
expect(setTimeout).toBeCalledTimes(1);
|
||||
expect(setTimeout).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
RETRY_IF_CONFLICTS_DELAY * getExponentialDelayMultiplier(i) + randomDelayMs
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
export const RETRY_IF_CONFLICTS_ATTEMPTS = 2;
|
||||
|
||||
// milliseconds to wait before retrying when conflicts occur
|
||||
// note: we considered making this random, to help avoid a stampede, but
|
||||
// with 1 retry it probably doesn't matter, and adding randomness could
|
||||
// make it harder to diagnose issues
|
||||
export const RETRY_IF_CONFLICTS_DELAY = 250;
|
||||
|
||||
export const randomDelayMs = Math.floor(Math.random() * 100);
|
||||
export const getExponentialDelayMultiplier = (retries: number) =>
|
||||
1 + (RETRY_IF_CONFLICTS_ATTEMPTS - retries) ** 2;
|
||||
|
||||
/**
|
||||
* exponential delay before retry with adding random delay
|
||||
*/
|
||||
export const waitBeforeNextRetry = async (retries: number): Promise<void> => {
|
||||
const exponentialDelayMultiplier = getExponentialDelayMultiplier(retries);
|
||||
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, RETRY_IF_CONFLICTS_DELAY * exponentialDelayMultiplier + randomDelayMs)
|
||||
);
|
||||
};
|
|
@ -33,6 +33,7 @@ import {
|
|||
SavedObjectsUtils,
|
||||
SavedObjectAttributes,
|
||||
SavedObjectsBulkUpdateObject,
|
||||
SavedObjectsBulkDeleteObject,
|
||||
SavedObjectsUpdateResponse,
|
||||
} from '@kbn/core/server';
|
||||
import { ActionsClient, ActionsAuthorization } from '@kbn/actions-plugin/server';
|
||||
|
@ -104,6 +105,7 @@ import {
|
|||
mapSortField,
|
||||
validateOperationOnAttributes,
|
||||
retryIfBulkEditConflicts,
|
||||
retryIfBulkDeleteConflicts,
|
||||
applyBulkEditOperation,
|
||||
buildKueryNodeFilter,
|
||||
} from './lib';
|
||||
|
@ -178,7 +180,7 @@ export interface RuleAggregation {
|
|||
};
|
||||
}
|
||||
|
||||
export interface RuleBulkEditAggregation {
|
||||
export interface RuleBulkOperationAggregation {
|
||||
alertTypeId: {
|
||||
buckets: Array<{
|
||||
key: string[];
|
||||
|
@ -297,6 +299,16 @@ export type BulkEditOptions<Params extends RuleTypeParams> =
|
|||
| BulkEditOptionsFilter<Params>
|
||||
| BulkEditOptionsIds<Params>;
|
||||
|
||||
export interface BulkDeleteOptionsFilter {
|
||||
filter?: string | KueryNode;
|
||||
}
|
||||
|
||||
export interface BulkDeleteOptionsIds {
|
||||
ids?: string[];
|
||||
}
|
||||
|
||||
export type BulkDeleteOptions = BulkDeleteOptionsFilter | BulkDeleteOptionsIds;
|
||||
|
||||
export interface BulkEditError {
|
||||
message: string;
|
||||
rule: {
|
||||
|
@ -305,6 +317,15 @@ export interface BulkEditError {
|
|||
};
|
||||
}
|
||||
|
||||
export interface BulkDeleteError {
|
||||
message: string;
|
||||
status: number;
|
||||
rule: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AggregateOptions extends IndexType {
|
||||
search?: string;
|
||||
defaultSearchOperator?: 'AND' | 'OR';
|
||||
|
@ -433,7 +454,7 @@ const extractedSavedObjectParamReferenceNamePrefix = 'param:';
|
|||
// NOTE: Changing this prefix will require a migration to update the prefix in all existing `rule` saved objects
|
||||
const preconfiguredConnectorActionRefPrefix = 'preconfigured:';
|
||||
|
||||
const MAX_RULES_NUMBER_FOR_BULK_EDIT = 10000;
|
||||
const MAX_RULES_NUMBER_FOR_BULK_OPERATION = 10000;
|
||||
const API_KEY_GENERATE_CONCURRENCY = 50;
|
||||
const RULE_TYPE_CHECKS_CONCURRENCY = 50;
|
||||
|
||||
|
@ -1695,6 +1716,212 @@ export class RulesClient {
|
|||
);
|
||||
}
|
||||
|
||||
private getAuthorizationFilter = async () => {
|
||||
try {
|
||||
const authorizationTuple = await this.authorization.getFindAuthorizationFilter(
|
||||
AlertingAuthorizationEntity.Rule,
|
||||
alertingAuthorizationFilterOpts
|
||||
);
|
||||
return authorizationTuple.filter;
|
||||
} catch (error) {
|
||||
this.auditLogger?.log(
|
||||
ruleAuditEvent({
|
||||
action: RuleAuditAction.DELETE,
|
||||
error,
|
||||
})
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
public bulkDeleteRules = async (options: BulkDeleteOptions) => {
|
||||
const filter = (options as BulkDeleteOptionsFilter).filter;
|
||||
const ids = (options as BulkDeleteOptionsIds).ids;
|
||||
|
||||
if (!ids && !filter) {
|
||||
throw Boom.badRequest(
|
||||
"Either 'ids' or 'filter' property in method's arguments should be provided"
|
||||
);
|
||||
}
|
||||
|
||||
if (ids?.length === 0) {
|
||||
throw Boom.badRequest("'ids' property should not be an empty array");
|
||||
}
|
||||
|
||||
if (ids && filter) {
|
||||
throw Boom.badRequest(
|
||||
"Both 'filter' and 'ids' are supplied. Define either 'ids' or 'filter' properties in method's arguments"
|
||||
);
|
||||
}
|
||||
|
||||
const kueryNodeFilter = ids ? convertRuleIdsToKueryNode(ids) : buildKueryNodeFilter(filter);
|
||||
const authorizationFilter = await this.getAuthorizationFilter();
|
||||
|
||||
const kueryNodeFilterWithAuth =
|
||||
authorizationFilter && kueryNodeFilter
|
||||
? nodeBuilder.and([kueryNodeFilter, authorizationFilter as KueryNode])
|
||||
: kueryNodeFilter;
|
||||
|
||||
const { aggregations, total } = await this.unsecuredSavedObjectsClient.find<
|
||||
RawRule,
|
||||
RuleBulkOperationAggregation
|
||||
>({
|
||||
filter: kueryNodeFilterWithAuth,
|
||||
page: 1,
|
||||
perPage: 0,
|
||||
type: 'alert',
|
||||
aggs: {
|
||||
alertTypeId: {
|
||||
multi_terms: {
|
||||
terms: [
|
||||
{ field: 'alert.attributes.alertTypeId' },
|
||||
{ field: 'alert.attributes.consumer' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (total > MAX_RULES_NUMBER_FOR_BULK_OPERATION) {
|
||||
throw Boom.badRequest(
|
||||
`More than ${MAX_RULES_NUMBER_FOR_BULK_OPERATION} rules matched for bulk delete`
|
||||
);
|
||||
}
|
||||
|
||||
const buckets = aggregations?.alertTypeId.buckets;
|
||||
|
||||
if (buckets === undefined || buckets?.length === 0) {
|
||||
throw Boom.badRequest('No rules found for bulk delete');
|
||||
}
|
||||
|
||||
await pMap(
|
||||
buckets,
|
||||
async ({ key: [ruleType, consumer] }) => {
|
||||
this.ruleTypeRegistry.ensureRuleTypeEnabled(ruleType);
|
||||
try {
|
||||
await this.authorization.ensureAuthorized({
|
||||
ruleTypeId: ruleType,
|
||||
consumer,
|
||||
operation: WriteOperations.BulkDelete,
|
||||
entity: AlertingAuthorizationEntity.Rule,
|
||||
});
|
||||
} catch (error) {
|
||||
this.auditLogger?.log(
|
||||
ruleAuditEvent({
|
||||
action: RuleAuditAction.DELETE,
|
||||
error,
|
||||
})
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
{ concurrency: RULE_TYPE_CHECKS_CONCURRENCY }
|
||||
);
|
||||
|
||||
const { apiKeysToInvalidate, errors, taskIdsToDelete } = await retryIfBulkDeleteConflicts(
|
||||
this.logger,
|
||||
(filterKueryNode: KueryNode | null) => this.bulkDeleteWithOCC({ filter: filterKueryNode }),
|
||||
kueryNodeFilterWithAuth
|
||||
);
|
||||
|
||||
const taskIdsFailedToBeDeleted: string[] = [];
|
||||
if (taskIdsToDelete.length > 0) {
|
||||
try {
|
||||
const resultFromDeletingTasks = await this.taskManager.bulkRemoveIfExist(taskIdsToDelete);
|
||||
resultFromDeletingTasks?.statuses.forEach((status) => {
|
||||
if (!status.success) {
|
||||
taskIdsFailedToBeDeleted.push(status.id);
|
||||
}
|
||||
});
|
||||
this.logger.debug(
|
||||
`Successfully deleted schedules for underlying tasks: ${taskIdsToDelete
|
||||
.filter((id) => taskIdsFailedToBeDeleted.includes(id))
|
||||
.join(', ')}`
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failure to delete schedules for underlying tasks: ${taskIdsToDelete.join(
|
||||
', '
|
||||
)}. TaskManager bulkRemoveIfExist failed with Error: ${error.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await bulkMarkApiKeysForInvalidation(
|
||||
{ apiKeys: apiKeysToInvalidate },
|
||||
this.logger,
|
||||
this.unsecuredSavedObjectsClient
|
||||
);
|
||||
|
||||
return { errors, total, taskIdsFailedToBeDeleted };
|
||||
};
|
||||
|
||||
private bulkDeleteWithOCC = async ({ filter }: { filter: KueryNode | null }) => {
|
||||
const rulesFinder =
|
||||
await this.encryptedSavedObjectsClient.createPointInTimeFinderDecryptedAsInternalUser<RawRule>(
|
||||
{
|
||||
filter,
|
||||
type: 'alert',
|
||||
perPage: 100,
|
||||
...(this.namespace ? { namespaces: [this.namespace] } : undefined),
|
||||
}
|
||||
);
|
||||
|
||||
const rules: SavedObjectsBulkDeleteObject[] = [];
|
||||
const apiKeysToInvalidate: string[] = [];
|
||||
const taskIdsToDelete: string[] = [];
|
||||
const errors: BulkDeleteError[] = [];
|
||||
const apiKeyToRuleIdMapping: Record<string, string> = {};
|
||||
const taskIdToRuleIdMapping: Record<string, string> = {};
|
||||
const ruleNameToRuleIdMapping: Record<string, string> = {};
|
||||
|
||||
for await (const response of rulesFinder.find()) {
|
||||
for (const rule of response.saved_objects) {
|
||||
if (rule.attributes.apiKey) {
|
||||
apiKeyToRuleIdMapping[rule.id] = rule.attributes.apiKey;
|
||||
}
|
||||
if (rule.attributes.name) {
|
||||
ruleNameToRuleIdMapping[rule.id] = rule.attributes.name;
|
||||
}
|
||||
if (rule.attributes.scheduledTaskId) {
|
||||
taskIdToRuleIdMapping[rule.id] = rule.attributes.scheduledTaskId;
|
||||
}
|
||||
rules.push(rule);
|
||||
|
||||
this.auditLogger?.log(
|
||||
ruleAuditEvent({
|
||||
action: RuleAuditAction.DELETE,
|
||||
outcome: 'unknown',
|
||||
savedObject: { type: 'alert', id: rule.id },
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await this.unsecuredSavedObjectsClient.bulkDelete(rules);
|
||||
|
||||
result.statuses.forEach((status) => {
|
||||
if (status.error === undefined) {
|
||||
if (apiKeyToRuleIdMapping[status.id]) {
|
||||
apiKeysToInvalidate.push(apiKeyToRuleIdMapping[status.id]);
|
||||
}
|
||||
if (taskIdToRuleIdMapping[status.id]) {
|
||||
taskIdsToDelete.push(taskIdToRuleIdMapping[status.id]);
|
||||
}
|
||||
} else {
|
||||
errors.push({
|
||||
message: status.error.message ?? 'n/a',
|
||||
status: status.error.statusCode,
|
||||
rule: {
|
||||
id: status.id,
|
||||
name: ruleNameToRuleIdMapping[status.id] ?? 'n/a',
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
return { apiKeysToInvalidate, errors, taskIdsToDelete };
|
||||
};
|
||||
|
||||
public async bulkEdit<Params extends RuleTypeParams>(
|
||||
options: BulkEditOptions<Params>
|
||||
): Promise<{
|
||||
|
@ -1737,7 +1964,7 @@ export class RulesClient {
|
|||
|
||||
const { aggregations, total } = await this.unsecuredSavedObjectsClient.find<
|
||||
RawRule,
|
||||
RuleBulkEditAggregation
|
||||
RuleBulkOperationAggregation
|
||||
>({
|
||||
filter: qNodeFilterWithAuth,
|
||||
page: 1,
|
||||
|
@ -1755,9 +1982,9 @@ export class RulesClient {
|
|||
},
|
||||
});
|
||||
|
||||
if (total > MAX_RULES_NUMBER_FOR_BULK_EDIT) {
|
||||
if (total > MAX_RULES_NUMBER_FOR_BULK_OPERATION) {
|
||||
throw Boom.badRequest(
|
||||
`More than ${MAX_RULES_NUMBER_FOR_BULK_EDIT} rules matched for bulk edit`
|
||||
`More than ${MAX_RULES_NUMBER_FOR_BULK_OPERATION} rules matched for bulk edit`
|
||||
);
|
||||
}
|
||||
const buckets = aggregations?.alertTypeId.buckets;
|
||||
|
|
|
@ -0,0 +1,492 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { RulesClient, ConstructorOptions } from '../rules_client';
|
||||
import { savedObjectsClientMock } from '@kbn/core/server/mocks';
|
||||
import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks';
|
||||
import { ruleTypeRegistryMock } from '../../rule_type_registry.mock';
|
||||
import { alertingAuthorizationMock } from '../../authorization/alerting_authorization.mock';
|
||||
import { RecoveredActionGroup } from '../../../common';
|
||||
import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks';
|
||||
import { actionsAuthorizationMock } from '@kbn/actions-plugin/server/mocks';
|
||||
import { AlertingAuthorization } from '../../authorization/alerting_authorization';
|
||||
import { ActionsAuthorization } from '@kbn/actions-plugin/server';
|
||||
import { auditLoggerMock } from '@kbn/security-plugin/server/audit/mocks';
|
||||
import { getBeforeSetup, setGlobalDate } from './lib';
|
||||
import { bulkMarkApiKeysForInvalidation } from '../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation';
|
||||
import { loggerMock } from '@kbn/logging-mocks';
|
||||
|
||||
jest.mock('../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation', () => ({
|
||||
bulkMarkApiKeysForInvalidation: jest.fn(),
|
||||
}));
|
||||
|
||||
const taskManager = taskManagerMock.createStart();
|
||||
const ruleTypeRegistry = ruleTypeRegistryMock.create();
|
||||
const unsecuredSavedObjectsClient = savedObjectsClientMock.create();
|
||||
const encryptedSavedObjects = encryptedSavedObjectsMock.createClient();
|
||||
const authorization = alertingAuthorizationMock.create();
|
||||
const actionsAuthorization = actionsAuthorizationMock.create();
|
||||
const auditLogger = auditLoggerMock.create();
|
||||
const logger = loggerMock.create();
|
||||
|
||||
const kibanaVersion = 'v8.2.0';
|
||||
const createAPIKeyMock = jest.fn();
|
||||
const rulesClientParams: jest.Mocked<ConstructorOptions> = {
|
||||
taskManager,
|
||||
ruleTypeRegistry,
|
||||
unsecuredSavedObjectsClient,
|
||||
authorization: authorization as unknown as AlertingAuthorization,
|
||||
actionsAuthorization: actionsAuthorization as unknown as ActionsAuthorization,
|
||||
spaceId: 'default',
|
||||
namespace: 'default',
|
||||
getUserName: jest.fn(),
|
||||
createAPIKey: createAPIKeyMock,
|
||||
logger,
|
||||
encryptedSavedObjectsClient: encryptedSavedObjects,
|
||||
getActionsClient: jest.fn(),
|
||||
getEventLogClient: jest.fn(),
|
||||
kibanaVersion,
|
||||
auditLogger,
|
||||
minimumScheduleInterval: { value: '1m', enforce: false },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
getBeforeSetup(rulesClientParams, taskManager, ruleTypeRegistry);
|
||||
(auditLogger.log as jest.Mock).mockClear();
|
||||
});
|
||||
|
||||
setGlobalDate();
|
||||
|
||||
describe('bulkDelete', () => {
|
||||
let rulesClient: RulesClient;
|
||||
const existingRule = {
|
||||
id: 'id1',
|
||||
type: 'alert',
|
||||
attributes: {},
|
||||
references: [],
|
||||
version: '123',
|
||||
};
|
||||
const existingDecryptedRule1 = {
|
||||
...existingRule,
|
||||
attributes: {
|
||||
...existingRule.attributes,
|
||||
scheduledTaskId: 'taskId1',
|
||||
apiKey: Buffer.from('123:abc').toString('base64'),
|
||||
},
|
||||
};
|
||||
const existingDecryptedRule2 = {
|
||||
...existingRule,
|
||||
id: 'id2',
|
||||
attributes: {
|
||||
...existingRule.attributes,
|
||||
scheduledTaskId: 'taskId2',
|
||||
apiKey: Buffer.from('321:abc').toString('base64'),
|
||||
},
|
||||
};
|
||||
|
||||
const mockCreatePointInTimeFinderAsInternalUser = (
|
||||
response = { saved_objects: [existingDecryptedRule1, existingDecryptedRule2] }
|
||||
) => {
|
||||
encryptedSavedObjects.createPointInTimeFinderDecryptedAsInternalUser = jest
|
||||
.fn()
|
||||
.mockResolvedValue({
|
||||
close: jest.fn(),
|
||||
find: function* asyncGenerator() {
|
||||
yield response;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
rulesClient = new RulesClient(rulesClientParams);
|
||||
authorization.getFindAuthorizationFilter.mockResolvedValue({
|
||||
ensureRuleTypeIsAuthorized() {},
|
||||
});
|
||||
|
||||
unsecuredSavedObjectsClient.find.mockResolvedValue({
|
||||
aggregations: {
|
||||
alertTypeId: {
|
||||
buckets: [{ key: ['myType', 'myApp'], key_as_string: 'myType|myApp', doc_count: 2 }],
|
||||
},
|
||||
},
|
||||
saved_objects: [],
|
||||
per_page: 0,
|
||||
page: 0,
|
||||
total: 2,
|
||||
});
|
||||
|
||||
ruleTypeRegistry.get.mockReturnValue({
|
||||
id: 'myType',
|
||||
name: 'Test',
|
||||
actionGroups: [
|
||||
{ id: 'default', name: 'Default' },
|
||||
{ id: 'custom', name: 'Not the Default' },
|
||||
],
|
||||
defaultActionGroupId: 'default',
|
||||
minimumLicenseRequired: 'basic',
|
||||
isExportable: true,
|
||||
recoveryActionGroup: RecoveredActionGroup,
|
||||
async executor() {},
|
||||
producer: 'alerts',
|
||||
});
|
||||
});
|
||||
|
||||
test('should try to delete rules, one successful and one with 500 error', async () => {
|
||||
mockCreatePointInTimeFinderAsInternalUser();
|
||||
unsecuredSavedObjectsClient.bulkDelete.mockResolvedValue({
|
||||
statuses: [
|
||||
{ id: 'id1', type: 'alert', success: true },
|
||||
{
|
||||
id: 'id2',
|
||||
type: 'alert',
|
||||
success: false,
|
||||
error: {
|
||||
error: '',
|
||||
message: 'UPS',
|
||||
statusCode: 500,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await rulesClient.bulkDeleteRules({ filter: 'fake_filter' });
|
||||
|
||||
expect(unsecuredSavedObjectsClient.bulkDelete).toHaveBeenCalledTimes(1);
|
||||
expect(unsecuredSavedObjectsClient.bulkDelete).toHaveBeenCalledWith([
|
||||
existingDecryptedRule1,
|
||||
existingDecryptedRule2,
|
||||
]);
|
||||
expect(taskManager.bulkRemoveIfExist).toHaveBeenCalledTimes(1);
|
||||
expect(taskManager.bulkRemoveIfExist).toHaveBeenCalledWith(['taskId1']);
|
||||
expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1);
|
||||
expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith(
|
||||
{ apiKeys: ['MTIzOmFiYw=='] },
|
||||
expect.anything(),
|
||||
expect.anything()
|
||||
);
|
||||
expect(result).toStrictEqual({
|
||||
errors: [{ message: 'UPS', rule: { id: 'id2', name: 'n/a' }, status: 500 }],
|
||||
total: 2,
|
||||
taskIdsFailedToBeDeleted: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('should try to delete rules, one successful and one with 409 error, which will not be deleted with retry', async () => {
|
||||
unsecuredSavedObjectsClient.bulkDelete
|
||||
.mockResolvedValueOnce({
|
||||
statuses: [
|
||||
{ id: 'id1', type: 'alert', success: true },
|
||||
{
|
||||
id: 'id2',
|
||||
type: 'alert',
|
||||
success: false,
|
||||
error: {
|
||||
error: '',
|
||||
message: 'UPS',
|
||||
statusCode: 409,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
statuses: [
|
||||
{
|
||||
id: 'id2',
|
||||
type: 'alert',
|
||||
success: false,
|
||||
error: {
|
||||
error: '',
|
||||
message: 'UPS',
|
||||
statusCode: 409,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
statuses: [
|
||||
{
|
||||
id: 'id2',
|
||||
type: 'alert',
|
||||
success: false,
|
||||
error: {
|
||||
error: '',
|
||||
message: 'UPS',
|
||||
statusCode: 409,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
encryptedSavedObjects.createPointInTimeFinderDecryptedAsInternalUser = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
close: jest.fn(),
|
||||
find: function* asyncGenerator() {
|
||||
yield { saved_objects: [existingDecryptedRule1, existingDecryptedRule2] };
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
close: jest.fn(),
|
||||
find: function* asyncGenerator() {
|
||||
yield { saved_objects: [existingDecryptedRule2] };
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
close: jest.fn(),
|
||||
find: function* asyncGenerator() {
|
||||
yield { saved_objects: [existingDecryptedRule2] };
|
||||
},
|
||||
});
|
||||
|
||||
const result = await rulesClient.bulkDeleteRules({ ids: ['id1', 'id2'] });
|
||||
|
||||
expect(unsecuredSavedObjectsClient.bulkDelete).toHaveBeenCalledTimes(3);
|
||||
expect(taskManager.bulkRemoveIfExist).toHaveBeenCalledTimes(1);
|
||||
expect(taskManager.bulkRemoveIfExist).toHaveBeenCalledWith(['taskId1']);
|
||||
expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1);
|
||||
expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith(
|
||||
{ apiKeys: ['MTIzOmFiYw=='] },
|
||||
expect.anything(),
|
||||
expect.anything()
|
||||
);
|
||||
expect(result).toStrictEqual({
|
||||
errors: [{ message: 'UPS', rule: { id: 'id2', name: 'n/a' }, status: 409 }],
|
||||
total: 2,
|
||||
taskIdsFailedToBeDeleted: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('should try to delete rules, one successful and one with 409 error, which successfully will be deleted with retry', async () => {
|
||||
unsecuredSavedObjectsClient.bulkDelete
|
||||
.mockResolvedValueOnce({
|
||||
statuses: [
|
||||
{ id: 'id1', type: 'alert', success: true },
|
||||
{
|
||||
id: 'id2',
|
||||
type: 'alert',
|
||||
success: false,
|
||||
error: {
|
||||
error: '',
|
||||
message: 'UPS',
|
||||
statusCode: 409,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
statuses: [
|
||||
{
|
||||
id: 'id2',
|
||||
type: 'alert',
|
||||
success: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
encryptedSavedObjects.createPointInTimeFinderDecryptedAsInternalUser = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
close: jest.fn(),
|
||||
find: function* asyncGenerator() {
|
||||
yield { saved_objects: [existingDecryptedRule1, existingDecryptedRule2] };
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
close: jest.fn(),
|
||||
find: function* asyncGenerator() {
|
||||
yield { saved_objects: [existingDecryptedRule2] };
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
close: jest.fn(),
|
||||
find: function* asyncGenerator() {
|
||||
yield { saved_objects: [existingDecryptedRule2] };
|
||||
},
|
||||
});
|
||||
|
||||
const result = await rulesClient.bulkDeleteRules({ ids: ['id1', 'id2'] });
|
||||
|
||||
expect(unsecuredSavedObjectsClient.bulkDelete).toHaveBeenCalledTimes(2);
|
||||
expect(taskManager.bulkRemoveIfExist).toHaveBeenCalledTimes(1);
|
||||
expect(taskManager.bulkRemoveIfExist).toHaveBeenCalledWith(['taskId1', 'taskId2']);
|
||||
expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledTimes(1);
|
||||
expect(bulkMarkApiKeysForInvalidation).toHaveBeenCalledWith(
|
||||
{ apiKeys: ['MTIzOmFiYw==', 'MzIxOmFiYw=='] },
|
||||
expect.anything(),
|
||||
expect.anything()
|
||||
);
|
||||
expect(result).toStrictEqual({
|
||||
errors: [],
|
||||
total: 2,
|
||||
taskIdsFailedToBeDeleted: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('should thow an error if number of matched rules greater than 10,000', async () => {
|
||||
unsecuredSavedObjectsClient.find.mockResolvedValue({
|
||||
aggregations: {
|
||||
alertTypeId: {
|
||||
buckets: [{ key: ['myType', 'myApp'], key_as_string: 'myType|myApp', doc_count: 2 }],
|
||||
},
|
||||
},
|
||||
saved_objects: [],
|
||||
per_page: 0,
|
||||
page: 0,
|
||||
total: 10001,
|
||||
});
|
||||
|
||||
await expect(rulesClient.bulkDeleteRules({ filter: 'fake_filter' })).rejects.toThrow(
|
||||
'More than 10000 rules matched for bulk delete'
|
||||
);
|
||||
});
|
||||
|
||||
test('should throw an error if we do not get buckets', async () => {
|
||||
mockCreatePointInTimeFinderAsInternalUser();
|
||||
unsecuredSavedObjectsClient.find.mockResolvedValue({
|
||||
aggregations: {
|
||||
alertTypeId: {},
|
||||
},
|
||||
saved_objects: [],
|
||||
per_page: 0,
|
||||
page: 0,
|
||||
total: 2,
|
||||
});
|
||||
|
||||
await expect(rulesClient.bulkDeleteRules({ filter: 'fake_filter' })).rejects.toThrow(
|
||||
'No rules found for bulk delete'
|
||||
);
|
||||
});
|
||||
|
||||
describe('taskManager', () => {
|
||||
test('should return task id if deleting task failed', async () => {
|
||||
mockCreatePointInTimeFinderAsInternalUser();
|
||||
unsecuredSavedObjectsClient.bulkDelete.mockResolvedValue({
|
||||
statuses: [
|
||||
{ id: 'id1', type: 'alert', success: true },
|
||||
{ id: 'id2', type: 'alert', success: true },
|
||||
],
|
||||
});
|
||||
taskManager.bulkRemoveIfExist.mockImplementation(async () => ({
|
||||
statuses: [
|
||||
{
|
||||
id: 'taskId1',
|
||||
type: 'alert',
|
||||
success: true,
|
||||
},
|
||||
{
|
||||
id: 'taskId2',
|
||||
type: 'alert',
|
||||
success: false,
|
||||
error: {
|
||||
error: '',
|
||||
message: 'UPS',
|
||||
statusCode: 500,
|
||||
},
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
const result = await rulesClient.bulkDeleteRules({ filter: 'fake_filter' });
|
||||
|
||||
expect(logger.debug).toBeCalledTimes(1);
|
||||
expect(logger.debug).toBeCalledWith(
|
||||
'Successfully deleted schedules for underlying tasks: taskId2'
|
||||
);
|
||||
expect(result).toStrictEqual({
|
||||
errors: [],
|
||||
total: 2,
|
||||
taskIdsFailedToBeDeleted: ['taskId2'],
|
||||
});
|
||||
});
|
||||
|
||||
test('should not throw an error if taskManager throw an error', async () => {
|
||||
mockCreatePointInTimeFinderAsInternalUser();
|
||||
unsecuredSavedObjectsClient.bulkDelete.mockResolvedValue({
|
||||
statuses: [
|
||||
{ id: 'id1', type: 'alert', success: true },
|
||||
{ id: 'id2', type: 'alert', success: true },
|
||||
],
|
||||
});
|
||||
taskManager.bulkRemoveIfExist.mockImplementation(() => {
|
||||
throw new Error('UPS');
|
||||
});
|
||||
|
||||
const result = await rulesClient.bulkDeleteRules({ filter: 'fake_filter' });
|
||||
|
||||
expect(logger.error).toBeCalledTimes(1);
|
||||
expect(logger.error).toBeCalledWith(
|
||||
'Failure to delete schedules for underlying tasks: taskId1, taskId2. TaskManager bulkRemoveIfExist failed with Error: UPS'
|
||||
);
|
||||
expect(result).toStrictEqual({
|
||||
errors: [],
|
||||
taskIdsFailedToBeDeleted: [],
|
||||
total: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('auditLogger', () => {
|
||||
jest.spyOn(auditLogger, 'log').mockImplementation();
|
||||
|
||||
test('logs audit event when deleting rules', async () => {
|
||||
mockCreatePointInTimeFinderAsInternalUser();
|
||||
unsecuredSavedObjectsClient.bulkDelete.mockResolvedValue({
|
||||
statuses: [
|
||||
{ id: 'id1', type: 'alert', success: true },
|
||||
{ id: 'id2', type: 'alert', success: true },
|
||||
],
|
||||
});
|
||||
|
||||
await rulesClient.bulkDeleteRules({ filter: 'fake_filter' });
|
||||
|
||||
expect(auditLogger.log.mock.calls[0][0]?.event?.action).toEqual('rule_delete');
|
||||
expect(auditLogger.log.mock.calls[0][0]?.event?.outcome).toEqual('unknown');
|
||||
expect(auditLogger.log.mock.calls[0][0]?.kibana).toEqual({
|
||||
saved_object: { id: 'id1', type: 'alert' },
|
||||
});
|
||||
expect(auditLogger.log.mock.calls[1][0]?.event?.action).toEqual('rule_delete');
|
||||
expect(auditLogger.log.mock.calls[1][0]?.event?.outcome).toEqual('unknown');
|
||||
expect(auditLogger.log.mock.calls[1][0]?.kibana).toEqual({
|
||||
saved_object: { id: 'id2', type: 'alert' },
|
||||
});
|
||||
});
|
||||
|
||||
test('logs audit event when authentication failed', async () => {
|
||||
mockCreatePointInTimeFinderAsInternalUser();
|
||||
authorization.ensureAuthorized.mockImplementation(() => {
|
||||
throw new Error('Unauthorized');
|
||||
});
|
||||
unsecuredSavedObjectsClient.bulkDelete.mockResolvedValue({
|
||||
statuses: [{ id: 'id1', type: 'alert', success: true }],
|
||||
});
|
||||
|
||||
await expect(rulesClient.bulkDeleteRules({ filter: 'fake_filter' })).rejects.toThrowError(
|
||||
'Unauthorized'
|
||||
);
|
||||
|
||||
expect(auditLogger.log.mock.calls[0][0]?.event?.action).toEqual('rule_delete');
|
||||
expect(auditLogger.log.mock.calls[0][0]?.event?.outcome).toEqual('failure');
|
||||
});
|
||||
|
||||
test('logs audit event when getting an authorization filter failed', async () => {
|
||||
mockCreatePointInTimeFinderAsInternalUser();
|
||||
authorization.getFindAuthorizationFilter.mockImplementation(() => {
|
||||
throw new Error('Error');
|
||||
});
|
||||
unsecuredSavedObjectsClient.bulkDelete.mockResolvedValue({
|
||||
statuses: [{ id: 'id1', type: 'alert', success: true }],
|
||||
});
|
||||
|
||||
await expect(rulesClient.bulkDeleteRules({ filter: 'fake_filter' })).rejects.toThrowError(
|
||||
'Error'
|
||||
);
|
||||
|
||||
expect(auditLogger.log.mock.calls[0][0]?.event?.action).toEqual('rule_delete');
|
||||
expect(auditLogger.log.mock.calls[0][0]?.event?.outcome).toEqual('failure');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -51,6 +51,9 @@ export function getBeforeSetup(
|
|||
rulesClientParams.createAPIKey.mockResolvedValue({ apiKeysEnabled: false });
|
||||
rulesClientParams.getUserName.mockResolvedValue('elastic');
|
||||
taskManager.runSoon.mockResolvedValue({ id: '' });
|
||||
taskManager.bulkRemoveIfExist.mockResolvedValue({
|
||||
statuses: [{ id: 'taskId', type: 'alert', success: true }],
|
||||
});
|
||||
const actionsClient = actionsClientMock.create();
|
||||
|
||||
actionsClient.getBulk.mockResolvedValueOnce([
|
||||
|
|
|
@ -233,6 +233,7 @@ describe(`feature_privilege_builder`, () => {
|
|||
"alerting:1.0.0-zeta1:alert-type/my-feature/rule/unmuteAlert",
|
||||
"alerting:1.0.0-zeta1:alert-type/my-feature/rule/snooze",
|
||||
"alerting:1.0.0-zeta1:alert-type/my-feature/rule/bulkEdit",
|
||||
"alerting:1.0.0-zeta1:alert-type/my-feature/rule/bulkDelete",
|
||||
"alerting:1.0.0-zeta1:alert-type/my-feature/rule/unsnooze",
|
||||
]
|
||||
`);
|
||||
|
@ -332,6 +333,7 @@ describe(`feature_privilege_builder`, () => {
|
|||
"alerting:1.0.0-zeta1:alert-type/my-feature/rule/unmuteAlert",
|
||||
"alerting:1.0.0-zeta1:alert-type/my-feature/rule/snooze",
|
||||
"alerting:1.0.0-zeta1:alert-type/my-feature/rule/bulkEdit",
|
||||
"alerting:1.0.0-zeta1:alert-type/my-feature/rule/bulkDelete",
|
||||
"alerting:1.0.0-zeta1:alert-type/my-feature/rule/unsnooze",
|
||||
"alerting:1.0.0-zeta1:alert-type/my-feature/alert/get",
|
||||
"alerting:1.0.0-zeta1:alert-type/my-feature/alert/find",
|
||||
|
@ -391,6 +393,7 @@ describe(`feature_privilege_builder`, () => {
|
|||
"alerting:1.0.0-zeta1:alert-type/my-feature/rule/unmuteAlert",
|
||||
"alerting:1.0.0-zeta1:alert-type/my-feature/rule/snooze",
|
||||
"alerting:1.0.0-zeta1:alert-type/my-feature/rule/bulkEdit",
|
||||
"alerting:1.0.0-zeta1:alert-type/my-feature/rule/bulkDelete",
|
||||
"alerting:1.0.0-zeta1:alert-type/my-feature/rule/unsnooze",
|
||||
"alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/get",
|
||||
"alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/getRuleState",
|
||||
|
@ -499,6 +502,7 @@ describe(`feature_privilege_builder`, () => {
|
|||
"alerting:1.0.0-zeta1:alert-type/my-feature/rule/unmuteAlert",
|
||||
"alerting:1.0.0-zeta1:alert-type/my-feature/rule/snooze",
|
||||
"alerting:1.0.0-zeta1:alert-type/my-feature/rule/bulkEdit",
|
||||
"alerting:1.0.0-zeta1:alert-type/my-feature/rule/bulkDelete",
|
||||
"alerting:1.0.0-zeta1:alert-type/my-feature/rule/unsnooze",
|
||||
"alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/get",
|
||||
"alerting:1.0.0-zeta1:readonly-alert-type/my-feature/rule/getRuleState",
|
||||
|
|
|
@ -42,6 +42,7 @@ const writeOperations: Record<AlertingEntity, string[]> = {
|
|||
'unmuteAlert',
|
||||
'snooze',
|
||||
'bulkEdit',
|
||||
'bulkDelete',
|
||||
'unsnooze',
|
||||
],
|
||||
alert: ['update'],
|
||||
|
|
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import uuid from 'uuid';
|
||||
import { SavedObjectsErrorHelpers } from '@kbn/core/server';
|
||||
import { bulkRemoveIfExist } from './bulk_remove_if_exist';
|
||||
import { taskStoreMock } from '../task_store.mock';
|
||||
|
||||
describe('removeIfExists', () => {
|
||||
const ids = [uuid.v4(), uuid.v4()];
|
||||
|
||||
test('removes the tasks by its IDs', async () => {
|
||||
const ts = taskStoreMock.create({});
|
||||
|
||||
expect(await bulkRemoveIfExist(ts, ids)).toBe(undefined);
|
||||
expect(ts.bulkRemove).toHaveBeenCalledWith(ids);
|
||||
});
|
||||
|
||||
test('handles 404 errors caused by the task not existing', async () => {
|
||||
const ts = taskStoreMock.create({});
|
||||
|
||||
ts.bulkRemove.mockRejectedValue(
|
||||
SavedObjectsErrorHelpers.createGenericNotFoundError('task', ids[0])
|
||||
);
|
||||
|
||||
expect(await bulkRemoveIfExist(ts, ids)).toBe(undefined);
|
||||
expect(ts.bulkRemove).toHaveBeenCalledWith(ids);
|
||||
});
|
||||
|
||||
test('throws if any other error is caused by task removal', async () => {
|
||||
const ts = taskStoreMock.create({});
|
||||
|
||||
const error = SavedObjectsErrorHelpers.createInvalidVersionError(uuid.v4());
|
||||
ts.bulkRemove.mockRejectedValue(error);
|
||||
|
||||
expect(bulkRemoveIfExist(ts, ids)).rejects.toBe(error);
|
||||
expect(ts.bulkRemove).toHaveBeenCalledWith(ids);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { SavedObjectsErrorHelpers } from '@kbn/core/server';
|
||||
import { TaskStore } from '../task_store';
|
||||
|
||||
/**
|
||||
* Removes a task from the store, ignoring a not found error
|
||||
* Other errors are re-thrown
|
||||
*
|
||||
* @param taskStore
|
||||
* @param taskIds
|
||||
*/
|
||||
export async function bulkRemoveIfExist(taskStore: TaskStore, taskIds: string[]) {
|
||||
try {
|
||||
return await taskStore.bulkRemove(taskIds);
|
||||
} catch (err) {
|
||||
if (!SavedObjectsErrorHelpers.isNotFoundError(err)) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -27,6 +27,7 @@ const createStartMock = () => {
|
|||
ephemeralRunNow: jest.fn(),
|
||||
ensureScheduled: jest.fn(),
|
||||
removeIfExists: jest.fn(),
|
||||
bulkRemoveIfExist: jest.fn(),
|
||||
supportsEphemeralTasks: jest.fn(),
|
||||
bulkUpdateSchedules: jest.fn(),
|
||||
bulkSchedule: jest.fn(),
|
||||
|
|
|
@ -18,10 +18,12 @@ import {
|
|||
ServiceStatusLevels,
|
||||
CoreStatus,
|
||||
} from '@kbn/core/server';
|
||||
import type { SavedObjectsBulkDeleteResponse } from '@kbn/core/server';
|
||||
import { TaskPollingLifecycle } from './polling_lifecycle';
|
||||
import { TaskManagerConfig } from './config';
|
||||
import { createInitialMiddleware, addMiddlewareToChain, Middleware } from './lib/middleware';
|
||||
import { removeIfExists } from './lib/remove_if_exists';
|
||||
import { bulkRemoveIfExist } from './lib/bulk_remove_if_exist';
|
||||
import { setupSavedObjects } from './saved_objects';
|
||||
import { TaskDefinitionRegistry, TaskTypeDictionary, REMOVED_TYPES } from './task_type_dictionary';
|
||||
import { AggregationOpts, FetchResult, SearchOpts, TaskStore } from './task_store';
|
||||
|
@ -33,6 +35,7 @@ import { EphemeralTaskLifecycle } from './ephemeral_task_lifecycle';
|
|||
import { EphemeralTask, ConcreteTaskInstance } from './task';
|
||||
import { registerTaskManagerUsageCollector } from './usage';
|
||||
import { TASK_MANAGER_INDEX } from './constants';
|
||||
|
||||
export interface TaskManagerSetupContract {
|
||||
/**
|
||||
* @deprecated
|
||||
|
@ -59,7 +62,11 @@ export type TaskManagerStartContract = Pick<
|
|||
> &
|
||||
Pick<TaskStore, 'fetch' | 'aggregate' | 'get' | 'remove'> & {
|
||||
removeIfExists: TaskStore['remove'];
|
||||
} & { supportsEphemeralTasks: () => boolean };
|
||||
} & {
|
||||
bulkRemoveIfExist: (ids: string[]) => Promise<SavedObjectsBulkDeleteResponse | undefined>;
|
||||
} & {
|
||||
supportsEphemeralTasks: () => boolean;
|
||||
};
|
||||
|
||||
export class TaskManagerPlugin
|
||||
implements Plugin<TaskManagerSetupContract, TaskManagerStartContract>
|
||||
|
@ -248,6 +255,7 @@ export class TaskManagerPlugin
|
|||
taskStore.aggregate(opts),
|
||||
get: (id: string) => taskStore.get(id),
|
||||
remove: (id: string) => taskStore.remove(id),
|
||||
bulkRemoveIfExist: (ids: string[]) => bulkRemoveIfExist(taskStore, ids),
|
||||
removeIfExists: (id: string) => removeIfExists(taskStore, id),
|
||||
schedule: (...args) => taskScheduling.schedule(...args),
|
||||
bulkSchedule: (...args) => taskScheduling.bulkSchedule(...args),
|
||||
|
|
|
@ -20,6 +20,7 @@ export const taskStoreMock = {
|
|||
schedule: jest.fn(),
|
||||
bulkSchedule: jest.fn(),
|
||||
bulkUpdate: jest.fn(),
|
||||
bulkRemove: jest.fn(),
|
||||
get: jest.fn(),
|
||||
getLifecycle: jest.fn(),
|
||||
fetch: jest.fn(),
|
||||
|
|
|
@ -25,6 +25,8 @@ import { mockLogger } from './test_utils';
|
|||
const savedObjectsClient = savedObjectsRepositoryMock.create();
|
||||
const serializer = savedObjectsServiceMock.createSerializer();
|
||||
|
||||
const randomId = () => `id-${_.random(1, 20)}`;
|
||||
|
||||
beforeEach(() => jest.resetAllMocks());
|
||||
|
||||
const mockedDate = new Date('2019-02-12T21:01:22.479Z');
|
||||
|
@ -529,6 +531,41 @@ describe('TaskStore', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('bulkRemove', () => {
|
||||
let store: TaskStore;
|
||||
|
||||
const tasksIdsToDelete = [randomId(), randomId()];
|
||||
|
||||
beforeAll(() => {
|
||||
store = new TaskStore({
|
||||
index: 'tasky',
|
||||
taskManagerId: '',
|
||||
serializer,
|
||||
esClient: elasticsearchServiceMock.createClusterClient().asInternalUser,
|
||||
definitions: taskDefinitions,
|
||||
savedObjectsRepository: savedObjectsClient,
|
||||
});
|
||||
});
|
||||
|
||||
test('removes the tasks with the specified ids', async () => {
|
||||
const result = await store.bulkRemove(tasksIdsToDelete);
|
||||
expect(result).toBeUndefined();
|
||||
expect(savedObjectsClient.bulkDelete).toHaveBeenCalledWith([
|
||||
{ type: 'task', id: tasksIdsToDelete[0] },
|
||||
{ type: 'task', id: tasksIdsToDelete[1] },
|
||||
]);
|
||||
});
|
||||
|
||||
test('pushes error from saved objects client to errors$', async () => {
|
||||
const firstErrorPromise = store.errors$.pipe(first()).toPromise();
|
||||
savedObjectsClient.bulkDelete.mockRejectedValue(new Error('Failure'));
|
||||
await expect(store.bulkRemove(tasksIdsToDelete)).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"Failure"`
|
||||
);
|
||||
expect(await firstErrorPromise).toMatchInlineSnapshot(`[Error: Failure]`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('get', () => {
|
||||
let store: TaskStore;
|
||||
|
||||
|
@ -802,5 +839,3 @@ describe('TaskStore', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
const randomId = () => `id-${_.random(1, 20)}`;
|
||||
|
|
|
@ -12,6 +12,7 @@ import { Subject } from 'rxjs';
|
|||
import { omit, defaults } from 'lodash';
|
||||
|
||||
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
|
||||
import type { SavedObjectsBulkDeleteResponse } from '@kbn/core/server';
|
||||
|
||||
import {
|
||||
SavedObject,
|
||||
|
@ -294,6 +295,22 @@ export class TaskStore {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk removes the specified tasks from the index.
|
||||
*
|
||||
* @param {SavedObjectsBulkDeleteObject[]} savedObjectsToDelete
|
||||
* @returns {Promise<SavedObjectsBulkDeleteResponse>}
|
||||
*/
|
||||
public async bulkRemove(taskIds: string[]): Promise<SavedObjectsBulkDeleteResponse> {
|
||||
try {
|
||||
const savedObjectsToDelete = taskIds.map((taskId) => ({ id: taskId, type: 'task' }));
|
||||
return await this.savedObjectsRepository.bulkDelete(savedObjectsToDelete);
|
||||
} catch (e) {
|
||||
this.errors$.next(e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a task by id
|
||||
*
|
||||
|
|
|
@ -0,0 +1,563 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import expect from '@kbn/expect';
|
||||
import { UserAtSpaceScenarios, SuperuserAtSpace1 } from '../../../scenarios';
|
||||
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
|
||||
import { getUrlPrefix, getTestRuleData, ObjectRemover } from '../../../../common/lib';
|
||||
|
||||
const defaultSuccessfulResponse = { errors: [], total: 1, taskIdsFailedToBeDeleted: [] };
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default ({ getService }: FtrProviderContext) => {
|
||||
const supertest = getService('supertest');
|
||||
const es = getService('es');
|
||||
const supertestWithoutAuth = getService('supertestWithoutAuth');
|
||||
|
||||
describe('bulkDelete', () => {
|
||||
const objectRemover = new ObjectRemover(supertest);
|
||||
|
||||
after(() => objectRemover.removeAll());
|
||||
|
||||
const getScheduledTask = async (id: string) => {
|
||||
return await es.get({
|
||||
id: `task:${id}`,
|
||||
index: '.kibana_task_manager',
|
||||
});
|
||||
};
|
||||
|
||||
for (const scenario of UserAtSpaceScenarios) {
|
||||
const { user, space } = scenario;
|
||||
|
||||
describe(scenario.id, () => {
|
||||
afterEach(() => objectRemover.removeAll());
|
||||
it('should handle bulk delete of one rule appropriately based on id', async () => {
|
||||
const { body: createdRule1 } = await supertest
|
||||
.post(`${getUrlPrefix(space.id)}/api/alerting/rule`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send(getTestRuleData())
|
||||
.expect(200);
|
||||
|
||||
const response = await supertestWithoutAuth
|
||||
.patch(`${getUrlPrefix(space.id)}/internal/alerting/rules/_bulk_delete`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({ ids: [createdRule1.id] })
|
||||
.auth(user.username, user.password);
|
||||
|
||||
switch (scenario.id) {
|
||||
case 'no_kibana_privileges at space1':
|
||||
case 'space_1_all at space2':
|
||||
expect(response.body).to.eql({
|
||||
error: 'Forbidden',
|
||||
message: 'Unauthorized to find rules for any rule types',
|
||||
statusCode: 403,
|
||||
});
|
||||
expect(response.statusCode).to.eql(403);
|
||||
objectRemover.add(space.id, createdRule1.id, 'rule', 'alerting');
|
||||
// Ensure task still exists
|
||||
await getScheduledTask(createdRule1.scheduled_task_id);
|
||||
break;
|
||||
case 'global_read at space1':
|
||||
expect(response.body).to.eql({
|
||||
error: 'Forbidden',
|
||||
message: 'Unauthorized to bulkDelete a "test.noop" rule for "alertsFixture"',
|
||||
statusCode: 403,
|
||||
});
|
||||
expect(response.statusCode).to.eql(403);
|
||||
objectRemover.add(space.id, createdRule1.id, 'rule', 'alerting');
|
||||
// Ensure task still exists
|
||||
await getScheduledTask(createdRule1.scheduled_task_id);
|
||||
break;
|
||||
case 'space_1_all_alerts_none_actions at space1':
|
||||
case 'superuser at space1':
|
||||
case 'space_1_all at space1':
|
||||
case 'space_1_all_with_restricted_fixture at space1':
|
||||
expect(response.body).to.eql(defaultSuccessfulResponse);
|
||||
expect(response.statusCode).to.eql(200);
|
||||
try {
|
||||
await getScheduledTask(createdRule1.scheduled_task_id);
|
||||
throw new Error('Should have removed scheduled task');
|
||||
} catch (e) {
|
||||
expect(e.meta.statusCode).to.eql(404);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle bulk delete of one rule appropriately based on id when consumer is the same as producer', async () => {
|
||||
const { body: createdRule1 } = await supertest
|
||||
.post(`${getUrlPrefix(space.id)}/api/alerting/rule`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send(
|
||||
getTestRuleData({
|
||||
rule_type_id: 'test.restricted-noop',
|
||||
consumer: 'alertsRestrictedFixture',
|
||||
})
|
||||
)
|
||||
.expect(200);
|
||||
|
||||
const response = await supertestWithoutAuth
|
||||
.patch(`${getUrlPrefix(space.id)}/internal/alerting/rules/_bulk_delete`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({ ids: [createdRule1.id] })
|
||||
.auth(user.username, user.password);
|
||||
|
||||
switch (scenario.id) {
|
||||
case 'no_kibana_privileges at space1':
|
||||
case 'space_1_all at space2':
|
||||
expect(response.body).to.eql({
|
||||
error: 'Forbidden',
|
||||
message: 'Unauthorized to find rules for any rule types',
|
||||
statusCode: 403,
|
||||
});
|
||||
expect(response.statusCode).to.eql(403);
|
||||
objectRemover.add(space.id, createdRule1.id, 'rule', 'alerting');
|
||||
// Ensure task still exists
|
||||
await getScheduledTask(createdRule1.scheduled_task_id);
|
||||
break;
|
||||
case 'global_read at space1':
|
||||
expect(response.body).to.eql({
|
||||
error: 'Forbidden',
|
||||
message:
|
||||
'Unauthorized to bulkDelete a "test.restricted-noop" rule for "alertsRestrictedFixture"',
|
||||
statusCode: 403,
|
||||
});
|
||||
expect(response.statusCode).to.eql(403);
|
||||
objectRemover.add(space.id, createdRule1.id, 'rule', 'alerting');
|
||||
// Ensure task still exists
|
||||
await getScheduledTask(createdRule1.scheduled_task_id);
|
||||
break;
|
||||
case 'space_1_all at space1':
|
||||
case 'space_1_all_alerts_none_actions at space1':
|
||||
expect(response.body).to.eql({
|
||||
statusCode: 400,
|
||||
error: 'Bad Request',
|
||||
message: 'No rules found for bulk delete',
|
||||
});
|
||||
expect(response.statusCode).to.eql(400);
|
||||
objectRemover.add(space.id, createdRule1.id, 'rule', 'alerting');
|
||||
// Ensure task still exists
|
||||
await getScheduledTask(createdRule1.scheduled_task_id);
|
||||
break;
|
||||
case 'superuser at space1':
|
||||
case 'space_1_all_with_restricted_fixture at space1':
|
||||
expect(response.body).to.eql(defaultSuccessfulResponse);
|
||||
expect(response.statusCode).to.eql(200);
|
||||
try {
|
||||
await getScheduledTask(createdRule1.scheduled_task_id);
|
||||
throw new Error('Should have removed scheduled task');
|
||||
} catch (e) {
|
||||
expect(e.meta.statusCode).to.eql(404);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle delete alert request appropriately when consumer is not the producer', async () => {
|
||||
const { body: createdRule1 } = await supertest
|
||||
.post(`${getUrlPrefix(space.id)}/api/alerting/rule`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send(
|
||||
getTestRuleData({
|
||||
rule_type_id: 'test.restricted-noop',
|
||||
consumer: 'alertsFixture',
|
||||
})
|
||||
)
|
||||
.expect(200);
|
||||
|
||||
const response = await supertestWithoutAuth
|
||||
.patch(`${getUrlPrefix(space.id)}/internal/alerting/rules/_bulk_delete`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({ ids: [createdRule1.id] })
|
||||
.auth(user.username, user.password);
|
||||
|
||||
switch (scenario.id) {
|
||||
case 'no_kibana_privileges at space1':
|
||||
case 'space_1_all at space2':
|
||||
expect(response.body).to.eql({
|
||||
error: 'Forbidden',
|
||||
message: 'Unauthorized to find rules for any rule types',
|
||||
statusCode: 403,
|
||||
});
|
||||
expect(response.statusCode).to.eql(403);
|
||||
objectRemover.add(space.id, createdRule1.id, 'rule', 'alerting');
|
||||
// Ensure task still exists
|
||||
await getScheduledTask(createdRule1.scheduled_task_id);
|
||||
break;
|
||||
case 'space_1_all at space1':
|
||||
case 'space_1_all_alerts_none_actions at space1':
|
||||
case 'space_1_all_with_restricted_fixture at space1':
|
||||
case 'global_read at space1':
|
||||
expect(response.body).to.eql({
|
||||
statusCode: 400,
|
||||
error: 'Bad Request',
|
||||
message: 'No rules found for bulk delete',
|
||||
});
|
||||
expect(response.statusCode).to.eql(400);
|
||||
objectRemover.add(space.id, createdRule1.id, 'rule', 'alerting');
|
||||
// Ensure task still exists
|
||||
await getScheduledTask(createdRule1.scheduled_task_id);
|
||||
break;
|
||||
case 'superuser at space1':
|
||||
expect(response.body).to.eql(defaultSuccessfulResponse);
|
||||
expect(response.statusCode).to.eql(200);
|
||||
try {
|
||||
await getScheduledTask(createdRule1.scheduled_task_id);
|
||||
throw new Error('Should have removed scheduled task');
|
||||
} catch (e) {
|
||||
expect(e.meta.statusCode).to.eql(404);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle delete alert request appropriately when consumer is "alerts"', async () => {
|
||||
const { body: createdRule1 } = await supertest
|
||||
.post(`${getUrlPrefix(space.id)}/api/alerting/rule`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send(
|
||||
getTestRuleData({
|
||||
rule_type_id: 'test.noop',
|
||||
consumer: 'alerts',
|
||||
})
|
||||
)
|
||||
.expect(200);
|
||||
|
||||
const response = await supertestWithoutAuth
|
||||
.patch(`${getUrlPrefix(space.id)}/internal/alerting/rules/_bulk_delete`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({ ids: [createdRule1.id] })
|
||||
.auth(user.username, user.password);
|
||||
|
||||
switch (scenario.id) {
|
||||
case 'no_kibana_privileges at space1':
|
||||
case 'space_1_all at space2':
|
||||
expect(response.body).to.eql({
|
||||
error: 'Forbidden',
|
||||
message: 'Unauthorized to find rules for any rule types',
|
||||
statusCode: 403,
|
||||
});
|
||||
expect(response.statusCode).to.eql(403);
|
||||
objectRemover.add(space.id, createdRule1.id, 'rule', 'alerting');
|
||||
// Ensure task still exists
|
||||
await getScheduledTask(createdRule1.scheduled_task_id);
|
||||
break;
|
||||
case 'global_read at space1':
|
||||
expect(response.body).to.eql({
|
||||
error: 'Forbidden',
|
||||
message: 'Unauthorized to bulkDelete a "test.noop" rule by "alertsFixture"',
|
||||
statusCode: 403,
|
||||
});
|
||||
expect(response.statusCode).to.eql(403);
|
||||
objectRemover.add(space.id, createdRule1.id, 'rule', 'alerting');
|
||||
// Ensure task still exists
|
||||
await getScheduledTask(createdRule1.scheduled_task_id);
|
||||
break;
|
||||
case 'superuser at space1':
|
||||
case 'space_1_all at space1':
|
||||
case 'space_1_all_alerts_none_actions at space1':
|
||||
case 'space_1_all_with_restricted_fixture at space1':
|
||||
expect(response.body).to.eql(defaultSuccessfulResponse);
|
||||
expect(response.statusCode).to.eql(200);
|
||||
try {
|
||||
await getScheduledTask(createdRule1.scheduled_task_id);
|
||||
throw new Error('Should have removed scheduled task');
|
||||
} catch (e) {
|
||||
expect(e.meta.statusCode).to.eql(404);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle bulk delete of several rules ids appropriately based on ids', async () => {
|
||||
const rules = await Promise.all(
|
||||
Array.from({ length: 3 }).map(() =>
|
||||
supertest
|
||||
.post(`${getUrlPrefix(space.id)}/api/alerting/rule`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send(getTestRuleData({ tags: ['multiple-rules-edit'] }))
|
||||
.expect(200)
|
||||
)
|
||||
);
|
||||
|
||||
const response = await supertestWithoutAuth
|
||||
.patch(`${getUrlPrefix(space.id)}/internal/alerting/rules/_bulk_delete`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({ ids: rules.map((rule) => rule.body.id) })
|
||||
.auth(user.username, user.password);
|
||||
|
||||
switch (scenario.id) {
|
||||
case 'no_kibana_privileges at space1':
|
||||
case 'space_1_all at space2':
|
||||
expect(response.body).to.eql({
|
||||
error: 'Forbidden',
|
||||
message: 'Unauthorized to find rules for any rule types',
|
||||
statusCode: 403,
|
||||
});
|
||||
expect(response.statusCode).to.eql(403);
|
||||
await Promise.all(
|
||||
rules.map((rule) => {
|
||||
objectRemover.add(space.id, rule.body.id, 'rule', 'alerting');
|
||||
return getScheduledTask(rule.body.scheduled_task_id);
|
||||
})
|
||||
);
|
||||
break;
|
||||
case 'global_read at space1':
|
||||
expect(response.body).to.eql({
|
||||
error: 'Forbidden',
|
||||
message: 'Unauthorized to bulkDelete a "test.noop" rule for "alertsFixture"',
|
||||
statusCode: 403,
|
||||
});
|
||||
expect(response.statusCode).to.eql(403);
|
||||
await Promise.all(
|
||||
rules.map((rule) => {
|
||||
objectRemover.add(space.id, rule.body.id, 'rule', 'alerting');
|
||||
return getScheduledTask(rule.body.scheduled_task_id);
|
||||
})
|
||||
);
|
||||
break;
|
||||
case 'space_1_all_alerts_none_actions at space1':
|
||||
case 'superuser at space1':
|
||||
case 'space_1_all at space1':
|
||||
case 'space_1_all_with_restricted_fixture at space1':
|
||||
expect(response.body).to.eql({ ...defaultSuccessfulResponse, total: 3 });
|
||||
expect(response.statusCode).to.eql(200);
|
||||
for (const rule of rules) {
|
||||
try {
|
||||
await getScheduledTask(rule.body.scheduled_task_id);
|
||||
throw new Error('Should have removed scheduled task');
|
||||
} catch (e) {
|
||||
expect(e.meta.statusCode).to.eql(404);
|
||||
}
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle bulk delete of several rules ids appropriately based on filter', async () => {
|
||||
const rules = await Promise.all(
|
||||
Array.from({ length: 3 }).map(() =>
|
||||
supertest
|
||||
.post(`${getUrlPrefix(space.id)}/api/alerting/rule`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send(getTestRuleData({ tags: ['multiple-rules-delete'] }))
|
||||
.expect(200)
|
||||
)
|
||||
);
|
||||
|
||||
const response = await supertestWithoutAuth
|
||||
.patch(`${getUrlPrefix(space.id)}/internal/alerting/rules/_bulk_delete`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({ filter: `alert.attributes.tags: "multiple-rules-delete"` })
|
||||
.auth(user.username, user.password);
|
||||
|
||||
switch (scenario.id) {
|
||||
case 'no_kibana_privileges at space1':
|
||||
case 'space_1_all at space2':
|
||||
expect(response.body).to.eql({
|
||||
error: 'Forbidden',
|
||||
message: 'Unauthorized to find rules for any rule types',
|
||||
statusCode: 403,
|
||||
});
|
||||
expect(response.statusCode).to.eql(403);
|
||||
await Promise.all(
|
||||
rules.map((rule) => {
|
||||
objectRemover.add(space.id, rule.body.id, 'rule', 'alerting');
|
||||
return getScheduledTask(rule.body.scheduled_task_id);
|
||||
})
|
||||
);
|
||||
break;
|
||||
case 'global_read at space1':
|
||||
expect(response.body).to.eql({
|
||||
error: 'Forbidden',
|
||||
message: 'Unauthorized to bulkDelete a "test.noop" rule for "alertsFixture"',
|
||||
statusCode: 403,
|
||||
});
|
||||
expect(response.statusCode).to.eql(403);
|
||||
await Promise.all(
|
||||
rules.map((rule) => {
|
||||
objectRemover.add(space.id, rule.body.id, 'rule', 'alerting');
|
||||
return getScheduledTask(rule.body.scheduled_task_id);
|
||||
})
|
||||
);
|
||||
break;
|
||||
case 'space_1_all_alerts_none_actions at space1':
|
||||
case 'superuser at space1':
|
||||
case 'space_1_all at space1':
|
||||
case 'space_1_all_with_restricted_fixture at space1':
|
||||
expect(response.body).to.eql({ ...defaultSuccessfulResponse, total: 3 });
|
||||
expect(response.statusCode).to.eql(200);
|
||||
for (const rule of rules) {
|
||||
try {
|
||||
await getScheduledTask(rule.body.scheduled_task_id);
|
||||
throw new Error('Should have removed scheduled task');
|
||||
} catch (e) {
|
||||
expect(e.meta.statusCode).to.eql(404);
|
||||
}
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('should not delete rule from another space', async () => {
|
||||
const { body: createdRule } = await supertest
|
||||
.post(`${getUrlPrefix('other')}/api/alerting/rule`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send(getTestRuleData())
|
||||
.expect(200);
|
||||
|
||||
const response = await supertestWithoutAuth
|
||||
.patch(`${getUrlPrefix('other')}/internal/alerting/rules/_bulk_delete`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.auth(user.username, user.password)
|
||||
.send({ ids: [createdRule.id] });
|
||||
|
||||
switch (scenario.id) {
|
||||
// This superuser has more privileges that we think
|
||||
case 'superuser at space1':
|
||||
expect(response.body).to.eql(defaultSuccessfulResponse);
|
||||
expect(response.statusCode).to.eql(200);
|
||||
try {
|
||||
await getScheduledTask(createdRule.scheduled_task_id);
|
||||
throw new Error('Should have removed scheduled task');
|
||||
} catch (e) {
|
||||
expect(e.meta.statusCode).to.eql(404);
|
||||
}
|
||||
break;
|
||||
case 'global_read at space1':
|
||||
expect(response.body).to.eql({
|
||||
error: 'Forbidden',
|
||||
message: 'Unauthorized to bulkDelete a "test.noop" rule for "alertsFixture"',
|
||||
statusCode: 403,
|
||||
});
|
||||
expect(response.statusCode).to.eql(403);
|
||||
objectRemover.add('other', createdRule.id, 'rule', 'alerting');
|
||||
await getScheduledTask(createdRule.scheduled_task_id);
|
||||
break;
|
||||
case 'no_kibana_privileges at space1':
|
||||
case 'space_1_all at space2':
|
||||
case 'space_1_all at space1':
|
||||
case 'space_1_all_alerts_none_actions at space1':
|
||||
case 'space_1_all_with_restricted_fixture at space1':
|
||||
expect(response.body).to.eql({
|
||||
error: 'Forbidden',
|
||||
message: 'Unauthorized to find rules for any rule types',
|
||||
statusCode: 403,
|
||||
});
|
||||
expect(response.statusCode).to.eql(403);
|
||||
expect(response.statusCode).to.eql(403);
|
||||
objectRemover.add('other', createdRule.id, 'rule', 'alerting');
|
||||
await getScheduledTask(createdRule.scheduled_task_id);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe('Validation tests', () => {
|
||||
const { user, space } = SuperuserAtSpace1;
|
||||
it('should throw an error when bulk delete of rules when both ids and filter supplied in payload', async () => {
|
||||
const { body: createdRule1 } = await supertest
|
||||
.post(`${getUrlPrefix(space.id)}/api/alerting/rule`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send(getTestRuleData({ tags: ['foo'] }))
|
||||
.expect(200);
|
||||
|
||||
const response = await supertestWithoutAuth
|
||||
.patch(`${getUrlPrefix(space.id)}/internal/alerting/rules/_bulk_delete`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({ filter: 'fake_filter', ids: [createdRule1.id] })
|
||||
.auth(user.username, user.password);
|
||||
|
||||
expect(response.statusCode).to.eql(400);
|
||||
expect(response.body.message).to.eql(
|
||||
"Both 'filter' and 'ids' are supplied. Define either 'ids' or 'filter' properties in method's arguments"
|
||||
);
|
||||
objectRemover.add(space.id, createdRule1.id, 'rule', 'alerting');
|
||||
await getScheduledTask(createdRule1.scheduled_task_id);
|
||||
});
|
||||
|
||||
it('should return an error if we pass more than 1000 ids', async () => {
|
||||
const ids = [...Array(1001)].map((_, i) => `rule${i}`);
|
||||
|
||||
const response = await supertestWithoutAuth
|
||||
.patch(`${getUrlPrefix(space.id)}/internal/alerting/rules/_bulk_delete`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({ ids })
|
||||
.auth(user.username, user.password);
|
||||
|
||||
expect(response.body).to.eql({
|
||||
error: 'Bad Request',
|
||||
message: '[request body.ids]: array size is [1001], but cannot be greater than [1000]',
|
||||
statusCode: 400,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return an error if we do not pass any arguments', async () => {
|
||||
await supertest
|
||||
.post(`${getUrlPrefix(space.id)}/api/alerting/rule`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send(getTestRuleData())
|
||||
.expect(200);
|
||||
|
||||
const response = await supertestWithoutAuth
|
||||
.patch(`${getUrlPrefix(space.id)}/internal/alerting/rules/_bulk_delete`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({})
|
||||
.auth(user.username, user.password);
|
||||
|
||||
expect(response.body).to.eql({
|
||||
error: 'Bad Request',
|
||||
message: "Either 'ids' or 'filter' property in method's arguments should be provided",
|
||||
statusCode: 400,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return an error if we pass empty ids array', async () => {
|
||||
const response = await supertestWithoutAuth
|
||||
.patch(`${getUrlPrefix(space.id)}/internal/alerting/rules/_bulk_delete`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({ ids: [] })
|
||||
.auth(user.username, user.password);
|
||||
|
||||
expect(response.body).to.eql({
|
||||
error: 'Bad Request',
|
||||
message: '[request body.ids]: array size is [0], but cannot be smaller than [1]',
|
||||
statusCode: 400,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return an error if we pass empty string instead of fiter', async () => {
|
||||
const response = await supertestWithoutAuth
|
||||
.patch(`${getUrlPrefix(space.id)}/internal/alerting/rules/_bulk_delete`)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({ filter: '' })
|
||||
.auth(user.username, user.password);
|
||||
|
||||
expect(response.body).to.eql({
|
||||
error: 'Bad Request',
|
||||
message: "Either 'ids' or 'filter' property in method's arguments should be provided",
|
||||
statusCode: 400,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
|
@ -31,6 +31,7 @@ export default function alertingTests({ loadTestFile, getService }: FtrProviderC
|
|||
loadTestFile(require.resolve('./get_alert_summary'));
|
||||
loadTestFile(require.resolve('./rule_types'));
|
||||
loadTestFile(require.resolve('./bulk_edit'));
|
||||
loadTestFile(require.resolve('./bulk_delete'));
|
||||
loadTestFile(require.resolve('./retain_api_key'));
|
||||
});
|
||||
});
|
||||
|
|
|
@ -196,7 +196,7 @@ const NoKibanaPrivilegesAtSpace1: NoKibanaPrivilegesAtSpace1 = {
|
|||
interface SuperuserAtSpace1 extends Scenario {
|
||||
id: 'superuser at space1';
|
||||
}
|
||||
const SuperuserAtSpace1: SuperuserAtSpace1 = {
|
||||
export const SuperuserAtSpace1: SuperuserAtSpace1 = {
|
||||
id: 'superuser at space1',
|
||||
user: Superuser,
|
||||
space: Space1,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue