[saved objects] Adds bulkDelete API (#139680)

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Christiane (Tina) Heiligers 2022-09-20 07:51:25 -07:00 committed by GitHub
parent d29521e897
commit 92ca42f007
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
50 changed files with 2200 additions and 2 deletions

View file

@ -22,4 +22,7 @@ export type {
SavedObjectsBulkUpdateOptions,
SavedObjectsBulkResolveResponse,
SavedObjectsBulkCreateObject,
SavedObjectsBulkDeleteOptions,
SavedObjectsBulkDeleteResponseItem,
SavedObjectsBulkDeleteResponse,
} from './src/apis';

View file

@ -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
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { SavedObjectError } from '@kbn/core-saved-objects-common';
/** @public */
export interface SavedObjectsBulkDeleteOptions {
force?: boolean;
}
/** @public */
export interface SavedObjectsBulkDeleteResponseItem {
id: string;
type: string;
success: boolean;
error?: SavedObjectError;
}
/** @public */
export interface SavedObjectsBulkDeleteResponse {
statuses: SavedObjectsBulkDeleteResponseItem[];
}

View file

@ -19,3 +19,8 @@ export type {
} from './find';
export type { ResolvedSimpleSavedObject } from './resolve';
export type { SavedObjectsUpdateOptions } from './update';
export type {
SavedObjectsBulkDeleteOptions,
SavedObjectsBulkDeleteResponseItem,
SavedObjectsBulkDeleteResponse,
} from './bulk_delete';

View file

@ -19,7 +19,10 @@ import type {
SavedObjectsFindOptions,
SavedObjectsUpdateOptions,
SavedObjectsDeleteOptions,
SavedObjectsBulkDeleteResponse,
SavedObjectsBulkDeleteOptions,
} from './apis';
import type { SimpleSavedObject } from './simple_saved_object';
/**
@ -52,6 +55,17 @@ export interface SavedObjectsClientContract {
*/
delete(type: string, id: string, options?: SavedObjectsDeleteOptions): Promise<{}>;
/**
* Deletes multiple documents at once
* @param objects - an array of objects containing id, type
* @param options - optional force argument to force deletion of objects in a namespace other than the scoped client
* @returns The bulk delete result for the saved objects for the given types and ids.
*/
bulkDelete(
objects: SavedObjectTypeIdTuple[],
options?: SavedObjectsBulkDeleteOptions
): Promise<SavedObjectsBulkDeleteResponse>;
/**
* Search for objects
*

View file

@ -46,6 +46,8 @@ import type {
SavedObjectsCollectMultiNamespaceReferencesResponse,
SavedObjectsUpdateObjectsSpacesObject,
SavedObjectsUpdateObjectsSpacesOptions,
SavedObjectsBulkDeleteObject,
SavedObjectsBulkDeleteOptions,
} from '@kbn/core-saved-objects-api-server';
import type {
SavedObjectsType,
@ -2044,6 +2046,517 @@ describe('SavedObjectsRepository', () => {
});
});
describe('#bulkDelete', () => {
const obj1: SavedObjectsBulkDeleteObject = {
type: 'config',
id: '6.0.0-alpha1',
};
const obj2: SavedObjectsBulkDeleteObject = {
type: 'index-pattern',
id: 'logstash-*',
};
const namespace = 'foo-namespace';
const createNamespaceAwareGetId = (type: string, id: string) =>
`${registry.isSingleNamespace(type) && namespace ? `${namespace}:` : ''}${type}:${id}`;
const getMockEsBulkDeleteResponse = (
objects: TypeIdTuple[],
options?: SavedObjectsBulkDeleteOptions
) =>
({
items: objects.map(({ type, id }) => ({
// es response returns more fields than what we're interested in.
delete: {
_id: `${
registry.isSingleNamespace(type) && options?.namespace ? `${options?.namespace}:` : ''
}${type}:${id}`,
...mockVersionProps,
result: 'deleted',
},
})),
} as estypes.BulkResponse);
const repositoryBulkDeleteSuccess = async (
objects: SavedObjectsBulkDeleteObject[] = [],
options?: SavedObjectsBulkDeleteOptions,
internalOptions: {
mockMGetResponseWithObject?: { initialNamespaces: string[]; type: string; id: string };
} = {}
) => {
const multiNamespaceObjects = objects.filter(({ type }) => {
return registry.isMultiNamespace(type);
});
const { mockMGetResponseWithObject } = internalOptions;
if (multiNamespaceObjects.length > 0) {
const mockedMGetResponse = mockMGetResponseWithObject
? getMockMgetResponse([mockMGetResponseWithObject], options?.namespace)
: getMockMgetResponse(multiNamespaceObjects, options?.namespace);
client.mget.mockResponseOnce(mockedMGetResponse);
}
const mockedEsBulkDeleteResponse = getMockEsBulkDeleteResponse(objects, options);
client.bulk.mockResponseOnce(mockedEsBulkDeleteResponse);
const result = await savedObjectsRepository.bulkDelete(objects, options);
expect(client.mget).toHaveBeenCalledTimes(multiNamespaceObjects?.length ? 1 : 0);
return result;
};
// bulk delete calls only has one object for each source -- the action
const expectClientCallBulkDeleteArgsAction = (
objects: TypeIdTuple[],
{
method,
_index = expect.any(String),
getId = () => expect.any(String),
overrides = {},
}: {
method: string;
_index?: string;
getId?: (type: string, id: string) => string;
overrides?: Record<string, unknown>;
}
) => {
const body = [];
for (const { type, id } of objects) {
body.push({
[method]: {
_index,
_id: getId(type, id),
...overrides,
},
});
}
expect(client.bulk).toHaveBeenCalledWith(
expect.objectContaining({ body }),
expect.anything()
);
};
const createBulkDeleteFailStatus = ({
type,
id,
error,
}: {
type: string;
id: string;
error?: ExpectedErrorResult['error'];
}) => ({
type,
id,
success: false,
error: error ?? createBadRequestError(),
});
const createBulkDeleteSuccessStatus = ({ type, id }: { type: string; id: string }) => ({
type,
id,
success: true,
});
// mocks a combination of success, error results for hidden and unknown object object types.
const repositoryBulkDeleteError = async (
obj: SavedObjectsBulkDeleteObject,
isBulkError: boolean,
expectedErrorResult: ExpectedErrorResult
) => {
const objects = [obj1, obj, obj2];
const mockedBulkDeleteResponse = getMockEsBulkDeleteResponse(objects);
if (isBulkError) {
mockGetBulkOperationError.mockReturnValueOnce(undefined);
mockGetBulkOperationError.mockReturnValueOnce(expectedErrorResult.error as Payload);
}
client.bulk.mockResponseOnce(mockedBulkDeleteResponse);
const result = await savedObjectsRepository.bulkDelete(objects);
expect(client.bulk).toHaveBeenCalled();
expect(result).toEqual({
statuses: [
createBulkDeleteSuccessStatus(obj1),
createBulkDeleteFailStatus({ ...obj, error: expectedErrorResult.error }),
createBulkDeleteSuccessStatus(obj2),
],
});
};
const expectClientCallArgsAction = (
objects: TypeIdTuple[],
{
method,
_index = expect.any(String),
getId = () => expect.any(String),
overrides = {},
}: {
method: string;
_index?: string;
getId?: (type: string, id: string) => string;
overrides?: Record<string, unknown>;
}
) => {
const body = [];
for (const { type, id } of objects) {
body.push({
[method]: {
_index,
_id: getId(type, id),
...overrides,
},
});
}
expect(client.bulk).toHaveBeenCalledWith(
expect.objectContaining({ body }),
expect.anything()
);
};
const bulkDeleteMultiNamespaceError = async (
[obj1, _obj, obj2]: SavedObjectsBulkDeleteObject[],
options: SavedObjectsBulkDeleteOptions | undefined,
mgetResponse: estypes.MgetResponse,
mgetOptions?: { statusCode?: number }
) => {
const getId = (type: string, id: string) => `${options?.namespace}:${type}:${id}`;
// mock the response for the not found doc
client.mget.mockResponseOnce(mgetResponse, { statusCode: mgetOptions?.statusCode });
// get a mocked response for the valid docs
const bulkResponse = getMockEsBulkDeleteResponse([obj1, obj2], { namespace });
client.bulk.mockResponseOnce(bulkResponse);
const result = await savedObjectsRepository.bulkDelete([obj1, _obj, obj2], options);
expect(client.bulk).toHaveBeenCalledTimes(1);
expect(client.mget).toHaveBeenCalledTimes(1);
expectClientCallArgsAction([obj1, obj2], { method: 'delete', getId });
expect(result).toEqual({
statuses: [
createBulkDeleteSuccessStatus(obj1),
{ ...expectErrorNotFound(_obj), success: false },
createBulkDeleteSuccessStatus(obj2),
],
});
};
beforeEach(() => {
mockDeleteLegacyUrlAliases.mockClear();
mockDeleteLegacyUrlAliases.mockResolvedValue();
});
describe('client calls', () => {
it(`should use the ES bulk action by default`, async () => {
await repositoryBulkDeleteSuccess([obj1, obj2]);
expect(client.bulk).toHaveBeenCalled();
});
it(`should use the ES mget action before bulk action for any types that are multi-namespace`, async () => {
const objects = [obj1, { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }];
await repositoryBulkDeleteSuccess(objects);
expect(client.bulk).toHaveBeenCalled();
expect(client.mget).toHaveBeenCalled();
const docs = [
expect.objectContaining({ _id: `${MULTI_NAMESPACE_ISOLATED_TYPE}:${obj2.id}` }),
];
expect(client.mget).toHaveBeenCalledWith(
expect.objectContaining({ body: { docs } }),
expect.anything()
);
});
it(`should not use the ES bulk action when there are no valid documents to delete`, async () => {
const objects = [obj1, obj2].map((x) => ({ ...x, type: 'unknownType' }));
await savedObjectsRepository.bulkDelete(objects);
expect(client.bulk).toHaveBeenCalledTimes(0);
});
it(`formats the ES request`, async () => {
const getId = createNamespaceAwareGetId;
await repositoryBulkDeleteSuccess([obj1, obj2], { namespace });
expectClientCallBulkDeleteArgsAction([obj1, obj2], { method: 'delete', getId });
});
it(`formats the ES request for any types that are multi-namespace`, async () => {
const _obj2 = { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE };
const getId = createNamespaceAwareGetId;
await repositoryBulkDeleteSuccess([obj1, _obj2], { namespace });
expectClientCallBulkDeleteArgsAction([obj1, _obj2], { method: 'delete', getId });
});
it(`defaults to a refresh setting of wait_for`, async () => {
await repositoryBulkDeleteSuccess([obj1, obj2]);
expect(client.bulk).toHaveBeenCalledWith(
expect.objectContaining({ refresh: 'wait_for' }),
expect.anything()
);
});
it(`does not include the version of the existing document when not using a multi-namespace type`, async () => {
const objects = [obj1, { ...obj2, type: NAMESPACE_AGNOSTIC_TYPE }];
await repositoryBulkDeleteSuccess(objects);
expectClientCallBulkDeleteArgsAction(objects, { method: 'delete' });
});
it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => {
const getId = createNamespaceAwareGetId;
await repositoryBulkDeleteSuccess([obj1, obj2], { namespace });
expectClientCallBulkDeleteArgsAction([obj1, obj2], { method: 'delete', getId });
});
it(`doesn't prepend namespace to the id when providing no namespace for single-namespace type`, async () => {
const getId = (type: string, id: string) => `${type}:${id}`;
await repositoryBulkDeleteSuccess([obj1, obj2]);
expectClientCallBulkDeleteArgsAction([obj1, obj2], { method: 'delete', getId });
});
it(`normalizes options.namespace from 'default' to undefined`, async () => {
const getId = (type: string, id: string) => `${type}:${id}`;
await repositoryBulkDeleteSuccess([obj1, obj2], { namespace: 'default' });
expectClientCallBulkDeleteArgsAction([obj1, obj2], { method: 'delete', getId });
});
it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => {
const getId = (type: string, id: string) => `${type}:${id}`; // not expecting namespace prefix;
const _obj1 = { ...obj1, type: NAMESPACE_AGNOSTIC_TYPE };
const _obj2 = { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE };
await repositoryBulkDeleteSuccess([_obj1, _obj2], { namespace });
expectClientCallBulkDeleteArgsAction([_obj1, _obj2], { method: 'delete', getId });
});
});
describe('legacy URL aliases', () => {
it(`doesn't delete legacy URL aliases for single-namespace object types`, async () => {
await repositoryBulkDeleteSuccess([obj1, obj2]);
expect(mockDeleteLegacyUrlAliases).not.toHaveBeenCalled();
});
it(`deletes legacy URL aliases for multi-namespace object types (all spaces)`, async () => {
const testObject = { ...obj1, type: MULTI_NAMESPACE_TYPE };
const internalOptions = {
mockMGetResponseWithObject: {
...testObject,
initialNamespaces: [ALL_NAMESPACES_STRING],
},
};
await repositoryBulkDeleteSuccess(
[testObject],
{ namespace, force: true },
internalOptions
);
expect(mockDeleteLegacyUrlAliases).toHaveBeenCalledWith(
expect.objectContaining({
type: MULTI_NAMESPACE_TYPE,
id: testObject.id,
namespaces: [],
deleteBehavior: 'exclusive',
})
);
});
it(`deletes legacy URL aliases for multi-namespace object types (specific space)`, async () => {
const testObject = { ...obj1, type: MULTI_NAMESPACE_TYPE };
const internalOptions = {
mockMGetResponseWithObject: {
...testObject,
initialNamespaces: [namespace],
},
};
// specifically test against the current namespace
await repositoryBulkDeleteSuccess([testObject], { namespace }, internalOptions);
expect(mockDeleteLegacyUrlAliases).toHaveBeenCalledWith(
expect.objectContaining({
type: MULTI_NAMESPACE_TYPE,
id: testObject.id,
namespaces: [namespace],
deleteBehavior: 'inclusive',
})
);
});
it(`deletes legacy URL aliases for multi-namespace object types shared to many specific spaces`, async () => {
const testObject = { ...obj1, type: MULTI_NAMESPACE_TYPE };
const initialTestObjectNamespaces = [namespace, 'bar-namespace'];
const internalOptions = {
mockMGetResponseWithObject: {
...testObject,
initialNamespaces: initialTestObjectNamespaces,
},
};
// specifically test against named spaces ('*' is handled specifically, this assures we also take care of named spaces)
await repositoryBulkDeleteSuccess(
[testObject],
{ namespace, force: true },
internalOptions
);
expect(mockDeleteLegacyUrlAliases).toHaveBeenCalledWith(
expect.objectContaining({
type: MULTI_NAMESPACE_TYPE,
id: testObject.id,
namespaces: initialTestObjectNamespaces,
deleteBehavior: 'inclusive',
})
);
});
it(`logs a message when deleteLegacyUrlAliases returns an error`, async () => {
const testObject = { type: MULTI_NAMESPACE_ISOLATED_TYPE, id: obj1.id };
client.mget.mockResolvedValueOnce(
elasticsearchClientMock.createSuccessTransportRequestPromise(
getMockMgetResponse([testObject], namespace)
)
);
const mockedBulkResponse = getMockEsBulkDeleteResponse([testObject], { namespace });
client.bulk.mockResolvedValueOnce(mockedBulkResponse);
mockDeleteLegacyUrlAliases.mockRejectedValueOnce(new Error('Oh no!'));
await savedObjectsRepository.bulkDelete([testObject], { namespace });
expect(client.mget).toHaveBeenCalledTimes(1);
expect(logger.error).toHaveBeenCalledTimes(1);
expect(logger.error).toHaveBeenCalledWith(
'Unable to delete aliases when deleting an object: Oh no!'
);
});
});
describe('errors', () => {
it(`throws an error when options.namespace is '*'`, async () => {
await expect(
savedObjectsRepository.bulkDelete([obj1], { namespace: ALL_NAMESPACES_STRING })
).rejects.toThrowError(createBadRequestError('"options.namespace" cannot be "*"'));
});
it(`throws an error when client bulk response is not defined`, async () => {
client.mget.mockResolvedValueOnce(
elasticsearchClientMock.createSuccessTransportRequestPromise(
getMockMgetResponse([obj1], namespace)
)
);
const mockedBulkResponse = undefined;
// we have to cast here to test the assumption we always get a response.
client.bulk.mockResponseOnce(mockedBulkResponse as unknown as estypes.BulkResponse);
await expect(savedObjectsRepository.bulkDelete([obj1], { namespace })).rejects.toThrowError(
'Unexpected error in bulkDelete saved objects: bulkDeleteResponse is undefined'
);
});
it(`returns an error for the object when the object's type is invalid`, async () => {
const unknownObjType = { ...obj1, type: 'unknownType' };
await repositoryBulkDeleteError(
unknownObjType,
false,
expectErrorInvalidType(unknownObjType)
);
});
it(`returns an error for an object when the object's type is hidden`, async () => {
const hiddenObject = { ...obj1, type: HIDDEN_TYPE };
await repositoryBulkDeleteError(hiddenObject, false, expectErrorInvalidType(hiddenObject));
});
it(`returns an error when ES is unable to find the document during mget`, async () => {
const notFoundObj = { ...obj1, type: MULTI_NAMESPACE_ISOLATED_TYPE, found: false };
const mgetResponse = getMockMgetResponse([notFoundObj], namespace);
await bulkDeleteMultiNamespaceError([obj1, notFoundObj, obj2], { namespace }, mgetResponse);
});
it(`returns an error when ES is unable to find the index during mget`, async () => {
const notFoundObj = { ...obj1, type: MULTI_NAMESPACE_ISOLATED_TYPE, found: false };
await bulkDeleteMultiNamespaceError(
[obj1, notFoundObj, obj2],
{ namespace },
{} as estypes.MgetResponse,
{
statusCode: 404,
}
);
});
it(`returns an error when the type is multi-namespace and the document exists, but not in this namespace`, async () => {
const obj = {
type: MULTI_NAMESPACE_ISOLATED_TYPE,
id: 'three',
namespace: 'bar-namespace',
};
const mgetResponse = getMockMgetResponse([obj], namespace);
await bulkDeleteMultiNamespaceError([obj1, obj, obj2], { namespace }, mgetResponse);
});
it(`returns an error when the type is multi-namespace and the document has multiple namespaces and the force option is not enabled`, async () => {
const testObject = { ...obj1, type: MULTI_NAMESPACE_TYPE };
const internalOptions = {
mockMGetResponseWithObject: {
...testObject,
initialNamespaces: [namespace, 'bar-namespace'],
},
};
const result = await repositoryBulkDeleteSuccess(
[testObject],
{ namespace },
internalOptions
);
expect(result.statuses[0]).toStrictEqual(
createBulkDeleteFailStatus({
...testObject,
error: createBadRequestError(
'Unable to delete saved object that exists in multiple namespaces, use the "force" option to delete it anyway'
),
})
);
});
it(`returns an error when the type is multi-namespace and the document has all namespaces and the force option is not enabled`, async () => {
const testObject = { ...obj1, type: ALL_NAMESPACES_STRING };
const internalOptions = {
mockMGetResponseWithObject: {
...testObject,
initialNamespaces: [namespace, 'bar-namespace'],
},
};
const result = await repositoryBulkDeleteSuccess(
[testObject],
{ namespace },
internalOptions
);
expect(result.statuses[0]).toStrictEqual(
createBulkDeleteFailStatus({
...testObject,
error: createBadRequestError("Unsupported saved object type: '*'"),
})
);
});
});
describe('returns', () => {
it(`returns early for empty objects argument`, async () => {
await savedObjectsRepository.bulkDelete([], { namespace });
expect(client.bulk).toHaveBeenCalledTimes(0);
});
it(`formats the ES response`, async () => {
const response = await repositoryBulkDeleteSuccess([obj1, obj2], { namespace });
expect(response).toEqual({
statuses: [obj1, obj2].map(createBulkDeleteSuccessStatus),
});
});
it(`handles a mix of successful deletes and errors`, async () => {
const notFoundObj = { ...obj1, type: MULTI_NAMESPACE_ISOLATED_TYPE, found: false };
await bulkDeleteMultiNamespaceError(
[obj1, notFoundObj, obj2],
{ namespace },
{} as estypes.MgetResponse,
{ statusCode: 404 }
);
});
});
});
describe('#checkConflicts', () => {
const obj1 = { type: 'dashboard', id: 'one' };
const obj2 = { type: 'dashboard', id: 'two' };

View file

@ -54,6 +54,9 @@ import type {
SavedObjectsClosePointInTimeOptions,
SavedObjectsCreatePointInTimeFinderOptions,
SavedObjectsFindOptions,
SavedObjectsBulkDeleteObject,
SavedObjectsBulkDeleteOptions,
SavedObjectsBulkDeleteResponse,
} from '@kbn/core-saved-objects-api-server';
import type {
SavedObjectSanitizedDoc,
@ -83,6 +86,7 @@ import {
type IndexMapping,
type IKibanaMigrator,
} from '@kbn/core-saved-objects-base-server-internal';
import pMap from 'p-map';
import { PointInTimeFinder } from './point_in_time_finder';
import { createRepositoryEsClient, RepositoryEsClient } from './repository_es_client';
import { getSearchDsl } from './search_dsl';
@ -109,6 +113,16 @@ import {
PreflightCheckForCreateObject,
} from './preflight_check_for_create';
import { deleteLegacyUrlAliases } from './legacy_url_aliases';
import type {
BulkDeleteParams,
ExpectedBulkDeleteResult,
BulkDeleteItemErrorResult,
NewBulkItemResponse,
BulkDeleteExpectedBulkGetResult,
PreflightCheckForBulkDeleteParams,
ExpectedBulkDeleteMultiNamespaceDocsParams,
ObjectToDeleteAliasesFor,
} from './repository_bulk_delete_internal_types';
// BEWARE: The SavedObjectClient depends on the implementation details of the SavedObjectsRepository
// so any breaking changes to this repository are considered breaking changes to the SavedObjectsClient.
@ -127,6 +141,7 @@ export interface SavedObjectsRepositoryOptions {
export const DEFAULT_REFRESH_SETTING = 'wait_for';
export const DEFAULT_RETRY_COUNT = 3;
const MAX_CONCURRENT_ALIAS_DELETIONS = 10;
/**
* @internal
*/
@ -676,7 +691,6 @@ export class SavedObjectsRepository implements ISavedObjectsRepository {
if (!this._allowedTypes.includes(type)) {
throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id);
}
const { refresh = DEFAULT_REFRESH_SETTING, force } = options;
const namespace = normalizeNamespace(options.namespace);
@ -762,6 +776,286 @@ export class SavedObjectsRepository implements ISavedObjectsRepository {
);
}
/**
* Performs initial checks on object type validity and flags multi-namespace objects for preflight checks by adding an `esRequestIndex`
* @param objects SavedObjectsBulkDeleteObject[]
* @returns array BulkDeleteExpectedBulkGetResult[]
* @internal
*/
private presortObjectsByNamespaceType(objects: SavedObjectsBulkDeleteObject[]) {
let bulkGetRequestIndexCounter = 0;
return objects.map<BulkDeleteExpectedBulkGetResult>((object) => {
const { type, id } = object;
if (!this._allowedTypes.includes(type)) {
return {
tag: 'Left',
value: {
id,
type,
error: errorContent(SavedObjectsErrorHelpers.createUnsupportedTypeError(type)),
},
};
}
const requiresNamespacesCheck = this._registry.isMultiNamespace(type);
return {
tag: 'Right',
value: {
type,
id,
...(requiresNamespacesCheck && { esRequestIndex: bulkGetRequestIndexCounter++ }),
},
};
});
}
/**
* Fetch multi-namespace saved objects
* @returns MgetResponse
* @notes multi-namespace objects shared to more than one space require special handling. We fetch these docs to retrieve their namespaces.
* @internal
*/
private async preflightCheckForBulkDelete(params: PreflightCheckForBulkDeleteParams) {
const { expectedBulkGetResults, namespace } = params;
const bulkGetMultiNamespaceDocs = expectedBulkGetResults
.filter(isRight)
.filter(({ value }) => value.esRequestIndex !== undefined)
.map(({ value: { type, id } }) => ({
_id: this._serializer.generateRawId(namespace, type, id),
_index: this.getIndexForType(type),
_source: ['type', 'namespaces'],
}));
const bulkGetMultiNamespaceDocsResponse = bulkGetMultiNamespaceDocs.length
? await this.client.mget(
{ body: { docs: bulkGetMultiNamespaceDocs } },
{ ignore: [404], meta: true }
)
: undefined;
// fail fast if we can't verify a 404 response is from Elasticsearch
if (
bulkGetMultiNamespaceDocsResponse &&
isNotFoundFromUnsupportedServer({
statusCode: bulkGetMultiNamespaceDocsResponse.statusCode,
headers: bulkGetMultiNamespaceDocsResponse.headers,
})
) {
throw SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError();
}
return bulkGetMultiNamespaceDocsResponse;
}
/**
* @returns array of objects sorted by expected delete success or failure result
* @internal
*/
private getExpectedBulkDeleteMultiNamespaceDocsResults(
params: ExpectedBulkDeleteMultiNamespaceDocsParams
): ExpectedBulkDeleteResult[] {
const { expectedBulkGetResults, multiNamespaceDocsResponse, namespace, force } = params;
let indexCounter = 0;
const expectedBulkDeleteMultiNamespaceDocsResults =
expectedBulkGetResults.map<ExpectedBulkDeleteResult>((expectedBulkGetResult) => {
if (isLeft(expectedBulkGetResult)) {
return { ...expectedBulkGetResult };
}
const { esRequestIndex: esBulkGetRequestIndex, id, type } = expectedBulkGetResult.value;
let namespaces;
if (esBulkGetRequestIndex !== undefined) {
const indexFound = multiNamespaceDocsResponse?.statusCode !== 404;
const actualResult = indexFound
? multiNamespaceDocsResponse?.body.docs[esBulkGetRequestIndex]
: undefined;
const docFound = indexFound && isMgetDoc(actualResult) && actualResult.found;
// return an error if the doc isn't found at all or the doc doesn't exist in the namespaces
if (!docFound) {
return {
tag: 'Left',
value: {
id,
type,
error: errorContent(SavedObjectsErrorHelpers.createGenericNotFoundError(type, id)),
},
};
}
// the following check should be redundant since we're retrieving the docs from elasticsearch but we check just to make sure
// @ts-expect-error MultiGetHit is incorrectly missing _id, _source
if (!this.rawDocExistsInNamespace(actualResult, namespace)) {
return {
tag: 'Left',
value: {
id,
type,
error: errorContent(SavedObjectsErrorHelpers.createGenericNotFoundError(type, id)),
},
};
}
// @ts-expect-error MultiGetHit is incorrectly missing _id, _source
namespaces = actualResult!._source.namespaces ?? [
SavedObjectsUtils.namespaceIdToString(namespace),
];
const useForce = force && force === true ? true : false;
// the document is shared to more than one space and can only be deleted by force.
if (!useForce && (namespaces.length > 1 || namespaces.includes(ALL_NAMESPACES_STRING))) {
return {
tag: 'Left',
value: {
success: false,
id,
type,
error: errorContent(
SavedObjectsErrorHelpers.createBadRequestError(
`Unable to delete saved object that exists in multiple namespaces, use the "force" option to delete it anyway`
)
),
},
};
}
}
// contains all objects that passed initial preflight checks, including single namespace objects that skipped the mget call
// single namespace objects will have namespaces:undefined
const expectedResult = {
type,
id,
namespaces,
esRequestIndex: indexCounter++,
};
return { tag: 'Right', value: expectedResult };
});
return expectedBulkDeleteMultiNamespaceDocsResults;
}
/**
* {@inheritDoc ISavedObjectsRepository.bulkDelete}
*/
async bulkDelete(
objects: SavedObjectsBulkDeleteObject[],
options: SavedObjectsBulkDeleteOptions = {}
): Promise<SavedObjectsBulkDeleteResponse> {
const { refresh = DEFAULT_REFRESH_SETTING, force } = options;
const namespace = normalizeNamespace(options.namespace);
const expectedBulkGetResults = this.presortObjectsByNamespaceType(objects);
const multiNamespaceDocsResponse = await this.preflightCheckForBulkDelete({
expectedBulkGetResults,
namespace,
});
const bulkDeleteParams: BulkDeleteParams[] = [];
const expectedBulkDeleteMultiNamespaceDocsResults =
this.getExpectedBulkDeleteMultiNamespaceDocsResults({
expectedBulkGetResults,
multiNamespaceDocsResponse,
namespace,
force,
});
// bulk up the bulkDeleteParams
expectedBulkDeleteMultiNamespaceDocsResults.map((expectedResult) => {
if (isRight(expectedResult)) {
bulkDeleteParams.push({
delete: {
_id: this._serializer.generateRawId(
namespace,
expectedResult.value.type,
expectedResult.value.id
),
_index: this.getIndexForType(expectedResult.value.type),
...getExpectedVersionProperties(undefined),
},
});
}
});
const bulkDeleteResponse = bulkDeleteParams.length
? await this.client.bulk({
refresh,
body: bulkDeleteParams,
require_alias: true,
})
: undefined;
// extracted to ensure consistency in the error results returned
let errorResult: BulkDeleteItemErrorResult;
const objectsToDeleteAliasesFor: ObjectToDeleteAliasesFor[] = [];
const savedObjects = expectedBulkDeleteMultiNamespaceDocsResults.map((expectedResult) => {
if (isLeft(expectedResult)) {
return { ...expectedResult.value, success: false };
}
const {
type,
id,
namespaces,
esRequestIndex: esBulkDeleteRequestIndex,
} = expectedResult.value;
// we assume this wouldn't happen but is needed to ensure type consistency
if (bulkDeleteResponse === undefined) {
throw new Error(
`Unexpected error in bulkDelete saved objects: bulkDeleteResponse is undefined`
);
}
const rawResponse = Object.values(
bulkDeleteResponse.items[esBulkDeleteRequestIndex]
)[0] as NewBulkItemResponse;
const error = getBulkOperationError(type, id, rawResponse);
if (error) {
errorResult = { success: false, type, id, error };
return errorResult;
}
if (rawResponse.result === 'not_found') {
errorResult = {
success: false,
type,
id,
error: errorContent(SavedObjectsErrorHelpers.createGenericNotFoundError(type, id)),
};
return errorResult;
}
if (rawResponse.result === 'deleted') {
// `namespaces` should only exist in the expectedResult.value if the type is multi-namespace.
if (namespaces) {
objectsToDeleteAliasesFor.push({
type,
id,
...(namespaces.includes(ALL_NAMESPACES_STRING)
? { namespaces: [], deleteBehavior: 'exclusive' }
: { namespaces, deleteBehavior: 'inclusive' }),
});
}
}
const successfulResult = {
success: true,
id,
type,
};
return successfulResult;
});
// Delete aliases if necessary, ensuring we don't have too many concurrent operations running.
const mapper = async ({ type, id, namespaces, deleteBehavior }: ObjectToDeleteAliasesFor) =>
await deleteLegacyUrlAliases({
mappings: this._mappings,
registry: this._registry,
client: this.client,
getIndexForType: this.getIndexForType.bind(this),
type,
id,
namespaces,
deleteBehavior,
}).catch((err) => {
this._logger.error(`Unable to delete aliases when deleting an object: ${err.message}`);
});
await pMap(objectsToDeleteAliasesFor, mapper, { concurrency: MAX_CONCURRENT_ALIAS_DELETIONS });
return { statuses: [...savedObjects] };
}
/**
* {@inheritDoc ISavedObjectsRepository.deleteByNamespace}
*/

View file

@ -0,0 +1,86 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { Payload } from '@hapi/boom';
import {
BulkOperationBase,
BulkResponseItem,
ErrorCause,
} from '@elastic/elasticsearch/lib/api/types';
import type { estypes, TransportResult } from '@elastic/elasticsearch';
import { Either } from './internal_utils';
import { DeleteLegacyUrlAliasesParams } from './legacy_url_aliases';
/**
* @internal
*/
export interface PreflightCheckForBulkDeleteParams {
expectedBulkGetResults: BulkDeleteExpectedBulkGetResult[];
namespace?: string;
}
/**
* @internal
*/
export interface ExpectedBulkDeleteMultiNamespaceDocsParams {
// contains the type and id of all objects to delete
expectedBulkGetResults: BulkDeleteExpectedBulkGetResult[];
// subset of multi-namespace only expectedBulkGetResults
multiNamespaceDocsResponse: TransportResult<estypes.MgetResponse<unknown>, unknown> | undefined;
// current namespace in which the bulkDelete call is made
namespace: string | undefined;
// optional parameter used to force delete multinamespace objects that exist in more than the current space
force?: boolean;
}
/**
* @internal
*/
export interface BulkDeleteParams {
delete: Omit<BulkOperationBase, 'version' | 'version_type' | 'routing'>;
}
/**
* @internal
*/
export type ExpectedBulkDeleteResult = Either<
{ type: string; id: string; error: Payload },
{
type: string;
id: string;
namespaces: string[];
esRequestIndex: number;
}
>;
/**
* @internal
*/
export interface BulkDeleteItemErrorResult {
success: boolean;
type: string;
id: string;
error: Payload;
}
/**
* @internal
*/
export type NewBulkItemResponse = BulkResponseItem & { error: ErrorCause & { index: string } };
/**
* @internal
* @note Contains all documents for bulk delete, regardless of namespace type
*/
export type BulkDeleteExpectedBulkGetResult = Either<
{ type: string; id: string; error: Payload },
{ type: string; id: string; version?: string; esRequestIndex?: number }
>;
export type ObjectToDeleteAliasesFor = Pick<
DeleteLegacyUrlAliasesParams,
'type' | 'id' | 'namespaces' | 'deleteBehavior'
>;

View file

@ -16,6 +16,7 @@ const createRepositoryMock = () => {
create: jest.fn(),
bulkCreate: jest.fn(),
bulkUpdate: jest.fn(),
bulkDelete: jest.fn(),
delete: jest.fn(),
bulkGet: jest.fn(),
find: jest.fn(),

View file

@ -23,6 +23,8 @@ import type {
SavedObjectsFindOptions,
SavedObjectsUpdateObjectsSpacesObject,
SavedObjectsUpdateObjectsSpacesOptions,
SavedObjectsBulkDeleteOptions,
SavedObjectsBulkDeleteObject,
} from '@kbn/core-saved-objects-api-server';
import { SavedObjectsClient } from './saved_objects_client';
import { repositoryMock, savedObjectsPointInTimeFinderMock } from './mocks';
@ -119,6 +121,22 @@ describe('SavedObjectsClient', () => {
});
});
test(`#bulkDelete`, async () => {
const returnValue: any = Symbol();
mockRepository.bulkDelete.mockResolvedValueOnce(returnValue);
const client = new SavedObjectsClient(mockRepository);
const objects: SavedObjectsBulkDeleteObject[] = [
{ type: 'foo', id: '1' },
{ type: 'bar', id: '2' },
];
const options: SavedObjectsBulkDeleteOptions = { namespace: 'ns-1', refresh: true };
const result = await client.bulkDelete(objects, options);
expect(mockRepository.bulkDelete).toHaveBeenCalledWith(objects, options);
expect(result).toBe(returnValue);
});
test(`#delete`, async () => {
const returnValue: any = Symbol();
mockRepository.delete.mockResolvedValueOnce(returnValue);

View file

@ -39,6 +39,9 @@ import type {
SavedObjectsClosePointInTimeOptions,
SavedObjectsCreatePointInTimeFinderOptions,
SavedObjectsFindOptions,
SavedObjectsBulkDeleteObject,
SavedObjectsBulkDeleteOptions,
SavedObjectsBulkDeleteResponse,
} from '@kbn/core-saved-objects-api-server';
import { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-utils-server';
@ -83,6 +86,14 @@ export class SavedObjectsClient implements SavedObjectsClientContract {
return await this._repository.delete(type, id, options);
}
/** {@inheritDoc SavedObjectsClientContract.bulkDelete} */
async bulkDelete(
objects: SavedObjectsBulkDeleteObject[],
options: SavedObjectsBulkDeleteOptions = {}
): Promise<SavedObjectsBulkDeleteResponse> {
return await this._repository.bulkDelete(objects, options);
}
/** {@inheritDoc SavedObjectsClientContract.find} */
async find<T = unknown, A = unknown>(
options: SavedObjectsFindOptions

View file

@ -15,6 +15,7 @@ const create = () => {
create: jest.fn(),
bulkCreate: jest.fn(),
bulkUpdate: jest.fn(),
bulkDelete: jest.fn(),
delete: jest.fn(),
bulkGet: jest.fn(),
find: jest.fn(),

View file

@ -18,6 +18,7 @@ const create = () => {
checkConflicts: jest.fn(),
bulkUpdate: jest.fn(),
delete: jest.fn(),
bulkDelete: jest.fn(),
bulkGet: jest.fn(),
find: jest.fn(),
get: jest.fn(),

View file

@ -52,4 +52,8 @@ export type {
SavedObjectsCreatePointInTimeFinderOptions,
SavedObjectsFindOptions,
SavedObjectsPointInTimeFinderClient,
SavedObjectsBulkDeleteObject,
SavedObjectsBulkDeleteOptions,
SavedObjectsBulkDeleteStatus,
SavedObjectsBulkDeleteResponse,
} from './src/apis';

View 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { SavedObjectError } from '@kbn/core-saved-objects-common';
import type { MutatingOperationRefreshSetting, SavedObjectsBaseOptions } from './base';
/**
*
* @public
*/
export interface SavedObjectsBulkDeleteObject {
type: string;
id: string;
}
/**
* @public
*/
export interface SavedObjectsBulkDeleteOptions extends SavedObjectsBaseOptions {
/** The Elasticsearch Refresh setting for this operation */
refresh?: MutatingOperationRefreshSetting;
/**
* Force deletion of all objects that exists in multiple namespaces, applied to all objects.
*/
force?: boolean;
}
/**
* @public
*/
export interface SavedObjectsBulkDeleteStatus {
id: string;
type: string;
/** The status of deleting the object: true for deleted, false for error */
success: boolean;
/** Reason the object could not be deleted (success is false) */
error?: SavedObjectError;
}
/**
* @public
*/
export interface SavedObjectsBulkDeleteResponse {
statuses: SavedObjectsBulkDeleteStatus[];
}

View file

@ -72,3 +72,9 @@ export type {
SavedObjectsUpdateObjectsSpacesOptions,
SavedObjectsUpdateObjectsSpacesResponseObject,
} from './update_objects_spaces';
export type {
SavedObjectsBulkDeleteObject,
SavedObjectsBulkDeleteOptions,
SavedObjectsBulkDeleteStatus,
SavedObjectsBulkDeleteResponse,
} from './bulk_delete';

View file

@ -41,6 +41,9 @@ import type {
SavedObjectsRemoveReferencesToResponse,
SavedObjectsCollectMultiNamespaceReferencesOptions,
SavedObjectsBulkResponse,
SavedObjectsBulkDeleteObject,
SavedObjectsBulkDeleteOptions,
SavedObjectsBulkDeleteResponse,
} from './apis';
/**
@ -151,6 +154,16 @@ export interface SavedObjectsClientContract {
*/
delete(type: string, id: string, options?: SavedObjectsDeleteOptions): Promise<{}>;
/**
* Deletes multiple SavedObjects batched together as a single request
*
* @param objects
* @param options
*/
bulkDelete(
objects: SavedObjectsBulkDeleteObject[],
options?: SavedObjectsBulkDeleteOptions
): Promise<SavedObjectsBulkDeleteResponse>;
/**
* Find all SavedObjects matching the search query
*

View file

@ -44,6 +44,9 @@ import type {
SavedObjectsDeleteByNamespaceOptions,
SavedObjectsIncrementCounterField,
SavedObjectsIncrementCounterOptions,
SavedObjectsBulkDeleteOptions,
SavedObjectsBulkDeleteObject,
SavedObjectsBulkDeleteResponse,
} from './apis';
/**
@ -105,6 +108,17 @@ export interface ISavedObjectsRepository {
*/
delete(type: string, id: string, options?: SavedObjectsDeleteOptions): Promise<{}>;
/**
* Deletes multiple documents at once
* @param {array} objects - an array of objects containing id and type
* @param {object} [options={}]
* @returns {promise} - { statuses: [{ id, type, success, error: { message } }] }
*/
bulkDelete(
objects: SavedObjectsBulkDeleteObject[],
options?: SavedObjectsBulkDeleteOptions
): Promise<SavedObjectsBulkDeleteResponse>;
/**
* Deletes all objects from the provided namespace.
*

View file

@ -308,6 +308,45 @@ describe('SavedObjectsClient', () => {
});
});
describe('#bulk_delete', () => {
const bulkDeleteDoc = {
id: 'AVwSwFxtcMV38qjDZoQg',
type: 'config',
};
beforeEach(() => {
http.fetch.mockResolvedValue({
statuses: [{ id: bulkDeleteDoc.id, type: bulkDeleteDoc.type, success: true }],
});
});
test('deletes with an array of id, type and success status for deleted docs', async () => {
const response = savedObjectsClient.bulkDelete([bulkDeleteDoc]);
await expect(response).resolves.toHaveProperty('statuses');
const result = await response;
expect(result.statuses).toHaveLength(1);
expect(result.statuses[0]).toHaveProperty('success');
});
test('makes HTTP call', async () => {
await savedObjectsClient.bulkDelete([bulkDeleteDoc]);
expect(http.fetch.mock.calls).toMatchInlineSnapshot(`
Array [
Array [
"/api/saved_objects/_bulk_delete",
Object {
"body": "[{\\"type\\":\\"config\\",\\"id\\":\\"AVwSwFxtcMV38qjDZoQg\\"}]",
"method": "POST",
"query": Object {
"force": false,
},
},
],
]
`);
});
});
describe('#update', () => {
const attributes = { foo: 'Foo', bar: 'Bar' };
const options = { version: '1' };

View file

@ -11,9 +11,11 @@ import type { HttpSetup, HttpFetchOptions } from '@kbn/core-http-browser';
import type { SavedObject, SavedObjectTypeIdTuple } from '@kbn/core-saved-objects-common';
import type {
SavedObjectsBulkResolveResponse as SavedObjectsBulkResolveResponseServer,
SavedObjectsBulkDeleteResponse as SavedObjectsBulkDeleteResponseServer,
SavedObjectsClientContract as SavedObjectsApi,
SavedObjectsFindResponse as SavedObjectsFindResponseServer,
SavedObjectsResolveResponse,
SavedObjectsBulkDeleteOptions,
} from '@kbn/core-saved-objects-api-server';
import type {
SavedObjectsClientContract,
@ -28,6 +30,7 @@ import type {
SavedObjectsBulkCreateOptions,
SavedObjectsBulkCreateObject,
SimpleSavedObject,
SavedObjectsBulkDeleteResponse,
} from '@kbn/core-saved-objects-api-browser';
import { SimpleSavedObjectImpl } from './simple_saved_object';
@ -255,6 +258,31 @@ export class SavedObjectsClient implements SavedObjectsClientContract {
return this.savedObjectsFetch(this.getPath([type, id]), { method: 'DELETE', query });
};
public bulkDelete = async (
objects: SavedObjectTypeIdTuple[],
options?: SavedObjectsBulkDeleteOptions
): Promise<SavedObjectsBulkDeleteResponse> => {
const filteredObjects = objects.map(({ type, id }) => ({ type, id }));
const queryOptions = { force: !!options?.force };
const response = await this.performBulkDelete(filteredObjects, queryOptions);
return {
statuses: response.statuses,
};
};
private async performBulkDelete(
objects: SavedObjectTypeIdTuple[],
queryOptions: { force: boolean }
) {
const path = this.getPath(['_bulk_delete']);
const request: Promise<SavedObjectsBulkDeleteResponseServer> = this.savedObjectsFetch(path, {
method: 'POST',
body: JSON.stringify(objects),
query: queryOptions,
});
return request;
}
public find = <T = unknown, A = unknown>(
options: SavedObjectsFindOptions
): Promise<SavedObjectsFindResponse<T>> => {

View file

@ -19,6 +19,7 @@ const createStartContractMock = () => {
bulkCreate: jest.fn(),
bulkResolve: jest.fn(),
bulkUpdate: jest.fn(),
bulkDelete: jest.fn(),
delete: jest.fn(),
bulkGet: jest.fn(),
find: jest.fn(),

View file

@ -22,6 +22,7 @@ export { registerBulkCreateRoute } from './src/routes/bulk_create';
export { registerBulkGetRoute } from './src/routes/bulk_get';
export { registerBulkResolveRoute } from './src/routes/bulk_resolve';
export { registerBulkUpdateRoute } from './src/routes/bulk_update';
export { registerBulkDeleteRoute } from './src/routes/bulk_delete';
export { registerCreateRoute } from './src/routes/create';
export { registerDeleteRoute } from './src/routes/delete';
export { registerExportRoute } from './src/routes/export';

View file

@ -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
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { schema } from '@kbn/config-schema';
import type { InternalCoreUsageDataSetup } from '@kbn/core-usage-data-base-server-internal';
import type { InternalSavedObjectRouter } from '../internal_types';
import { catchAndReturnBoomErrors } from './utils';
interface RouteDependencies {
coreUsageData: InternalCoreUsageDataSetup;
}
export const registerBulkDeleteRoute = (
router: InternalSavedObjectRouter,
{ coreUsageData }: RouteDependencies
) => {
router.post(
{
path: '/_bulk_delete',
validate: {
body: schema.arrayOf(
schema.object({
type: schema.string(),
id: schema.string(),
})
),
query: schema.object({
force: schema.maybe(schema.boolean()),
}),
},
},
catchAndReturnBoomErrors(async (context, req, res) => {
const { force } = req.query;
const usageStatsClient = coreUsageData.getClient();
usageStatsClient.incrementSavedObjectsBulkDelete({ request: req }).catch(() => {});
const { savedObjects } = await context.core;
const statuses = await savedObjects.client.bulkDelete(req.body, { force });
return res.ok({ body: statuses });
})
);
};

View file

@ -23,6 +23,7 @@ import { registerUpdateRoute } from './update';
import { registerBulkGetRoute } from './bulk_get';
import { registerBulkCreateRoute } from './bulk_create';
import { registerBulkUpdateRoute } from './bulk_update';
import { registerBulkDeleteRoute } from './bulk_delete';
import { registerExportRoute } from './export';
import { registerImportRoute } from './import';
import { registerResolveImportErrorsRoute } from './resolve_import_errors';
@ -62,6 +63,7 @@ export function registerRoutes({
registerBulkCreateRoute(router, { coreUsageData });
registerBulkResolveRoute(router, { coreUsageData });
registerBulkUpdateRoute(router, { coreUsageData });
registerBulkDeleteRoute(router, { coreUsageData });
registerExportRoute(router, { config, coreUsageData });
registerImportRoute(router, { config, coreUsageData });
registerResolveImportErrorsRoute(router, { config, coreUsageData });

View file

@ -43,6 +43,8 @@ export interface ICoreUsageStatsClient {
incrementSavedObjectsBulkUpdate(options: BaseIncrementOptions): Promise<void>;
incrementSavedObjectsBulkDelete(options: BaseIncrementOptions): Promise<void>;
incrementSavedObjectsCreate(options: BaseIncrementOptions): Promise<void>;
incrementSavedObjectsDelete(options: BaseIncrementOptions): Promise<void>;

View file

@ -20,6 +20,7 @@ import {
BULK_CREATE_STATS_PREFIX,
BULK_GET_STATS_PREFIX,
BULK_UPDATE_STATS_PREFIX,
BULK_DELETE_STATS_PREFIX,
CREATE_STATS_PREFIX,
DELETE_STATS_PREFIX,
FIND_STATS_PREFIX,
@ -452,6 +453,81 @@ describe('CoreUsageStatsClient', () => {
});
});
describe('#incrementSavedObjectsBulkDelete', () => {
it('does not throw an error if repository incrementCounter operation fails', async () => {
const { usageStatsClient, repositoryMock } = setup();
repositoryMock.incrementCounter.mockRejectedValue(new Error('Oh no!'));
const request = httpServerMock.createKibanaRequest();
await expect(
usageStatsClient.incrementSavedObjectsBulkDelete({
request,
} as BaseIncrementOptions)
).resolves.toBeUndefined();
expect(repositoryMock.incrementCounter).toHaveBeenCalled();
});
it('handles falsy options appropriately', async () => {
const { usageStatsClient, repositoryMock } = setup();
const request = httpServerMock.createKibanaRequest();
await usageStatsClient.incrementSavedObjectsBulkDelete({
request,
} as BaseIncrementOptions);
expect(repositoryMock.incrementCounter).toHaveBeenCalledTimes(1);
expect(repositoryMock.incrementCounter).toHaveBeenCalledWith(
CORE_USAGE_STATS_TYPE,
CORE_USAGE_STATS_ID,
[
`${BULK_DELETE_STATS_PREFIX}.total`,
`${BULK_DELETE_STATS_PREFIX}.namespace.default.total`,
`${BULK_DELETE_STATS_PREFIX}.namespace.default.kibanaRequest.no`,
],
incrementOptions
);
});
it('handles truthy options and the default namespace string appropriately', async () => {
const { usageStatsClient, repositoryMock } = setup(DEFAULT_NAMESPACE_STRING);
const request = httpServerMock.createKibanaRequest({ headers: firstPartyRequestHeaders });
await usageStatsClient.incrementSavedObjectsBulkDelete({
request,
} as BaseIncrementOptions);
expect(repositoryMock.incrementCounter).toHaveBeenCalledTimes(1);
expect(repositoryMock.incrementCounter).toHaveBeenCalledWith(
CORE_USAGE_STATS_TYPE,
CORE_USAGE_STATS_ID,
[
`${BULK_DELETE_STATS_PREFIX}.total`,
`${BULK_DELETE_STATS_PREFIX}.namespace.default.total`,
`${BULK_DELETE_STATS_PREFIX}.namespace.default.kibanaRequest.yes`,
],
incrementOptions
);
});
it('handles a non-default space appropriately', async () => {
const { usageStatsClient, repositoryMock } = setup('foo');
const request = httpServerMock.createKibanaRequest();
await usageStatsClient.incrementSavedObjectsBulkDelete({
request,
} as BaseIncrementOptions);
expect(repositoryMock.incrementCounter).toHaveBeenCalledTimes(1);
expect(repositoryMock.incrementCounter).toHaveBeenCalledWith(
CORE_USAGE_STATS_TYPE,
CORE_USAGE_STATS_ID,
[
`${BULK_DELETE_STATS_PREFIX}.total`,
`${BULK_DELETE_STATS_PREFIX}.namespace.custom.total`,
`${BULK_DELETE_STATS_PREFIX}.namespace.custom.kibanaRequest.no`,
],
incrementOptions
);
});
});
describe('#incrementSavedObjectsDelete', () => {
it('does not throw an error if repository incrementCounter operation fails', async () => {
const { usageStatsClient, repositoryMock } = setup();

View file

@ -25,6 +25,7 @@ export const BULK_CREATE_STATS_PREFIX = 'apiCalls.savedObjectsBulkCreate';
export const BULK_GET_STATS_PREFIX = 'apiCalls.savedObjectsBulkGet';
export const BULK_RESOLVE_STATS_PREFIX = 'apiCalls.savedObjectsBulkResolve';
export const BULK_UPDATE_STATS_PREFIX = 'apiCalls.savedObjectsBulkUpdate';
export const BULK_DELETE_STATS_PREFIX = 'apiCalls.savedObjectsBulkDelete';
export const CREATE_STATS_PREFIX = 'apiCalls.savedObjectsCreate';
export const DELETE_STATS_PREFIX = 'apiCalls.savedObjectsDelete';
export const FIND_STATS_PREFIX = 'apiCalls.savedObjectsFind';
@ -43,6 +44,7 @@ const ALL_COUNTER_FIELDS = [
...getFieldsForCounter(BULK_GET_STATS_PREFIX),
...getFieldsForCounter(BULK_RESOLVE_STATS_PREFIX),
...getFieldsForCounter(BULK_UPDATE_STATS_PREFIX),
...getFieldsForCounter(BULK_DELETE_STATS_PREFIX),
...getFieldsForCounter(CREATE_STATS_PREFIX),
...getFieldsForCounter(DELETE_STATS_PREFIX),
...getFieldsForCounter(FIND_STATS_PREFIX),
@ -114,6 +116,10 @@ export class CoreUsageStatsClient implements ICoreUsageStatsClient {
await this.updateUsageStats([], BULK_UPDATE_STATS_PREFIX, options);
}
public async incrementSavedObjectsBulkDelete(options: BaseIncrementOptions) {
await this.updateUsageStats([], BULK_DELETE_STATS_PREFIX, options);
}
public async incrementSavedObjectsCreate(options: BaseIncrementOptions) {
await this.updateUsageStats([], CREATE_STATS_PREFIX, options);
}

View file

@ -15,6 +15,7 @@ const createUsageStatsClientMock = () =>
incrementSavedObjectsBulkGet: jest.fn().mockResolvedValue(null),
incrementSavedObjectsBulkResolve: jest.fn().mockResolvedValue(null),
incrementSavedObjectsBulkUpdate: jest.fn().mockResolvedValue(null),
incrementSavedObjectsBulkDelete: jest.fn().mockResolvedValue(null),
incrementSavedObjectsCreate: jest.fn().mockResolvedValue(null),
incrementSavedObjectsDelete: jest.fn().mockResolvedValue(null),
incrementSavedObjectsFind: jest.fn().mockResolvedValue(null),

View file

@ -42,6 +42,13 @@ export interface CoreUsageStats {
'apiCalls.savedObjectsBulkUpdate.namespace.custom.total'?: number;
'apiCalls.savedObjectsBulkUpdate.namespace.custom.kibanaRequest.yes'?: number;
'apiCalls.savedObjectsBulkUpdate.namespace.custom.kibanaRequest.no'?: number;
'apiCalls.savedObjectsBulkDelete.total'?: number;
'apiCalls.savedObjectsBulkDelete.namespace.default.total'?: number;
'apiCalls.savedObjectsBulkDelete.namespace.default.kibanaRequest.yes'?: number;
'apiCalls.savedObjectsBulkDelete.namespace.default.kibanaRequest.no'?: number;
'apiCalls.savedObjectsBulkDelete.namespace.custom.total'?: number;
'apiCalls.savedObjectsBulkDelete.namespace.custom.kibanaRequest.yes'?: number;
'apiCalls.savedObjectsBulkDelete.namespace.custom.kibanaRequest.no'?: number;
'apiCalls.savedObjectsCreate.total'?: number;
'apiCalls.savedObjectsCreate.namespace.default.total'?: number;
'apiCalls.savedObjectsCreate.namespace.default.kibanaRequest.yes'?: number;

View file

@ -338,6 +338,9 @@ export type {
SavedObjectsFindOptions,
SavedObjectsFindOptionsReference,
SavedObjectsPitParams,
SavedObjectsBulkDeleteObject,
SavedObjectsBulkDeleteOptions,
SavedObjectsBulkDeleteResponse,
} from '@kbn/core-saved-objects-api-server';
export type {
SavedObjectsServiceSetup,

View file

@ -0,0 +1,97 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import supertest from 'supertest';
import { savedObjectsClientMock } from '../../../mocks';
import type { ICoreUsageStatsClient } from '@kbn/core-usage-data-base-server-internal';
import {
coreUsageStatsClientMock,
coreUsageDataServiceMock,
} from '@kbn/core-usage-data-server-mocks';
import { setupServer } from './test_utils';
import {
registerBulkDeleteRoute,
type InternalSavedObjectsRequestHandlerContext,
} from '@kbn/core-saved-objects-server-internal';
type SetupServerReturn = Awaited<ReturnType<typeof setupServer>>;
describe('POST /api/saved_objects/_bulk_delete', () => {
let server: SetupServerReturn['server'];
let httpSetup: SetupServerReturn['httpSetup'];
let handlerContext: SetupServerReturn['handlerContext'];
let savedObjectsClient: ReturnType<typeof savedObjectsClientMock.create>;
let coreUsageStatsClient: jest.Mocked<ICoreUsageStatsClient>;
beforeEach(async () => {
({ server, httpSetup, handlerContext } = await setupServer());
savedObjectsClient = handlerContext.savedObjects.client;
savedObjectsClient.bulkDelete.mockResolvedValue({
statuses: [],
});
const router =
httpSetup.createRouter<InternalSavedObjectsRequestHandlerContext>('/api/saved_objects/');
coreUsageStatsClient = coreUsageStatsClientMock.create();
coreUsageStatsClient.incrementSavedObjectsBulkDelete.mockRejectedValue(new Error('Oh no!')); // intentionally throw this error, which is swallowed, so we can assert that the operation does not fail
const coreUsageData = coreUsageDataServiceMock.createSetupContract(coreUsageStatsClient);
registerBulkDeleteRoute(router, { coreUsageData });
await server.start();
});
afterEach(async () => {
await server.stop();
});
it('formats successful response and records usage stats', async () => {
const clientResponse = {
statuses: [
{
id: 'abc123',
type: 'index-pattern',
success: true,
},
],
};
savedObjectsClient.bulkDelete.mockImplementation(() => Promise.resolve(clientResponse));
const result = await supertest(httpSetup.server.listener)
.post('/api/saved_objects/_bulk_delete')
.send([
{
id: 'abc123',
type: 'index-pattern',
},
])
.expect(200);
expect(result.body).toEqual(clientResponse);
expect(coreUsageStatsClient.incrementSavedObjectsBulkDelete).toHaveBeenCalledWith({
request: expect.anything(),
});
});
it('calls upon savedObjectClient.bulkDelete with query options', async () => {
const docs = [
{
id: 'abc123',
type: 'index-pattern',
},
];
await supertest(httpSetup.server.listener)
.post('/api/saved_objects/_bulk_delete')
.send(docs)
.query({ force: true })
.expect(200);
expect(savedObjectsClient.bulkDelete).toHaveBeenCalledTimes(1);
expect(savedObjectsClient.bulkDelete).toHaveBeenCalledWith(docs, { force: true });
});
});

View file

@ -54,6 +54,17 @@ const registerSOTypes = (setup: InternalCoreSetup) => {
},
namespaceType: 'single',
});
setup.savedObjects.registerType({
name: 'my_bulk_delete_type',
hidden: false,
mappings: {
dynamic: false,
properties: {
title: { type: 'text' },
},
},
namespaceType: 'single',
});
};
describe('404s from proxies', () => {
@ -124,6 +135,7 @@ describe('404s from proxies', () => {
let repository: ISavedObjectsRepository;
let myOtherType: SavedObject;
const myOtherTypeDocs: SavedObject[] = [];
const myBulkDeleteTypeDocs: SavedObject[] = [];
beforeAll(async () => {
repository = start.savedObjects.createInternalRepository();
@ -145,6 +157,19 @@ describe('404s from proxies', () => {
overwrite: true,
namespace: 'default',
});
for (let i = 1; i < 11; i++) {
myBulkDeleteTypeDocs.push({
type: 'my_bulk_delete_type',
id: `myOtherTypeId${i}`,
attributes: { title: `MyOtherTypeTitle${i}` },
references: [],
});
}
await repository.bulkCreate(myBulkDeleteTypeDocs, {
overwrite: true,
namespace: 'default',
});
});
beforeEach(() => {
@ -237,6 +262,18 @@ describe('404s from proxies', () => {
);
});
it('handles `bulkDelete` requests that are successful when the proxy passes through the product header', async () => {
const docsToDelete = myBulkDeleteTypeDocs;
const bulkDeleteDocs = docsToDelete.map((doc) => ({
id: doc.id,
type: 'my_bulk_delete_type',
}));
const docsFound = await repository.bulkDelete(bulkDeleteDocs, { force: false });
expect(docsFound.statuses.length).toBeGreaterThan(0);
expect(docsFound.statuses[0].success).toBe(true);
});
it('handles `bulkGet` requests that are successful when the proxy passes through the product header', async () => {
const docsToGet = myOtherTypeDocs;
const docsFound = await repository.bulkGet(

View file

@ -122,7 +122,8 @@ export const declarePostMgetRoute = (hapiServer: Hapi.Server, hostname: string,
if (
proxyInterrupt === 'bulkGetMyType' ||
proxyInterrupt === 'checkConficts' ||
proxyInterrupt === 'internalBulkResolve'
proxyInterrupt === 'internalBulkResolve' ||
proxyInterrupt === 'bulkDeleteMyDocs'
) {
return proxyResponseHandler(h, hostname, port);
} else {

View file

@ -822,6 +822,46 @@ export function getCoreUsageCollector(
'How many times this API has been called by a non-Kibana client in a custom space.',
},
},
'apiCalls.savedObjectsBulkDelete.total': {
type: 'long',
_meta: { description: 'How many times this API has been called.' },
},
'apiCalls.savedObjectsBulkDelete.namespace.default.total': {
type: 'long',
_meta: { description: 'How many times this API has been called in the Default space.' },
},
'apiCalls.savedObjectsBulkDelete.namespace.default.kibanaRequest.yes': {
type: 'long',
_meta: {
description:
'How many times this API has been called by the Kibana client in the Default space.',
},
},
'apiCalls.savedObjectsBulkDelete.namespace.default.kibanaRequest.no': {
type: 'long',
_meta: {
description:
'How many times this API has been called by a non-Kibana client in the Default space.',
},
},
'apiCalls.savedObjectsBulkDelete.namespace.custom.total': {
type: 'long',
_meta: { description: 'How many times this API has been called in a custom space.' },
},
'apiCalls.savedObjectsBulkDelete.namespace.custom.kibanaRequest.yes': {
type: 'long',
_meta: {
description:
'How many times this API has been called by the Kibana client in a custom space.',
},
},
'apiCalls.savedObjectsBulkDelete.namespace.custom.kibanaRequest.no': {
type: 'long',
_meta: {
description:
'How many times this API has been called by a non-Kibana client in a custom space.',
},
},
// Saved Objects Management APIs
'apiCalls.savedObjectsImport.total': {
type: 'long',

View file

@ -38,6 +38,13 @@ export interface CoreUsageStats {
'apiCalls.savedObjectsBulkResolve.namespace.custom.total'?: number;
'apiCalls.savedObjectsBulkResolve.namespace.custom.kibanaRequest.yes'?: number;
'apiCalls.savedObjectsBulkResolve.namespace.custom.kibanaRequest.no'?: number;
'apiCalls.savedObjectsBulkDelete.total'?: number;
'apiCalls.savedObjectsBulkDelete.namespace.default.total'?: number;
'apiCalls.savedObjectsBulkDelete.namespace.default.kibanaRequest.yes'?: number;
'apiCalls.savedObjectsBulkDelete.namespace.default.kibanaRequest.no'?: number;
'apiCalls.savedObjectsBulkDelete.namespace.custom.total'?: number;
'apiCalls.savedObjectsBulkDelete.namespace.custom.kibanaRequest.yes'?: number;
'apiCalls.savedObjectsBulkDelete.namespace.custom.kibanaRequest.no'?: number;
'apiCalls.savedObjectsBulkUpdate.total'?: number;
'apiCalls.savedObjectsBulkUpdate.namespace.default.total'?: number;
'apiCalls.savedObjectsBulkUpdate.namespace.default.kibanaRequest.yes'?: number;

View file

@ -7222,6 +7222,48 @@
"description": "How many times this API has been called by a non-Kibana client in a custom space."
}
},
"apiCalls.savedObjectsBulkDelete.total": {
"type": "long",
"_meta": {
"description": "How many times this API has been called."
}
},
"apiCalls.savedObjectsBulkDelete.namespace.default.total": {
"type": "long",
"_meta": {
"description": "How many times this API has been called in the Default space."
}
},
"apiCalls.savedObjectsBulkDelete.namespace.default.kibanaRequest.yes": {
"type": "long",
"_meta": {
"description": "How many times this API has been called by the Kibana client in the Default space."
}
},
"apiCalls.savedObjectsBulkDelete.namespace.default.kibanaRequest.no": {
"type": "long",
"_meta": {
"description": "How many times this API has been called by a non-Kibana client in the Default space."
}
},
"apiCalls.savedObjectsBulkDelete.namespace.custom.total": {
"type": "long",
"_meta": {
"description": "How many times this API has been called in a custom space."
}
},
"apiCalls.savedObjectsBulkDelete.namespace.custom.kibanaRequest.yes": {
"type": "long",
"_meta": {
"description": "How many times this API has been called by the Kibana client in a custom space."
}
},
"apiCalls.savedObjectsBulkDelete.namespace.custom.kibanaRequest.no": {
"type": "long",
"_meta": {
"description": "How many times this API has been called by a non-Kibana client in a custom space."
}
},
"apiCalls.savedObjectsImport.total": {
"type": "long",
"_meta": {

View file

@ -0,0 +1,114 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const kibanaServer = getService('kibanaServer');
describe('bulk_delete', () => {
before(async () => {
await kibanaServer.importExport.load(
'test/api_integration/fixtures/kbn_archiver/saved_objects/basic.json'
);
});
after(async () => {
await kibanaServer.importExport.unload(
'test/api_integration/fixtures/kbn_archiver/saved_objects/basic.json'
);
});
it('should return 200 with individual responses when deleting many docs', async () =>
await supertest
.post(`/api/saved_objects/_bulk_delete`)
.send([
{
type: 'visualization',
id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab',
},
{
type: 'dashboard',
id: 'be3733a0-9efe-11e7-acb3-3dab96693fab',
},
])
.expect(200)
.then((resp) => {
expect(resp.body).to.eql({
statuses: [
{
success: true,
type: 'visualization',
id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab',
},
{
success: true,
type: 'dashboard',
id: 'be3733a0-9efe-11e7-acb3-3dab96693fab',
},
],
});
}));
it('should return generic 404 when deleting an unknown doc', async () =>
await supertest
.post(`/api/saved_objects/_bulk_delete`)
.send([{ type: 'dashboard', id: 'not-a-real-id' }])
.expect(200)
.then((resp) => {
expect(resp.body).to.eql({
statuses: [
{
error: {
error: 'Not Found',
message: 'Saved object [dashboard/not-a-real-id] not found',
statusCode: 404,
},
id: 'not-a-real-id',
type: 'dashboard',
success: false,
},
],
});
}));
it('should return the result of deleting valid and invalid objects in the same request', async () =>
await supertest
.post(`/api/saved_objects/_bulk_delete`)
.send([
{ type: 'visualization', id: 'not-a-real-vis-id' },
{
type: 'index-pattern',
id: '91200a00-9efd-11e7-acb3-3dab96693fab',
},
])
.expect(200)
.then((resp) => {
expect(resp.body).to.eql({
statuses: [
{
error: {
error: 'Not Found',
message: 'Saved object [visualization/not-a-real-vis-id] not found',
statusCode: 404,
},
id: 'not-a-real-vis-id',
type: 'visualization',
success: false,
},
{
success: true,
type: 'index-pattern',
id: '91200a00-9efd-11e7-acb3-3dab96693fab',
},
],
});
}));
});
}

View file

@ -11,6 +11,7 @@ import { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ loadTestFile }: FtrProviderContext) {
describe('saved_objects', () => {
loadTestFile(require.resolve('./bulk_create'));
loadTestFile(require.resolve('./bulk_delete'));
loadTestFile(require.resolve('./bulk_get'));
loadTestFile(require.resolve('./bulk_update'));
loadTestFile(require.resolve('./create'));

View file

@ -870,6 +870,44 @@ describe('#delete', () => {
});
});
describe('#bulkDelete', () => {
const obj1 = Object.freeze({ type: 'unknown-type', id: 'unknown-type-id-1' });
const obj2 = Object.freeze({ type: 'unknown-type', id: 'unknown-type-id-2' });
const namespace = 'some-ns';
it('redirects request to underlying base client if type is not registered', async () => {
await wrapper.bulkDelete([obj1, obj2], { namespace });
expect(mockBaseClient.bulkDelete).toHaveBeenCalledTimes(1);
expect(mockBaseClient.bulkDelete).toHaveBeenCalledWith([obj1, obj2], { namespace });
});
it('redirects request to underlying base client if type is registered', async () => {
const knownObj1 = Object.freeze({ type: 'known-type', id: 'known-type-id-1' });
const knownObj2 = Object.freeze({ type: 'known-type', id: 'known-type-id-2' });
const options = { namespace: 'some-ns' };
await wrapper.bulkDelete([knownObj1, knownObj2], options);
expect(mockBaseClient.bulkDelete).toHaveBeenCalledTimes(1);
expect(mockBaseClient.bulkDelete).toHaveBeenCalledWith([knownObj1, knownObj2], { namespace });
});
it('fails if base client fails', async () => {
const failureReason = new Error('Something bad happened...');
mockBaseClient.bulkDelete.mockRejectedValue(failureReason);
await expect(wrapper.bulkDelete([{ type: 'known-type', id: 'some-id' }])).rejects.toThrowError(
failureReason
);
expect(mockBaseClient.bulkDelete).toHaveBeenCalledTimes(1);
expect(mockBaseClient.bulkDelete).toHaveBeenCalledWith(
[{ type: 'known-type', id: 'some-id' }],
undefined
);
});
});
describe('#find', () => {
it('redirects request to underlying base client and does not alter response if type is not registered', async () => {
const mockedResponse = {

View file

@ -10,6 +10,8 @@ import type {
SavedObject,
SavedObjectsBaseOptions,
SavedObjectsBulkCreateObject,
SavedObjectsBulkDeleteObject,
SavedObjectsBulkDeleteOptions,
SavedObjectsBulkGetObject,
SavedObjectsBulkResolveObject,
SavedObjectsBulkResponse,
@ -166,6 +168,13 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon
return await this.options.baseClient.delete(type, id, options);
}
public async bulkDelete(
objects: SavedObjectsBulkDeleteObject[],
options?: SavedObjectsBulkDeleteOptions
) {
return await this.options.baseClient.bulkDelete(objects, options);
}
public async find<T, A>(options: SavedObjectsFindOptions) {
return await this.handleEncryptedAttributesInBulkResponse(
await this.options.baseClient.find<T, A>(options),

View file

@ -24,6 +24,7 @@ const writeOperations: string[] = [
'update',
'bulk_update',
'delete',
'bulk_delete',
'share_to_space',
];
const allOperations: string[] = [...readOperations, ...writeOperations];

View file

@ -109,6 +109,7 @@ describe('features', () => {
actions.savedObject.get('all-savedObject-all-1', 'update'),
actions.savedObject.get('all-savedObject-all-1', 'bulk_update'),
actions.savedObject.get('all-savedObject-all-1', 'delete'),
actions.savedObject.get('all-savedObject-all-1', 'bulk_delete'),
actions.savedObject.get('all-savedObject-all-1', 'share_to_space'),
actions.savedObject.get('all-savedObject-all-2', 'bulk_get'),
actions.savedObject.get('all-savedObject-all-2', 'get'),
@ -120,6 +121,7 @@ describe('features', () => {
actions.savedObject.get('all-savedObject-all-2', 'update'),
actions.savedObject.get('all-savedObject-all-2', 'bulk_update'),
actions.savedObject.get('all-savedObject-all-2', 'delete'),
actions.savedObject.get('all-savedObject-all-2', 'bulk_delete'),
actions.savedObject.get('all-savedObject-all-2', 'share_to_space'),
actions.savedObject.get('all-savedObject-read-1', 'bulk_get'),
actions.savedObject.get('all-savedObject-read-1', 'get'),
@ -148,6 +150,7 @@ describe('features', () => {
actions.savedObject.get('read-savedObject-all-1', 'update'),
actions.savedObject.get('read-savedObject-all-1', 'bulk_update'),
actions.savedObject.get('read-savedObject-all-1', 'delete'),
actions.savedObject.get('read-savedObject-all-1', 'bulk_delete'),
actions.savedObject.get('read-savedObject-all-1', 'share_to_space'),
actions.savedObject.get('read-savedObject-all-2', 'bulk_get'),
actions.savedObject.get('read-savedObject-all-2', 'get'),
@ -159,6 +162,7 @@ describe('features', () => {
actions.savedObject.get('read-savedObject-all-2', 'update'),
actions.savedObject.get('read-savedObject-all-2', 'bulk_update'),
actions.savedObject.get('read-savedObject-all-2', 'delete'),
actions.savedObject.get('read-savedObject-all-2', 'bulk_delete'),
actions.savedObject.get('read-savedObject-all-2', 'share_to_space'),
actions.savedObject.get('read-savedObject-read-1', 'bulk_get'),
actions.savedObject.get('read-savedObject-read-1', 'get'),
@ -301,6 +305,7 @@ describe('features', () => {
actions.savedObject.get('all-savedObject-all-1', 'update'),
actions.savedObject.get('all-savedObject-all-1', 'bulk_update'),
actions.savedObject.get('all-savedObject-all-1', 'delete'),
actions.savedObject.get('all-savedObject-all-1', 'bulk_delete'),
actions.savedObject.get('all-savedObject-all-1', 'share_to_space'),
actions.savedObject.get('all-savedObject-all-2', 'bulk_get'),
actions.savedObject.get('all-savedObject-all-2', 'get'),
@ -312,6 +317,7 @@ describe('features', () => {
actions.savedObject.get('all-savedObject-all-2', 'update'),
actions.savedObject.get('all-savedObject-all-2', 'bulk_update'),
actions.savedObject.get('all-savedObject-all-2', 'delete'),
actions.savedObject.get('all-savedObject-all-2', 'bulk_delete'),
actions.savedObject.get('all-savedObject-all-2', 'share_to_space'),
actions.savedObject.get('all-savedObject-read-1', 'bulk_get'),
actions.savedObject.get('all-savedObject-read-1', 'get'),
@ -339,6 +345,7 @@ describe('features', () => {
actions.savedObject.get('read-savedObject-all-1', 'update'),
actions.savedObject.get('read-savedObject-all-1', 'bulk_update'),
actions.savedObject.get('read-savedObject-all-1', 'delete'),
actions.savedObject.get('read-savedObject-all-1', 'bulk_delete'),
actions.savedObject.get('read-savedObject-all-1', 'share_to_space'),
actions.savedObject.get('read-savedObject-all-2', 'bulk_get'),
actions.savedObject.get('read-savedObject-all-2', 'get'),
@ -350,6 +357,7 @@ describe('features', () => {
actions.savedObject.get('read-savedObject-all-2', 'update'),
actions.savedObject.get('read-savedObject-all-2', 'bulk_update'),
actions.savedObject.get('read-savedObject-all-2', 'delete'),
actions.savedObject.get('read-savedObject-all-2', 'bulk_delete'),
actions.savedObject.get('read-savedObject-all-2', 'share_to_space'),
actions.savedObject.get('read-savedObject-read-1', 'bulk_get'),
actions.savedObject.get('read-savedObject-read-1', 'get'),
@ -427,6 +435,7 @@ describe('features', () => {
actions.savedObject.get('read-savedObject-all-1', 'update'),
actions.savedObject.get('read-savedObject-all-1', 'bulk_update'),
actions.savedObject.get('read-savedObject-all-1', 'delete'),
actions.savedObject.get('read-savedObject-all-1', 'bulk_delete'),
actions.savedObject.get('read-savedObject-all-1', 'share_to_space'),
actions.savedObject.get('read-savedObject-all-2', 'bulk_get'),
actions.savedObject.get('read-savedObject-all-2', 'get'),
@ -438,6 +447,7 @@ describe('features', () => {
actions.savedObject.get('read-savedObject-all-2', 'update'),
actions.savedObject.get('read-savedObject-all-2', 'bulk_update'),
actions.savedObject.get('read-savedObject-all-2', 'delete'),
actions.savedObject.get('read-savedObject-all-2', 'bulk_delete'),
actions.savedObject.get('read-savedObject-all-2', 'share_to_space'),
actions.savedObject.get('read-savedObject-read-1', 'bulk_get'),
actions.savedObject.get('read-savedObject-read-1', 'get'),
@ -732,6 +742,7 @@ describe('reserved', () => {
actions.savedObject.get('savedObject-all-1', 'update'),
actions.savedObject.get('savedObject-all-1', 'bulk_update'),
actions.savedObject.get('savedObject-all-1', 'delete'),
actions.savedObject.get('savedObject-all-1', 'bulk_delete'),
actions.savedObject.get('savedObject-all-1', 'share_to_space'),
actions.savedObject.get('savedObject-all-2', 'bulk_get'),
actions.savedObject.get('savedObject-all-2', 'get'),
@ -743,6 +754,7 @@ describe('reserved', () => {
actions.savedObject.get('savedObject-all-2', 'update'),
actions.savedObject.get('savedObject-all-2', 'bulk_update'),
actions.savedObject.get('savedObject-all-2', 'delete'),
actions.savedObject.get('savedObject-all-2', 'bulk_delete'),
actions.savedObject.get('savedObject-all-2', 'share_to_space'),
actions.savedObject.get('savedObject-read-1', 'bulk_get'),
actions.savedObject.get('savedObject-read-1', 'get'),
@ -862,6 +874,7 @@ describe('subFeatures', () => {
actions.savedObject.get('all-sub-feature-type', 'update'),
actions.savedObject.get('all-sub-feature-type', 'bulk_update'),
actions.savedObject.get('all-sub-feature-type', 'delete'),
actions.savedObject.get('all-sub-feature-type', 'bulk_delete'),
actions.savedObject.get('all-sub-feature-type', 'share_to_space'),
actions.savedObject.get('read-sub-feature-type', 'bulk_get'),
actions.savedObject.get('read-sub-feature-type', 'get'),
@ -993,6 +1006,7 @@ describe('subFeatures', () => {
actions.savedObject.get('all-sub-feature-type', 'update'),
actions.savedObject.get('all-sub-feature-type', 'bulk_update'),
actions.savedObject.get('all-sub-feature-type', 'delete'),
actions.savedObject.get('all-sub-feature-type', 'bulk_delete'),
actions.savedObject.get('all-sub-feature-type', 'share_to_space'),
actions.savedObject.get('read-sub-feature-type', 'bulk_get'),
actions.savedObject.get('read-sub-feature-type', 'get'),
@ -1015,6 +1029,7 @@ describe('subFeatures', () => {
actions.savedObject.get('all-sub-feature-type', 'update'),
actions.savedObject.get('all-sub-feature-type', 'bulk_update'),
actions.savedObject.get('all-sub-feature-type', 'delete'),
actions.savedObject.get('all-sub-feature-type', 'bulk_delete'),
actions.savedObject.get('all-sub-feature-type', 'share_to_space'),
actions.savedObject.get('read-sub-feature-type', 'bulk_get'),
actions.savedObject.get('read-sub-feature-type', 'get'),
@ -1044,6 +1059,7 @@ describe('subFeatures', () => {
actions.savedObject.get('all-sub-feature-type', 'update'),
actions.savedObject.get('all-sub-feature-type', 'bulk_update'),
actions.savedObject.get('all-sub-feature-type', 'delete'),
actions.savedObject.get('all-sub-feature-type', 'bulk_delete'),
actions.savedObject.get('all-sub-feature-type', 'share_to_space'),
actions.savedObject.get('read-sub-feature-type', 'bulk_get'),
actions.savedObject.get('read-sub-feature-type', 'get'),
@ -1081,6 +1097,7 @@ describe('subFeatures', () => {
actions.savedObject.get('all-sub-feature-type', 'update'),
actions.savedObject.get('all-sub-feature-type', 'bulk_update'),
actions.savedObject.get('all-sub-feature-type', 'delete'),
actions.savedObject.get('all-sub-feature-type', 'bulk_delete'),
actions.savedObject.get('all-sub-feature-type', 'share_to_space'),
actions.savedObject.get('read-sub-feature-type', 'bulk_get'),
actions.savedObject.get('read-sub-feature-type', 'get'),
@ -1104,6 +1121,7 @@ describe('subFeatures', () => {
actions.savedObject.get('all-sub-feature-type', 'update'),
actions.savedObject.get('all-sub-feature-type', 'bulk_update'),
actions.savedObject.get('all-sub-feature-type', 'delete'),
actions.savedObject.get('all-sub-feature-type', 'bulk_delete'),
actions.savedObject.get('all-sub-feature-type', 'share_to_space'),
actions.savedObject.get('read-sub-feature-type', 'bulk_get'),
actions.savedObject.get('read-sub-feature-type', 'get'),
@ -1127,6 +1145,7 @@ describe('subFeatures', () => {
actions.savedObject.get('all-sub-feature-type', 'update'),
actions.savedObject.get('all-sub-feature-type', 'bulk_update'),
actions.savedObject.get('all-sub-feature-type', 'delete'),
actions.savedObject.get('all-sub-feature-type', 'bulk_delete'),
actions.savedObject.get('all-sub-feature-type', 'share_to_space'),
actions.savedObject.get('read-sub-feature-type', 'bulk_get'),
actions.savedObject.get('read-sub-feature-type', 'get'),
@ -1149,6 +1168,7 @@ describe('subFeatures', () => {
actions.savedObject.get('all-sub-feature-type', 'update'),
actions.savedObject.get('all-sub-feature-type', 'bulk_update'),
actions.savedObject.get('all-sub-feature-type', 'delete'),
actions.savedObject.get('all-sub-feature-type', 'bulk_delete'),
actions.savedObject.get('all-sub-feature-type', 'share_to_space'),
actions.savedObject.get('read-sub-feature-type', 'bulk_get'),
actions.savedObject.get('read-sub-feature-type', 'get'),
@ -1227,6 +1247,7 @@ describe('subFeatures', () => {
actions.savedObject.get('all-sub-feature-type', 'update'),
actions.savedObject.get('all-sub-feature-type', 'bulk_update'),
actions.savedObject.get('all-sub-feature-type', 'delete'),
actions.savedObject.get('all-sub-feature-type', 'bulk_delete'),
actions.savedObject.get('all-sub-feature-type', 'share_to_space'),
actions.savedObject.get('read-sub-feature-type', 'bulk_get'),
actions.savedObject.get('read-sub-feature-type', 'get'),
@ -1249,6 +1270,7 @@ describe('subFeatures', () => {
actions.savedObject.get('all-sub-feature-type', 'update'),
actions.savedObject.get('all-sub-feature-type', 'bulk_update'),
actions.savedObject.get('all-sub-feature-type', 'delete'),
actions.savedObject.get('all-sub-feature-type', 'bulk_delete'),
actions.savedObject.get('all-sub-feature-type', 'share_to_space'),
actions.savedObject.get('read-sub-feature-type', 'bulk_get'),
actions.savedObject.get('read-sub-feature-type', 'get'),
@ -1278,6 +1300,7 @@ describe('subFeatures', () => {
actions.savedObject.get('all-sub-feature-type', 'update'),
actions.savedObject.get('all-sub-feature-type', 'bulk_update'),
actions.savedObject.get('all-sub-feature-type', 'delete'),
actions.savedObject.get('all-sub-feature-type', 'bulk_delete'),
actions.savedObject.get('all-sub-feature-type', 'share_to_space'),
actions.savedObject.get('read-sub-feature-type', 'bulk_get'),
actions.savedObject.get('read-sub-feature-type', 'get'),
@ -1384,6 +1407,7 @@ describe('subFeatures', () => {
actions.savedObject.get('all-sub-feature-type', 'update'),
actions.savedObject.get('all-sub-feature-type', 'bulk_update'),
actions.savedObject.get('all-sub-feature-type', 'delete'),
actions.savedObject.get('all-sub-feature-type', 'bulk_delete'),
actions.savedObject.get('all-sub-feature-type', 'share_to_space'),
actions.savedObject.get('read-sub-feature-type', 'bulk_get'),
actions.savedObject.get('read-sub-feature-type', 'get'),
@ -1406,6 +1430,7 @@ describe('subFeatures', () => {
actions.savedObject.get('all-sub-feature-type', 'update'),
actions.savedObject.get('all-sub-feature-type', 'bulk_update'),
actions.savedObject.get('all-sub-feature-type', 'delete'),
actions.savedObject.get('all-sub-feature-type', 'bulk_delete'),
actions.savedObject.get('all-sub-feature-type', 'share_to_space'),
actions.savedObject.get('read-sub-feature-type', 'bulk_get'),
actions.savedObject.get('read-sub-feature-type', 'get'),
@ -1455,6 +1480,7 @@ describe('subFeatures', () => {
actions.savedObject.get('all-sub-feature-type', 'update'),
actions.savedObject.get('all-sub-feature-type', 'bulk_update'),
actions.savedObject.get('all-sub-feature-type', 'delete'),
actions.savedObject.get('all-sub-feature-type', 'bulk_delete'),
actions.savedObject.get('all-sub-feature-type', 'share_to_space'),
actions.savedObject.get('read-sub-feature-type', 'bulk_get'),
actions.savedObject.get('read-sub-feature-type', 'get'),
@ -1484,6 +1510,7 @@ describe('subFeatures', () => {
actions.savedObject.get('all-sub-feature-type', 'update'),
actions.savedObject.get('all-sub-feature-type', 'bulk_update'),
actions.savedObject.get('all-sub-feature-type', 'delete'),
actions.savedObject.get('all-sub-feature-type', 'bulk_delete'),
actions.savedObject.get('all-sub-feature-type', 'share_to_space'),
actions.savedObject.get('read-sub-feature-type', 'bulk_get'),
actions.savedObject.get('read-sub-feature-type', 'get'),
@ -1567,6 +1594,7 @@ describe('subFeatures', () => {
actions.savedObject.get('all-sub-feature-type', 'update'),
actions.savedObject.get('all-sub-feature-type', 'bulk_update'),
actions.savedObject.get('all-sub-feature-type', 'delete'),
actions.savedObject.get('all-sub-feature-type', 'bulk_delete'),
actions.savedObject.get('all-sub-feature-type', 'share_to_space'),
actions.savedObject.get('read-sub-feature-type', 'bulk_get'),
actions.savedObject.get('read-sub-feature-type', 'get'),
@ -1589,6 +1617,7 @@ describe('subFeatures', () => {
actions.savedObject.get('all-sub-feature-type', 'update'),
actions.savedObject.get('all-sub-feature-type', 'bulk_update'),
actions.savedObject.get('all-sub-feature-type', 'delete'),
actions.savedObject.get('all-sub-feature-type', 'bulk_delete'),
actions.savedObject.get('all-sub-feature-type', 'share_to_space'),
actions.savedObject.get('read-sub-feature-type', 'bulk_get'),
actions.savedObject.get('read-sub-feature-type', 'get'),
@ -1709,6 +1738,7 @@ describe('subFeatures', () => {
actions.savedObject.get('all-sub-feature-type', 'update'),
actions.savedObject.get('all-sub-feature-type', 'bulk_update'),
actions.savedObject.get('all-sub-feature-type', 'delete'),
actions.savedObject.get('all-sub-feature-type', 'bulk_delete'),
actions.savedObject.get('all-sub-feature-type', 'share_to_space'),
actions.savedObject.get('read-sub-feature-type', 'bulk_get'),
actions.savedObject.get('read-sub-feature-type', 'get'),
@ -1738,6 +1768,7 @@ describe('subFeatures', () => {
actions.savedObject.get('all-sub-feature-type', 'update'),
actions.savedObject.get('all-sub-feature-type', 'bulk_update'),
actions.savedObject.get('all-sub-feature-type', 'delete'),
actions.savedObject.get('all-sub-feature-type', 'bulk_delete'),
actions.savedObject.get('all-sub-feature-type', 'share_to_space'),
actions.savedObject.get('read-sub-feature-type', 'bulk_get'),
actions.savedObject.get('read-sub-feature-type', 'get'),
@ -1775,6 +1806,7 @@ describe('subFeatures', () => {
actions.savedObject.get('all-sub-feature-type', 'update'),
actions.savedObject.get('all-sub-feature-type', 'bulk_update'),
actions.savedObject.get('all-sub-feature-type', 'delete'),
actions.savedObject.get('all-sub-feature-type', 'bulk_delete'),
actions.savedObject.get('all-sub-feature-type', 'share_to_space'),
actions.savedObject.get('read-sub-feature-type', 'bulk_get'),
actions.savedObject.get('read-sub-feature-type', 'get'),
@ -1798,6 +1830,7 @@ describe('subFeatures', () => {
actions.savedObject.get('all-sub-feature-type', 'update'),
actions.savedObject.get('all-sub-feature-type', 'bulk_update'),
actions.savedObject.get('all-sub-feature-type', 'delete'),
actions.savedObject.get('all-sub-feature-type', 'bulk_delete'),
actions.savedObject.get('all-sub-feature-type', 'share_to_space'),
actions.savedObject.get('read-sub-feature-type', 'bulk_get'),
actions.savedObject.get('read-sub-feature-type', 'get'),
@ -1821,6 +1854,7 @@ describe('subFeatures', () => {
actions.savedObject.get('all-sub-feature-type', 'update'),
actions.savedObject.get('all-sub-feature-type', 'bulk_update'),
actions.savedObject.get('all-sub-feature-type', 'delete'),
actions.savedObject.get('all-sub-feature-type', 'bulk_delete'),
actions.savedObject.get('all-sub-feature-type', 'share_to_space'),
actions.savedObject.get('read-sub-feature-type', 'bulk_get'),
actions.savedObject.get('read-sub-feature-type', 'get'),
@ -1843,6 +1877,7 @@ describe('subFeatures', () => {
actions.savedObject.get('all-sub-feature-type', 'update'),
actions.savedObject.get('all-sub-feature-type', 'bulk_update'),
actions.savedObject.get('all-sub-feature-type', 'delete'),
actions.savedObject.get('all-sub-feature-type', 'bulk_delete'),
actions.savedObject.get('all-sub-feature-type', 'share_to_space'),
actions.savedObject.get('read-sub-feature-type', 'bulk_get'),
actions.savedObject.get('read-sub-feature-type', 'get'),
@ -1941,6 +1976,7 @@ describe('subFeatures', () => {
actions.savedObject.get('all-sub-feature-type', 'update'),
actions.savedObject.get('all-sub-feature-type', 'bulk_update'),
actions.savedObject.get('all-sub-feature-type', 'delete'),
actions.savedObject.get('all-sub-feature-type', 'bulk_delete'),
actions.savedObject.get('all-sub-feature-type', 'share_to_space'),
actions.savedObject.get('read-sub-feature-type', 'bulk_get'),
actions.savedObject.get('read-sub-feature-type', 'get'),
@ -1970,6 +2006,7 @@ describe('subFeatures', () => {
actions.savedObject.get('all-sub-feature-type', 'update'),
actions.savedObject.get('all-sub-feature-type', 'bulk_update'),
actions.savedObject.get('all-sub-feature-type', 'delete'),
actions.savedObject.get('all-sub-feature-type', 'bulk_delete'),
actions.savedObject.get('all-sub-feature-type', 'share_to_space'),
actions.savedObject.get('read-sub-feature-type', 'bulk_get'),
actions.savedObject.get('read-sub-feature-type', 'get'),
@ -2007,6 +2044,7 @@ describe('subFeatures', () => {
actions.savedObject.get('all-sub-feature-type', 'update'),
actions.savedObject.get('all-sub-feature-type', 'bulk_update'),
actions.savedObject.get('all-sub-feature-type', 'delete'),
actions.savedObject.get('all-sub-feature-type', 'bulk_delete'),
actions.savedObject.get('all-sub-feature-type', 'share_to_space'),
actions.savedObject.get('read-sub-feature-type', 'bulk_get'),
actions.savedObject.get('read-sub-feature-type', 'get'),
@ -2030,6 +2068,7 @@ describe('subFeatures', () => {
actions.savedObject.get('all-sub-feature-type', 'update'),
actions.savedObject.get('all-sub-feature-type', 'bulk_update'),
actions.savedObject.get('all-sub-feature-type', 'delete'),
actions.savedObject.get('all-sub-feature-type', 'bulk_delete'),
actions.savedObject.get('all-sub-feature-type', 'share_to_space'),
actions.savedObject.get('read-sub-feature-type', 'bulk_get'),
actions.savedObject.get('read-sub-feature-type', 'get'),
@ -2053,6 +2092,7 @@ describe('subFeatures', () => {
actions.savedObject.get('all-sub-feature-type', 'update'),
actions.savedObject.get('all-sub-feature-type', 'bulk_update'),
actions.savedObject.get('all-sub-feature-type', 'delete'),
actions.savedObject.get('all-sub-feature-type', 'bulk_delete'),
actions.savedObject.get('all-sub-feature-type', 'share_to_space'),
actions.savedObject.get('read-sub-feature-type', 'bulk_get'),
actions.savedObject.get('read-sub-feature-type', 'get'),
@ -2075,6 +2115,7 @@ describe('subFeatures', () => {
actions.savedObject.get('all-sub-feature-type', 'update'),
actions.savedObject.get('all-sub-feature-type', 'bulk_update'),
actions.savedObject.get('all-sub-feature-type', 'delete'),
actions.savedObject.get('all-sub-feature-type', 'bulk_delete'),
actions.savedObject.get('all-sub-feature-type', 'share_to_space'),
actions.savedObject.get('read-sub-feature-type', 'bulk_get'),
actions.savedObject.get('read-sub-feature-type', 'get'),
@ -2173,6 +2214,7 @@ describe('subFeatures', () => {
actions.savedObject.get('all-sub-feature-type', 'update'),
actions.savedObject.get('all-sub-feature-type', 'bulk_update'),
actions.savedObject.get('all-sub-feature-type', 'delete'),
actions.savedObject.get('all-sub-feature-type', 'bulk_delete'),
actions.savedObject.get('all-sub-feature-type', 'share_to_space'),
actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_get'),
actions.savedObject.get('all-licensed-sub-feature-type', 'get'),
@ -2184,6 +2226,7 @@ describe('subFeatures', () => {
actions.savedObject.get('all-licensed-sub-feature-type', 'update'),
actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_update'),
actions.savedObject.get('all-licensed-sub-feature-type', 'delete'),
actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_delete'),
actions.savedObject.get('all-licensed-sub-feature-type', 'share_to_space'),
actions.savedObject.get('read-sub-feature-type', 'bulk_get'),
actions.savedObject.get('read-sub-feature-type', 'get'),
@ -2219,6 +2262,7 @@ describe('subFeatures', () => {
actions.savedObject.get('all-sub-feature-type', 'update'),
actions.savedObject.get('all-sub-feature-type', 'bulk_update'),
actions.savedObject.get('all-sub-feature-type', 'delete'),
actions.savedObject.get('all-sub-feature-type', 'bulk_delete'),
actions.savedObject.get('all-sub-feature-type', 'share_to_space'),
actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_get'),
actions.savedObject.get('all-licensed-sub-feature-type', 'get'),
@ -2230,6 +2274,7 @@ describe('subFeatures', () => {
actions.savedObject.get('all-licensed-sub-feature-type', 'update'),
actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_update'),
actions.savedObject.get('all-licensed-sub-feature-type', 'delete'),
actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_delete'),
actions.savedObject.get('all-licensed-sub-feature-type', 'share_to_space'),
actions.savedObject.get('read-sub-feature-type', 'bulk_get'),
actions.savedObject.get('read-sub-feature-type', 'get'),
@ -2273,6 +2318,7 @@ describe('subFeatures', () => {
actions.savedObject.get('all-sub-feature-type', 'update'),
actions.savedObject.get('all-sub-feature-type', 'bulk_update'),
actions.savedObject.get('all-sub-feature-type', 'delete'),
actions.savedObject.get('all-sub-feature-type', 'bulk_delete'),
actions.savedObject.get('all-sub-feature-type', 'share_to_space'),
actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_get'),
actions.savedObject.get('all-licensed-sub-feature-type', 'get'),
@ -2284,6 +2330,7 @@ describe('subFeatures', () => {
actions.savedObject.get('all-licensed-sub-feature-type', 'update'),
actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_update'),
actions.savedObject.get('all-licensed-sub-feature-type', 'delete'),
actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_delete'),
actions.savedObject.get('all-licensed-sub-feature-type', 'share_to_space'),
actions.savedObject.get('read-sub-feature-type', 'bulk_get'),
actions.savedObject.get('read-sub-feature-type', 'get'),
@ -2313,6 +2360,7 @@ describe('subFeatures', () => {
actions.savedObject.get('all-sub-feature-type', 'update'),
actions.savedObject.get('all-sub-feature-type', 'bulk_update'),
actions.savedObject.get('all-sub-feature-type', 'delete'),
actions.savedObject.get('all-sub-feature-type', 'bulk_delete'),
actions.savedObject.get('all-sub-feature-type', 'share_to_space'),
actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_get'),
actions.savedObject.get('all-licensed-sub-feature-type', 'get'),
@ -2324,6 +2372,7 @@ describe('subFeatures', () => {
actions.savedObject.get('all-licensed-sub-feature-type', 'update'),
actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_update'),
actions.savedObject.get('all-licensed-sub-feature-type', 'delete'),
actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_delete'),
actions.savedObject.get('all-licensed-sub-feature-type', 'share_to_space'),
actions.savedObject.get('read-sub-feature-type', 'bulk_get'),
actions.savedObject.get('read-sub-feature-type', 'get'),
@ -2353,6 +2402,7 @@ describe('subFeatures', () => {
actions.savedObject.get('all-sub-feature-type', 'update'),
actions.savedObject.get('all-sub-feature-type', 'bulk_update'),
actions.savedObject.get('all-sub-feature-type', 'delete'),
actions.savedObject.get('all-sub-feature-type', 'bulk_delete'),
actions.savedObject.get('all-sub-feature-type', 'share_to_space'),
actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_get'),
actions.savedObject.get('all-licensed-sub-feature-type', 'get'),
@ -2364,6 +2414,7 @@ describe('subFeatures', () => {
actions.savedObject.get('all-licensed-sub-feature-type', 'update'),
actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_update'),
actions.savedObject.get('all-licensed-sub-feature-type', 'delete'),
actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_delete'),
actions.savedObject.get('all-licensed-sub-feature-type', 'share_to_space'),
actions.savedObject.get('read-sub-feature-type', 'bulk_get'),
actions.savedObject.get('read-sub-feature-type', 'get'),
@ -2392,6 +2443,7 @@ describe('subFeatures', () => {
actions.savedObject.get('all-sub-feature-type', 'update'),
actions.savedObject.get('all-sub-feature-type', 'bulk_update'),
actions.savedObject.get('all-sub-feature-type', 'delete'),
actions.savedObject.get('all-sub-feature-type', 'bulk_delete'),
actions.savedObject.get('all-sub-feature-type', 'share_to_space'),
actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_get'),
actions.savedObject.get('all-licensed-sub-feature-type', 'get'),
@ -2403,6 +2455,7 @@ describe('subFeatures', () => {
actions.savedObject.get('all-licensed-sub-feature-type', 'update'),
actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_update'),
actions.savedObject.get('all-licensed-sub-feature-type', 'delete'),
actions.savedObject.get('all-licensed-sub-feature-type', 'bulk_delete'),
actions.savedObject.get('all-licensed-sub-feature-type', 'share_to_space'),
actions.savedObject.get('read-sub-feature-type', 'bulk_get'),
actions.savedObject.get('read-sub-feature-type', 'get'),

View file

@ -591,6 +591,67 @@ describe('#bulkUpdate', () => {
});
});
describe('#bulkDelete', () => {
const obj1 = Object.freeze({ type: 'foo', id: 'foo-id' });
const obj2 = Object.freeze({ type: 'bar', id: 'bar-id' });
const namespace = 'some-ns';
test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => {
const objects = [obj1];
await expectGeneralError(client.bulkDelete, { objects });
});
test(`throws decorated ForbiddenError when unauthorized`, async () => {
const objects = [obj1, obj2];
const options = { namespace };
await expectForbiddenError(client.bulkDelete, { objects, options });
});
test(`returns result of baseClient.bulkDelete when authorized`, async () => {
const apiCallReturnValue = {
statuses: [obj1, obj2].map((obj) => {
return { ...obj, success: true };
}),
};
clientOpts.baseClient.bulkDelete.mockReturnValue(apiCallReturnValue as any);
const objects = [obj1, obj2];
const options = { namespace };
const result = await expectSuccess(client.bulkDelete, { objects, options });
expect(result).toEqual(apiCallReturnValue);
});
test(`checks privileges for user, actions, and namespace`, async () => {
const objects = [obj1, obj2];
const options = { namespace };
await expectPrivilegeCheck(client.bulkDelete, { objects, options }, namespace);
});
test(`adds audit event when successful`, async () => {
const apiCallReturnValue = {
statuses: [obj1, obj2].map((obj) => {
return { ...obj, success: true };
}),
};
clientOpts.baseClient.bulkDelete.mockReturnValue(apiCallReturnValue as any);
const objects = [obj1, obj2];
const options = { namespace };
await expectSuccess(client.bulkDelete, { objects, options });
expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(2);
expectAuditEvent('saved_object_delete', 'success', { type: obj1.type, id: obj1.id });
expectAuditEvent('saved_object_delete', 'success', { type: obj2.type, id: obj2.id });
});
test(`adds audit event when not successful`, async () => {
clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue(new Error());
await expect(() => client.bulkDelete([obj1, obj2], { namespace })).rejects.toThrow();
expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(2);
expectAuditEvent('saved_object_delete', 'failure', { type: obj1.type, id: obj1.id });
expectAuditEvent('saved_object_delete', 'failure', { type: obj2.type, id: obj2.id });
});
});
describe('#checkConflicts', () => {
const obj1 = Object.freeze({ type: 'foo', id: 'foo-id' });
const obj2 = Object.freeze({ type: 'bar', id: 'bar-id' });

View file

@ -9,6 +9,9 @@ import type {
SavedObjectReferenceWithContext,
SavedObjectsBaseOptions,
SavedObjectsBulkCreateObject,
SavedObjectsBulkDeleteObject,
SavedObjectsBulkDeleteOptions,
SavedObjectsBulkDeleteResponse,
SavedObjectsBulkGetObject,
SavedObjectsBulkResolveObject,
SavedObjectsBulkUpdateObject,
@ -224,6 +227,48 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra
return await this.baseClient.delete(type, id, options);
}
public async bulkDelete(
objects: SavedObjectsBulkDeleteObject[],
options: SavedObjectsBulkDeleteOptions
): Promise<SavedObjectsBulkDeleteResponse> {
try {
const args = { objects, options };
await this.legacyEnsureAuthorized(
this.getUniqueObjectTypes(objects),
'bulk_delete',
options?.namespace,
{
args,
}
);
} catch (error) {
objects.forEach(({ type, id }) =>
this.auditLogger.log(
savedObjectEvent({
action: SavedObjectAction.DELETE,
savedObject: { type, id },
error,
})
)
);
throw error;
}
const response = await this.baseClient.bulkDelete(objects, options);
response?.statuses.forEach(({ id, type, success, error }) => {
const auditEventOutcome = success === true ? 'success' : 'failure';
const auditEventOutcomeError = error ? (error as unknown as Error) : undefined;
this.auditLogger.log(
savedObjectEvent({
action: SavedObjectAction.DELETE,
savedObject: { type, id },
outcome: auditEventOutcome,
error: auditEventOutcomeError,
})
);
});
return response;
}
public async find<T = unknown, A = unknown>(options: SavedObjectsFindOptions) {
if (
this.getSpacesService() == null &&

View file

@ -586,6 +586,41 @@ const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces';
});
});
describe('#bulkDelete', () => {
test(`throws error if options.namespace is specified`, async () => {
const { client } = createSpacesSavedObjectsClient();
await expect(
// @ts-expect-error
client.bulkDelete(null, { namespace: 'bar' })
).rejects.toThrow(ERROR_NAMESPACE_SPECIFIED);
});
test(`supplements options with the current namespace`, async () => {
const { client, baseClient } = createSpacesSavedObjectsClient();
const expectedReturnValue = { statuses: [{ id: 'id', type: 'type', success: true }] };
baseClient.bulkDelete.mockReturnValue(Promise.resolve(expectedReturnValue));
const actualReturnValue = await client.bulkDelete([{ id: 'id', type: 'foo' }], {
force: true,
});
expect(actualReturnValue).toBe(expectedReturnValue);
expect(baseClient.bulkDelete).toHaveBeenCalledWith(
[
{
id: 'id',
type: 'foo',
},
],
{
namespace: currentSpace.expectedNamespace,
force: true,
}
);
});
});
describe('#removeReferencesTo', () => {
test(`throws error if options.namespace is specified`, async () => {
const { client } = createSpacesSavedObjectsClient();

View file

@ -12,6 +12,8 @@ import type {
SavedObject,
SavedObjectsBaseOptions,
SavedObjectsBulkCreateObject,
SavedObjectsBulkDeleteObject,
SavedObjectsBulkDeleteOptions,
SavedObjectsBulkGetObject,
SavedObjectsBulkResolveObject,
SavedObjectsBulkUpdateObject,
@ -139,6 +141,17 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract {
});
}
async bulkDelete<T = unknown>(
objects: SavedObjectsBulkDeleteObject[] = [],
options: SavedObjectsBulkDeleteOptions = {}
) {
throwErrorIfNamespaceSpecified(options);
return await this.client.bulkDelete(objects, {
...options,
namespace: spaceIdToNamespace(this.spaceId),
});
}
async find<T = unknown, A = unknown>(options: SavedObjectsFindOptions) {
let namespaces: string[];
try {

View file

@ -0,0 +1,154 @@
/*
* 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 { SuperTest } from 'supertest';
import type { Client } from '@elastic/elasticsearch';
import expect from '@kbn/expect';
import type { SearchTotalHits } from '@elastic/elasticsearch/lib/api/types';
import { SAVED_OBJECT_TEST_CASES as CASES } from '../lib/saved_object_test_cases';
import { SPACES } from '../lib/spaces';
import { expectResponses, getUrlPrefix, getTestTitle } from '../lib/saved_object_test_utils';
import { ExpectResponseBody, TestCase, TestDefinition, TestSuite, TestUser } from '../lib/types';
export interface BulkDeleteTestDefinition extends TestDefinition {
request: { type: string; id: string; force?: boolean };
force?: boolean;
}
export type BulkDeleteTestSuite = TestSuite<BulkDeleteTestDefinition>;
export interface BulkDeleteTestCase extends TestCase {
force?: boolean;
failure?: 400 | 403 | 404;
}
const ALIAS_DELETE_INCLUSIVE = Object.freeze({
type: 'resolvetype',
id: 'alias-match-newid',
}); // exists in three specific spaces; deleting this should also delete the aliases that target it in the default space and space_1
const ALIAS_DELETE_EXCLUSIVE = Object.freeze({
type: 'resolvetype',
id: 'all_spaces',
}); // exists in all spaces; deleting this should also delete the aliases that target it in the default space and space_1
const DOES_NOT_EXIST = Object.freeze({ type: 'dashboard', id: 'does-not-exist' });
export const TEST_CASES: Record<string, BulkDeleteTestCase> = Object.freeze({
...CASES,
ALIAS_DELETE_INCLUSIVE,
ALIAS_DELETE_EXCLUSIVE,
DOES_NOT_EXIST,
});
/**
* Test cases have additional properties that we don't want to send in HTTP Requests
*/
const createRequest = ({ type, id, force }: BulkDeleteTestCase) => ({ type, id, force });
export function bulkDeleteTestSuiteFactory(es: Client, esArchiver: any, supertest: SuperTest<any>) {
const expectSavedObjectForbidden = expectResponses.forbiddenTypes('bulk_delete');
const expectResponseBody =
(testCase: BulkDeleteTestCase, statusCode: 200 | 403, user?: TestUser): ExpectResponseBody =>
async (response: Record<string, any>) => {
if (statusCode === 403) {
await expectSavedObjectForbidden(testCase.type)(response);
} else {
// permitted
const statuses = response.body.statuses;
expect(statuses).length([testCase].length);
for (let i = 0; i < statuses.length; i++) {
const object = statuses[i];
expect(object).to.have.keys(['id', 'type', 'success']);
if (testCase.failure) {
const { type, id } = testCase;
expect(object.type).to.eql(type);
expect(object.id).to.eql(id);
await expectResponses.permitted(object, testCase);
} else {
await es.indices.refresh({ index: '.kibana' }); // alias deletion uses refresh: false, so we need to manually refresh the index before searching
const searchResponse = await es.search({
index: '.kibana',
body: {
size: 0,
query: { terms: { type: ['legacy-url-alias'] } },
track_total_hits: true,
},
});
const expectAliasWasDeleted = !![ALIAS_DELETE_INCLUSIVE, ALIAS_DELETE_EXCLUSIVE].find(
({ type, id }) => testCase.type === type && testCase.id === id
);
// Eight aliases exist and they are all deleted in the bulk operation.
// The delete behavior for multinamespace objects shared to more than one space when using force is to delete the object from all the spaces it is shared to.
expect((searchResponse.hits.total as SearchTotalHits).value).to.eql(
expectAliasWasDeleted ? 6 : 8
);
}
}
}
};
const createTestDefinitions = (
testCases: BulkDeleteTestCase | BulkDeleteTestCase[],
forbidden: boolean,
options?: {
spaceId?: string;
responseBodyOverride?: ExpectResponseBody;
}
): BulkDeleteTestDefinition[] => {
let cases = Array.isArray(testCases) ? testCases : [testCases];
const responseStatusCode = forbidden ? 403 : 200;
if (forbidden) {
// override the expected result in each test case
cases = cases.map((x) => ({ ...x, failure: 403 }));
}
return cases.map((x) => ({
title: getTestTitle(x, responseStatusCode),
responseStatusCode,
request: createRequest(x),
responseBody: options?.responseBodyOverride || expectResponseBody(x, responseStatusCode),
}));
};
const makeBulkDeleteTest =
(describeFn: Mocha.SuiteFunction) => (description: string, definition: BulkDeleteTestSuite) => {
const { user, spaceId = SPACES.DEFAULT.spaceId, tests } = definition;
describeFn(description, () => {
before(() =>
esArchiver.load(
'x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces'
)
);
after(() =>
esArchiver.unload(
'x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces'
)
);
for (const test of tests) {
it(`should return ${test.responseStatusCode} ${test.title} `, async () => {
const { type: testType, id: testId, force: testForce } = test.request;
const requestBody = [{ type: testType, id: testId }];
const query = testForce && testForce === true ? '?force=true' : '';
await supertest
.post(`${getUrlPrefix(spaceId)}/api/saved_objects/_bulk_delete${query}`)
.auth(user?.username, user?.password)
.send(requestBody)
.expect(test.responseStatusCode)
.then(test.responseBody);
});
}
});
};
const addTests = makeBulkDeleteTest(describe);
// @ts-ignore
addTests.only = makeBulkDeleteTest(describe.only);
return {
addTests,
createTestDefinitions,
};
}

View file

@ -0,0 +1,105 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { SPACES } from '../../common/lib/spaces';
import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils';
import { TestUser } from '../../common/lib/types';
import { FtrProviderContext } from '../../common/ftr_provider_context';
import {
bulkDeleteTestSuiteFactory,
TEST_CASES as CASES,
BulkDeleteTestDefinition,
} from '../../common/suites/bulk_delete';
const {
DEFAULT: { spaceId: DEFAULT_SPACE_ID },
SPACE_1: { spaceId: SPACE_1_ID },
SPACE_2: { spaceId: SPACE_2_ID },
} = SPACES;
const { fail400, fail404 } = testCaseFailures;
const createTestCases = (spaceId: string) => {
// for each permitted (non-403) outcome, if failure !== undefined then we expect
// to receive an error; otherwise, we expect to receive a success result
const normalTypes = [
{ ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail404(spaceId !== DEFAULT_SPACE_ID) },
{ ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) },
{ ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) },
{ ...CASES.MULTI_NAMESPACE_ALL_SPACES, ...fail400() },
// try to delete this object again, this time using the `force` option
{ ...CASES.MULTI_NAMESPACE_ALL_SPACES, force: true },
{
...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1,
...fail400(spaceId === DEFAULT_SPACE_ID || spaceId === SPACE_1_ID),
...fail404(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID),
},
// try to delete this object again, this time using the `force` option
{
...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1,
force: true,
...fail404(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID),
},
{ ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) },
{ ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) },
{
...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE,
...fail404(spaceId !== DEFAULT_SPACE_ID),
},
{ ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) },
CASES.NAMESPACE_AGNOSTIC,
{ ...CASES.ALIAS_DELETE_INCLUSIVE, force: true },
{ ...CASES.ALIAS_DELETE_EXCLUSIVE, force: true },
{ ...CASES.DOES_NOT_EXIST, ...fail404() },
];
const hiddenType = [{ ...CASES.HIDDEN, ...fail400() }]; // this behavior diverges from `delete`, which throws 404
const allTypes = normalTypes.concat(hiddenType);
return { normalTypes, hiddenType, allTypes };
};
export default function ({ getService }: FtrProviderContext) {
const supertest = getService('supertestWithoutAuth');
const esArchiver = getService('esArchiver');
const es = getService('es');
const { addTests, createTestDefinitions } = bulkDeleteTestSuiteFactory(es, esArchiver, supertest);
const createTests = (spaceId: string) => {
const { normalTypes, hiddenType, allTypes } = createTestCases(spaceId);
return {
unauthorized: createTestDefinitions(allTypes, true, { spaceId }),
authorized: [
createTestDefinitions(normalTypes, false, { spaceId }),
createTestDefinitions(hiddenType, true, { spaceId }),
].flat(),
superuser: createTestDefinitions(allTypes, false, { spaceId }),
};
};
describe('_bulk_delete', () => {
getTestScenarios().securityAndSpaces.forEach(({ spaceId, users }) => {
const suffix = ` within the ${spaceId} space`;
const { unauthorized, authorized, superuser } = createTests(spaceId);
const _addTests = (user: TestUser, tests: BulkDeleteTestDefinition[]) => {
addTests(`${user.description}${suffix}`, { user, spaceId, tests });
};
[
users.noAccess,
users.legacyAll,
users.dualRead,
users.readGlobally,
users.readAtSpace,
users.allAtOtherSpace,
].forEach((user) => {
_addTests(user, unauthorized);
});
[users.dualAll, users.allGlobally, users.allAtSpace].forEach((user) => {
_addTests(user, authorized);
});
_addTests(users.superuser, superuser);
});
});
}

View file

@ -21,6 +21,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) {
loadTestFile(require.resolve('./bulk_get'));
loadTestFile(require.resolve('./bulk_update'));
loadTestFile(require.resolve('./bulk_resolve'));
loadTestFile(require.resolve('./bulk_delete'));
loadTestFile(require.resolve('./create'));
loadTestFile(require.resolve('./delete'));
loadTestFile(require.resolve('./export'));

View file

@ -0,0 +1,68 @@
/*
* 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 { SPACES } from '../../common/lib/spaces';
import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils';
import { FtrProviderContext } from '../../common/ftr_provider_context';
import { bulkDeleteTestSuiteFactory, TEST_CASES as CASES } from '../../common/suites/bulk_delete';
const {
DEFAULT: { spaceId: DEFAULT_SPACE_ID },
SPACE_1: { spaceId: SPACE_1_ID },
SPACE_2: { spaceId: SPACE_2_ID },
} = SPACES;
const { fail400, fail404 } = testCaseFailures;
const createTestCases = (spaceId: string) => [
{ ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail404(spaceId !== DEFAULT_SPACE_ID) },
{ ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) },
{ ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) },
{ ...CASES.MULTI_NAMESPACE_ALL_SPACES, ...fail400() },
// // try to delete this object again, this time using the `force` option
{ ...CASES.MULTI_NAMESPACE_ALL_SPACES, force: true },
{
...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1,
...fail400(spaceId === DEFAULT_SPACE_ID || spaceId === SPACE_1_ID),
...fail404(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID),
},
// try to delete this object again, this time using the `force` option
{
...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1,
force: true,
},
{ ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) },
{ ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) },
{
...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_DEFAULT_SPACE,
...fail404(spaceId !== DEFAULT_SPACE_ID),
},
{ ...CASES.MULTI_NAMESPACE_ISOLATED_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) },
CASES.NAMESPACE_AGNOSTIC,
{ ...CASES.ALIAS_DELETE_INCLUSIVE, force: true },
{ ...CASES.ALIAS_DELETE_EXCLUSIVE, force: true },
{ ...CASES.HIDDEN, ...fail400() },
{ ...CASES.DOES_NOT_EXIST, ...fail404() },
];
export default function ({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const esArchiver = getService('esArchiver');
const es = getService('es');
const { addTests, createTestDefinitions } = bulkDeleteTestSuiteFactory(es, esArchiver, supertest);
const createTests = (spaceId: string) => {
const testCases = createTestCases(spaceId);
return createTestDefinitions(testCases, false, { spaceId });
};
describe('_bulk_delete', () => {
getTestScenarios().spaces.forEach(({ spaceId }) => {
const tests = createTests(spaceId);
addTests(`within the ${spaceId} space`, { spaceId, tests });
});
});
}

View file

@ -15,6 +15,7 @@ export default function ({ loadTestFile }: FtrProviderContext) {
loadTestFile(require.resolve('./bulk_update'));
loadTestFile(require.resolve('./create'));
loadTestFile(require.resolve('./delete'));
loadTestFile(require.resolve('./bulk_delete'));
loadTestFile(require.resolve('./export'));
loadTestFile(require.resolve('./find'));
loadTestFile(require.resolve('./get'));