mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
Saved objects extensions refactor merge (#142878)
Merges the changes of #134395 into the new packages structure. Resolves #133835 ### Description This PR represents a fully manual merge of the saved objects refactor of client wrapper system into repository extensions. These changes are being manually merged due to significant changes of the saved objects implementation in the main branch, specifically the migration to the new packages structure. ### Other changes - Bulk Delete: bulk delete was implemented in parallel to #134395 being completed and this PR will refactor that API to utilize the new extensions Co-authored-by: Jeramy Soucy <jeramy.soucy@elastic.co> Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Aleh Zasypkin <aleh.zasypkin@gmail.com>
This commit is contained in:
parent
a67bfd10b2
commit
ff51407fdf
189 changed files with 11249 additions and 10090 deletions
|
@ -234,7 +234,9 @@ export function createPluginSetupContext<TPlugin, TPluginDependencies>(
|
|||
},
|
||||
savedObjects: {
|
||||
setClientFactoryProvider: deps.savedObjects.setClientFactoryProvider,
|
||||
addClientWrapper: deps.savedObjects.addClientWrapper,
|
||||
setEncryptionExtension: deps.savedObjects.setEncryptionExtension,
|
||||
setSecurityExtension: deps.savedObjects.setSecurityExtension,
|
||||
setSpacesExtension: deps.savedObjects.setSpacesExtension,
|
||||
registerType: deps.savedObjects.registerType,
|
||||
getKibanaIndex: deps.savedObjects.getKibanaIndex,
|
||||
},
|
||||
|
|
|
@ -8,7 +8,12 @@
|
|||
|
||||
import { SimpleSavedObject } from '../simple_saved_object';
|
||||
|
||||
/** @public */
|
||||
/**
|
||||
* Batch response for simple saved objects
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface SavedObjectsBatchResponse<T = unknown> {
|
||||
/** Array of simple saved objects */
|
||||
savedObjects: Array<SimpleSavedObject<T>>;
|
||||
}
|
||||
|
|
|
@ -9,18 +9,23 @@
|
|||
import type { SavedObjectsCreateOptions } from './create';
|
||||
|
||||
/**
|
||||
* @param type - Create a SavedObject of the given type
|
||||
* @param attributes - Create a SavedObject with the given attributes
|
||||
* Per-object parameters for bulk create operation
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface SavedObjectsBulkCreateObject<T = unknown> extends SavedObjectsCreateOptions {
|
||||
/** Create a SavedObject of this type. */
|
||||
type: string;
|
||||
/** Attributes for the saved object to be created. */
|
||||
attributes: T;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
/**
|
||||
* Options for bulk create operation
|
||||
*
|
||||
* @public
|
||||
* */
|
||||
export interface SavedObjectsBulkCreateOptions {
|
||||
/** If a document with the given `id` already exists, overwrite it's contents (default=false). */
|
||||
/** If a document with the given `id` already exists, overwrite its contents (default=false). */
|
||||
overwrite?: boolean;
|
||||
}
|
||||
|
|
|
@ -8,20 +8,38 @@
|
|||
|
||||
import { SavedObjectError } from '@kbn/core-saved-objects-common';
|
||||
|
||||
/** @public */
|
||||
/**
|
||||
* Options for bulk delete operation
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface SavedObjectsBulkDeleteOptions {
|
||||
/** Force deletion of any objects that exist in multiple namespaces (default=false) */
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
/**
|
||||
* Single item within the statuses array of the bulk delete response
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface SavedObjectsBulkDeleteResponseItem {
|
||||
/** saved object id */
|
||||
id: string;
|
||||
/** saved object type */
|
||||
type: string;
|
||||
/** true if the delete operation succeeded*/
|
||||
success: boolean;
|
||||
/** error from delete operation (undefined if no error) */
|
||||
error?: SavedObjectError;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
/**
|
||||
* Return type of the Saved Objects `bulkDelete()` method.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface SavedObjectsBulkDeleteResponse {
|
||||
/** array of statuses per object */
|
||||
statuses: SavedObjectsBulkDeleteResponseItem[];
|
||||
}
|
||||
|
|
|
@ -8,7 +8,12 @@
|
|||
|
||||
import type { ResolvedSimpleSavedObject } from './resolve';
|
||||
|
||||
/** @public */
|
||||
/**
|
||||
* Return type of the Saved Objects `bulkResolve()` method.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface SavedObjectsBulkResolveResponse<T = unknown> {
|
||||
/** Array of {@link ResolvedSimpleSavedObject} that were resolved */
|
||||
resolved_objects: Array<ResolvedSimpleSavedObject<T>>;
|
||||
}
|
||||
|
|
|
@ -8,16 +8,33 @@
|
|||
|
||||
import type { SavedObjectReference } from '@kbn/core-saved-objects-common';
|
||||
|
||||
/** @public */
|
||||
/**
|
||||
* Per-object parameters for bulk update operation
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface SavedObjectsBulkUpdateObject<T = unknown> {
|
||||
/** Type of the saved object to update */
|
||||
type: string;
|
||||
/** ID of the saved object to update */
|
||||
id: string;
|
||||
/** The attributes to update */
|
||||
attributes: T;
|
||||
/** The version string for the saved object */
|
||||
version?: string;
|
||||
/** Array of references to other saved objects */
|
||||
references?: SavedObjectReference[];
|
||||
}
|
||||
|
||||
/** @public */
|
||||
/**
|
||||
* Options for bulk update operation
|
||||
*
|
||||
* @public
|
||||
* */
|
||||
export interface SavedObjectsBulkUpdateOptions {
|
||||
/**
|
||||
* The namespace from which to apply the bulk update operation
|
||||
* Not permitted if spaces extension is enabled
|
||||
*/
|
||||
namespace?: string;
|
||||
}
|
||||
|
|
|
@ -11,7 +11,11 @@ import type {
|
|||
SavedObjectsMigrationVersion,
|
||||
} from '@kbn/core-saved-objects-common';
|
||||
|
||||
/** @public */
|
||||
/**
|
||||
* Options for creating a saved object.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface SavedObjectsCreateOptions {
|
||||
/**
|
||||
* (Not recommended) Specify an id instead of having the saved objects service generate one for you.
|
||||
|
@ -23,5 +27,6 @@ export interface SavedObjectsCreateOptions {
|
|||
migrationVersion?: SavedObjectsMigrationVersion;
|
||||
/** A semver value that is used when upgrading objects between Kibana versions. */
|
||||
coreMigrationVersion?: string;
|
||||
/** Array of referenced saved objects. */
|
||||
references?: SavedObjectReference[];
|
||||
}
|
||||
|
|
|
@ -6,8 +6,12 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
/** @public */
|
||||
/**
|
||||
* Options for deleting a saved object.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface SavedObjectsDeleteOptions {
|
||||
/** Force deletion of an object that exists in multiple namespaces */
|
||||
/** Force deletion of an object that exists in multiple namespaces (default=false) */
|
||||
force?: boolean;
|
||||
}
|
||||
|
|
|
@ -9,7 +9,11 @@
|
|||
import type { SavedObjectsFindOptions as SavedObjectFindOptionsServer } from '@kbn/core-saved-objects-api-server';
|
||||
import type { SavedObjectsBatchResponse } from './base';
|
||||
|
||||
export type { SavedObjectsFindOptionsReference } from '@kbn/core-saved-objects-api-server';
|
||||
|
||||
/**
|
||||
* Browser options for finding saved objects
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type SavedObjectsFindOptions = Omit<
|
||||
|
@ -24,16 +28,12 @@ export type SavedObjectsFindOptions = Omit<
|
|||
*/
|
||||
export interface SavedObjectsFindResponse<T = unknown, A = unknown>
|
||||
extends SavedObjectsBatchResponse<T> {
|
||||
/** aggregations from the search query */
|
||||
aggregations?: A;
|
||||
/** total number of results */
|
||||
total: number;
|
||||
/** number of results per page */
|
||||
perPage: number;
|
||||
/** current page in results*/
|
||||
page: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export interface SavedObjectsFindOptionsReference {
|
||||
type: string;
|
||||
id: string;
|
||||
}
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
*/
|
||||
|
||||
import type { SavedObjectsResolveResponse } from '@kbn/core-saved-objects-api-server';
|
||||
import { SimpleSavedObject } from '../simple_saved_object';
|
||||
import type { SimpleSavedObject } from '../simple_saved_object';
|
||||
|
||||
/**
|
||||
* This interface is a very simple wrapper for SavedObjects resolved from the server
|
||||
|
|
|
@ -8,9 +8,16 @@
|
|||
|
||||
import type { SavedObjectReference } from '@kbn/core-saved-objects-common';
|
||||
|
||||
/** @public */
|
||||
/**
|
||||
* Options for updating a saved object
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface SavedObjectsUpdateOptions<Attributes = unknown> {
|
||||
/** version of the saved object */
|
||||
version?: string;
|
||||
/** Alternative attributes for the saved object if upserting */
|
||||
upsert?: Attributes;
|
||||
/** Array of references to other saved objects */
|
||||
references?: SavedObjectReference[];
|
||||
}
|
||||
|
|
|
@ -33,7 +33,12 @@ import type { SimpleSavedObject } from './simple_saved_object';
|
|||
*/
|
||||
export interface SavedObjectsClientContract {
|
||||
/**
|
||||
* Persists an object
|
||||
* Creates an object
|
||||
*
|
||||
* @param {string} type - the type of object to create
|
||||
* @param {string} attributes - the attributes of the object
|
||||
* @param {string} options {@link SavedObjectsCreateOptions}
|
||||
* @returns The result of the create operation - the created saved object
|
||||
*/
|
||||
create<T = unknown>(
|
||||
type: string,
|
||||
|
@ -42,7 +47,10 @@ export interface SavedObjectsClientContract {
|
|||
): Promise<SimpleSavedObject<T>>;
|
||||
|
||||
/**
|
||||
* Creates multiple documents at once
|
||||
* Creates multiple objects at once
|
||||
*
|
||||
* @param {string} objects - an array of objects containing type, attributes
|
||||
* @param {string} options {@link SavedObjectsBulkCreateOptions}
|
||||
* @returns The result of the create operation containing created saved objects.
|
||||
*/
|
||||
bulkCreate(
|
||||
|
@ -52,6 +60,11 @@ export interface SavedObjectsClientContract {
|
|||
|
||||
/**
|
||||
* Deletes an object
|
||||
*
|
||||
* @param {string} type - the type the of object to delete
|
||||
* @param {string} id - the id of the object to delete
|
||||
* @param {string} options {@link SavedObjectsDeleteOptions}
|
||||
* @param {string} options.force - required to delete objects shared to multiple spaces
|
||||
*/
|
||||
delete(type: string, id: string, options?: SavedObjectsDeleteOptions): Promise<{}>;
|
||||
|
||||
|
@ -69,8 +82,8 @@ export interface SavedObjectsClientContract {
|
|||
/**
|
||||
* Search for objects
|
||||
*
|
||||
* @param {object} [options={}]
|
||||
* @property {string} options.type
|
||||
* @param {object} [options={}] {@link SavedObjectsFindOptions}
|
||||
* @property {string} options.type - the type or array of types to find
|
||||
* @property {string} options.search
|
||||
* @property {string} options.searchFields - see Elasticsearch Simple Query String
|
||||
* Query field argument for more information
|
||||
|
@ -87,8 +100,8 @@ export interface SavedObjectsClientContract {
|
|||
/**
|
||||
* Fetches a single object
|
||||
*
|
||||
* @param {string} type
|
||||
* @param {string} id
|
||||
* @param {string} type - the type of the object to get
|
||||
* @param {string} id - the ID of the object to get
|
||||
* @returns The saved object for the given type and id.
|
||||
*/
|
||||
get<T = unknown>(type: string, id: string): Promise<SimpleSavedObject<T>>;
|
||||
|
@ -110,8 +123,8 @@ export interface SavedObjectsClientContract {
|
|||
/**
|
||||
* Resolves a single object
|
||||
*
|
||||
* @param {string} type
|
||||
* @param {string} id
|
||||
* @param {string} type - the type of the object to resolve
|
||||
* @param {string} id - the ID of the object to resolve
|
||||
* @returns The resolve result for the saved object for the given type and id.
|
||||
*
|
||||
* @note Saved objects that Kibana fails to find are replaced with an error object and an "exactMatch" outcome. The rationale behind the
|
||||
|
@ -144,13 +157,13 @@ export interface SavedObjectsClientContract {
|
|||
/**
|
||||
* Updates an object
|
||||
*
|
||||
* @param {string} type
|
||||
* @param {string} id
|
||||
* @param {object} attributes
|
||||
* @param {object} options
|
||||
* @param {string} type - the type of the object to update
|
||||
* @param {string} id - the ID of the object to update
|
||||
* @param {object} attributes - the attributes to update
|
||||
* @param {object} options {@link SavedObjectsUpdateOptions}
|
||||
* @prop {integer} options.version - ensures version matches that of persisted object
|
||||
* @prop {object} options.migrationVersion - The optional migrationVersion of this document
|
||||
* @returns
|
||||
* @returns the udpated simple saved object
|
||||
*/
|
||||
update<T = unknown>(
|
||||
type: string,
|
||||
|
@ -162,8 +175,8 @@ export interface SavedObjectsClientContract {
|
|||
/**
|
||||
* Update multiple documents at once
|
||||
*
|
||||
* @param {array} objects - [{ type, id, attributes, options: { version, references } }]
|
||||
* @returns The result of the update operation containing both failed and updated saved objects.
|
||||
* @param {array} objects - an array of objects containing type, id, attributes, and references
|
||||
* @returns the result of the bulk update operation containing both failed and updated saved objects.
|
||||
*/
|
||||
bulkUpdate<T = unknown>(
|
||||
objects: SavedObjectsBulkUpdateObject[]
|
||||
|
|
|
@ -18,15 +18,25 @@ import type { SavedObject as SavedObjectType } from '@kbn/core-saved-objects-com
|
|||
* @public
|
||||
*/
|
||||
export interface SimpleSavedObject<T = unknown> {
|
||||
/** attributes of the object, templated */
|
||||
attributes: T;
|
||||
/** version of the saved object */
|
||||
_version?: SavedObjectType<T>['version'];
|
||||
/** ID of the saved object */
|
||||
id: SavedObjectType<T>['id'];
|
||||
/** Type of the saved object */
|
||||
type: SavedObjectType<T>['type'];
|
||||
/** Migration version of the saved object */
|
||||
migrationVersion: SavedObjectType<T>['migrationVersion'];
|
||||
/** Core migration version of the saved object */
|
||||
coreMigrationVersion: SavedObjectType<T>['coreMigrationVersion'];
|
||||
/** Error associated with this object, undefined if no error */
|
||||
error: SavedObjectType<T>['error'];
|
||||
/** References to other saved objects */
|
||||
references: SavedObjectType<T>['references'];
|
||||
/** The date this object was last updated */
|
||||
updatedAt: SavedObjectType<T>['updated_at'];
|
||||
/** The date this object was created */
|
||||
createdAt: SavedObjectType<T>['created_at'];
|
||||
/**
|
||||
* Space(s) that this saved object exists in. This attribute is not used for "global" saved object types which are registered with
|
||||
|
@ -34,13 +44,38 @@ export interface SimpleSavedObject<T = unknown> {
|
|||
*/
|
||||
namespaces: SavedObjectType<T>['namespaces'];
|
||||
|
||||
/**
|
||||
* Gets an attribute of this object
|
||||
*
|
||||
* @param {string} key - the name of the attribute
|
||||
* @returns The value of the attribute.
|
||||
*/
|
||||
get(key: string): any;
|
||||
|
||||
/**
|
||||
* Sets an attribute of this object
|
||||
*
|
||||
* @param {string} key - the name of the attribute
|
||||
* @param {string} value - the value for the attribute
|
||||
* @returns The updated attributes of this object.
|
||||
*/
|
||||
set(key: string, value: any): T;
|
||||
|
||||
/**
|
||||
* Checks if this object has an attribute
|
||||
*
|
||||
* @param {string} key - the name of the attribute
|
||||
* @returns true if the attribute exists.
|
||||
*/
|
||||
has(key: string): boolean;
|
||||
|
||||
/**
|
||||
* Saves this object
|
||||
*/
|
||||
save(): Promise<SimpleSavedObject<T>>;
|
||||
|
||||
/**
|
||||
* Deletes this object
|
||||
*/
|
||||
delete(): Promise<{}>;
|
||||
}
|
||||
|
|
|
@ -22,10 +22,24 @@ import { SavedObjectsSerializer } from '@kbn/core-saved-objects-base-server-inte
|
|||
import { typeRegistryMock } from '@kbn/core-saved-objects-base-server-mocks';
|
||||
import {
|
||||
ALIAS_OR_SHARED_ORIGIN_SEARCH_PER_PAGE,
|
||||
CollectMultiNamespaceReferencesParams,
|
||||
type CollectMultiNamespaceReferencesParams,
|
||||
} from './collect_multi_namespace_references';
|
||||
import { collectMultiNamespaceReferences } from './collect_multi_namespace_references';
|
||||
import type { CreatePointInTimeFinderFn } from './point_in_time_finder';
|
||||
import { AuditAction, type ISavedObjectsSecurityExtension } from '@kbn/core-saved-objects-server';
|
||||
|
||||
import {
|
||||
authMap,
|
||||
enforceError,
|
||||
typeMapsAreEqual,
|
||||
setsAreEqual,
|
||||
setupCheckAuthorized,
|
||||
setupCheckUnauthorized,
|
||||
setupEnforceFailure,
|
||||
setupEnforceSuccess,
|
||||
setupRedactPassthrough,
|
||||
} from '../test_helpers/repository.test.common';
|
||||
import { savedObjectsExtensionsMock } from '../mocks/saved_objects_extensions.mock';
|
||||
|
||||
const SPACES = ['default', 'another-space'];
|
||||
const VERSION_PROPS = { _seq_no: 1, _primary_term: 1 };
|
||||
|
@ -50,7 +64,8 @@ describe('collectMultiNamespaceReferences', () => {
|
|||
/** Sets up the type registry, saved objects client, etc. and return the full parameters object to be passed to `collectMultiNamespaceReferences` */
|
||||
function setup(
|
||||
objects: SavedObjectsCollectMultiNamespaceReferencesObject[],
|
||||
options: SavedObjectsCollectMultiNamespaceReferencesOptions = {}
|
||||
options: SavedObjectsCollectMultiNamespaceReferencesOptions = {},
|
||||
securityExtension?: ISavedObjectsSecurityExtension | undefined
|
||||
): CollectMultiNamespaceReferencesParams {
|
||||
const registry = typeRegistryMock.create();
|
||||
registry.isMultiNamespace.mockImplementation(
|
||||
|
@ -65,8 +80,8 @@ describe('collectMultiNamespaceReferences', () => {
|
|||
(type) => [MULTI_NAMESPACE_OBJ_TYPE_1, MULTI_NAMESPACE_HIDDEN_OBJ_TYPE].includes(type) // MULTI_NAMESPACE_OBJ_TYPE_2 and NON_MULTI_NAMESPACE_TYPE are omitted
|
||||
);
|
||||
client = elasticsearchClientMock.createElasticsearchClient();
|
||||
|
||||
const serializer = new SavedObjectsSerializer(registry);
|
||||
|
||||
return {
|
||||
registry,
|
||||
allowedTypes: [
|
||||
|
@ -78,6 +93,7 @@ describe('collectMultiNamespaceReferences', () => {
|
|||
serializer,
|
||||
getIndexForType: (type: string) => `index-for-${type}`,
|
||||
createPointInTimeFinder: jest.fn() as CreatePointInTimeFinderFn,
|
||||
securityExtension,
|
||||
objects,
|
||||
options,
|
||||
};
|
||||
|
@ -287,6 +303,7 @@ describe('collectMultiNamespaceReferences', () => {
|
|||
// obj3 is excluded from the results
|
||||
]);
|
||||
});
|
||||
|
||||
it(`handles 404 responses that don't come from Elasticsearch`, async () => {
|
||||
const createEsUnavailableNotFoundError = () => {
|
||||
return SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError();
|
||||
|
@ -447,4 +464,213 @@ describe('collectMultiNamespaceReferences', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with security enabled', () => {
|
||||
const mockSecurityExt = savedObjectsExtensionsMock.createSecurityExtension();
|
||||
const obj1 = { type: MULTI_NAMESPACE_OBJ_TYPE_1, id: 'id-1' };
|
||||
const obj2 = { type: MULTI_NAMESPACE_OBJ_TYPE_1, id: 'id-2' };
|
||||
const obj3 = { type: MULTI_NAMESPACE_OBJ_TYPE_1, id: 'id-3' };
|
||||
const objects = [obj1, obj2];
|
||||
const obj1LegacySpaces = ['space-1', 'space-2', 'space-3', 'space-4'];
|
||||
let params: CollectMultiNamespaceReferencesParams;
|
||||
|
||||
beforeEach(() => {
|
||||
params = setup([obj1, obj2], {}, mockSecurityExt);
|
||||
mockMgetResults({ found: true, references: [obj3] }, { found: true, references: [] }); // results for obj1 and obj2
|
||||
mockMgetResults({ found: true, references: [] }); // results for obj3
|
||||
mockFindLegacyUrlAliases.mockResolvedValue(
|
||||
new Map([
|
||||
[`${obj1.type}:${obj1.id}`, new Set(obj1LegacySpaces)],
|
||||
// the result map does not contain keys for obj2 or obj3 because we did not find any aliases for those objects
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockSecurityExt.checkAuthorization.mockReset();
|
||||
mockSecurityExt.enforceAuthorization.mockReset();
|
||||
mockSecurityExt.redactNamespaces.mockReset();
|
||||
mockSecurityExt.addAuditEvent.mockReset();
|
||||
});
|
||||
|
||||
describe(`errors`, () => {
|
||||
test(`propagates decorated error when not authorized`, async () => {
|
||||
setupCheckUnauthorized(mockSecurityExt);
|
||||
// Unlike other functions, it doesn't validate the level of authorization first, so we need to
|
||||
// carry on and mock the enforce function as well to create an unauthorized condition
|
||||
setupEnforceFailure(mockSecurityExt);
|
||||
|
||||
await expect(collectMultiNamespaceReferences(params)).rejects.toThrow(enforceError);
|
||||
expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1);
|
||||
expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test(`adds audit event per object when not successful`, async () => {
|
||||
setupCheckUnauthorized(mockSecurityExt);
|
||||
// Unlike other functions, it doesn't validate the level of authorization first, so we need to
|
||||
// carry on and mock the enforce function as well to create an unauthorized condition
|
||||
setupEnforceFailure(mockSecurityExt);
|
||||
|
||||
await expect(collectMultiNamespaceReferences(params)).rejects.toThrow(enforceError);
|
||||
expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1);
|
||||
expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledTimes(objects.length);
|
||||
objects.forEach((obj) => {
|
||||
expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledWith({
|
||||
action: AuditAction.COLLECT_MULTINAMESPACE_REFERENCES,
|
||||
savedObject: { type: obj.type, id: obj.id },
|
||||
error: enforceError,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('checks privileges', () => {
|
||||
beforeEach(() => {
|
||||
setupCheckUnauthorized(mockSecurityExt);
|
||||
setupEnforceFailure(mockSecurityExt);
|
||||
});
|
||||
test(`in the default state`, async () => {
|
||||
await expect(collectMultiNamespaceReferences(params)).rejects.toThrow(enforceError);
|
||||
|
||||
expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1);
|
||||
const expectedSpaces = new Set(['default', ...SPACES, ...obj1LegacySpaces]);
|
||||
const { spaces: actualSpaces } = mockSecurityExt.checkAuthorization.mock.calls[0][0];
|
||||
expect(setsAreEqual(actualSpaces, expectedSpaces)).toBeTruthy();
|
||||
|
||||
expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1);
|
||||
const expectedTypesAndSpaces = new Map([[objects[0].type, new Set(['default'])]]);
|
||||
const { typesAndSpaces: actualTypesAndSpaces } =
|
||||
mockSecurityExt.enforceAuthorization.mock.calls[0][0];
|
||||
|
||||
expect(typeMapsAreEqual(actualTypesAndSpaces, expectedTypesAndSpaces)).toBeTruthy();
|
||||
});
|
||||
|
||||
test(`in a non-default state`, async () => {
|
||||
const namespace = 'space-X';
|
||||
await expect(
|
||||
collectMultiNamespaceReferences({ ...params, options: { namespace } })
|
||||
).rejects.toThrow(enforceError);
|
||||
|
||||
expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1);
|
||||
const expectedSpaces = new Set([namespace, ...SPACES, ...obj1LegacySpaces]);
|
||||
const { spaces: actualSpaces } = mockSecurityExt.checkAuthorization.mock.calls[0][0];
|
||||
expect(setsAreEqual(actualSpaces, expectedSpaces)).toBeTruthy();
|
||||
|
||||
expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1);
|
||||
const expectedTypesAndSpaces = new Map([[objects[0].type, new Set([namespace])]]);
|
||||
const { typesAndSpaces: actualTypesAndSpaces } =
|
||||
mockSecurityExt.enforceAuthorization.mock.calls[0][0];
|
||||
|
||||
expect(typeMapsAreEqual(actualTypesAndSpaces, expectedTypesAndSpaces)).toBeTruthy();
|
||||
});
|
||||
|
||||
test(`with purpose 'collectMultiNamespaceReferences'`, async () => {
|
||||
const options: SavedObjectsCollectMultiNamespaceReferencesOptions = {
|
||||
purpose: 'collectMultiNamespaceReferences',
|
||||
};
|
||||
|
||||
setupCheckUnauthorized(mockSecurityExt);
|
||||
setupEnforceFailure(mockSecurityExt);
|
||||
|
||||
await expect(collectMultiNamespaceReferences({ ...params, options })).rejects.toThrow(
|
||||
enforceError
|
||||
);
|
||||
expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1);
|
||||
expect(mockSecurityExt.checkAuthorization).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
actions: new Set(['bulk_get']),
|
||||
})
|
||||
);
|
||||
expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test(`with purpose 'updateObjectsSpaces'`, async () => {
|
||||
const options: SavedObjectsCollectMultiNamespaceReferencesOptions = {
|
||||
purpose: 'updateObjectsSpaces',
|
||||
};
|
||||
|
||||
setupCheckUnauthorized(mockSecurityExt);
|
||||
setupEnforceFailure(mockSecurityExt);
|
||||
|
||||
await expect(collectMultiNamespaceReferences({ ...params, options })).rejects.toThrow(
|
||||
enforceError
|
||||
);
|
||||
expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1);
|
||||
expect(mockSecurityExt.checkAuthorization).toBeCalledWith(
|
||||
expect.objectContaining({
|
||||
actions: new Set(['share_to_space']),
|
||||
})
|
||||
);
|
||||
expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('success', () => {
|
||||
beforeEach(async () => {
|
||||
setupCheckAuthorized(mockSecurityExt);
|
||||
setupEnforceSuccess(mockSecurityExt);
|
||||
setupRedactPassthrough(mockSecurityExt);
|
||||
await collectMultiNamespaceReferences(params);
|
||||
});
|
||||
test(`calls redactNamespaces with type, spaces, and authorization map`, async () => {
|
||||
expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1);
|
||||
const expectedSpaces = new Set(['default', ...SPACES, ...obj1LegacySpaces]);
|
||||
const { spaces: actualSpaces } = mockSecurityExt.checkAuthorization.mock.calls[0][0];
|
||||
expect(setsAreEqual(actualSpaces, expectedSpaces)).toBeTruthy();
|
||||
|
||||
const resultObjects = [obj1, obj2, obj3];
|
||||
|
||||
// enforce is called once for all objects/spaces, then once per object
|
||||
expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(
|
||||
1 + resultObjects.length
|
||||
);
|
||||
const expectedTypesAndSpaces = new Map([[objects[0].type, new Set(['default'])]]);
|
||||
const { typesAndSpaces: actualTypesAndSpaces } =
|
||||
mockSecurityExt.enforceAuthorization.mock.calls[0][0];
|
||||
expect(typeMapsAreEqual(actualTypesAndSpaces, expectedTypesAndSpaces)).toBeTruthy();
|
||||
|
||||
// Redact is called once per object, but an additional time for object 1 because it has legacy URL aliases in another set of spaces
|
||||
expect(mockSecurityExt.redactNamespaces).toBeCalledTimes(resultObjects.length + 1);
|
||||
const expectedRedactParams = [
|
||||
{ type: obj1.type, spaces: SPACES },
|
||||
{ type: obj1.type, spaces: obj1LegacySpaces },
|
||||
{ type: obj2.type, spaces: SPACES },
|
||||
{ type: obj3.type, spaces: SPACES },
|
||||
];
|
||||
|
||||
expectedRedactParams.forEach((expected, i) => {
|
||||
const { savedObject, typeMap } = mockSecurityExt.redactNamespaces.mock.calls[i][0];
|
||||
expect(savedObject).toEqual(
|
||||
expect.objectContaining({
|
||||
type: expected.type,
|
||||
namespaces: expected.spaces,
|
||||
})
|
||||
);
|
||||
expect(typeMap).toBe(authMap);
|
||||
});
|
||||
});
|
||||
|
||||
test(`adds audit event per object when successful`, async () => {
|
||||
expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1);
|
||||
|
||||
const resultObjects = [obj1, obj2, obj3];
|
||||
|
||||
// enforce is called once for all objects/spaces, then once per object
|
||||
expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(
|
||||
1 + resultObjects.length
|
||||
);
|
||||
|
||||
expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledTimes(resultObjects.length);
|
||||
resultObjects.forEach((obj) => {
|
||||
expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledWith({
|
||||
action: AuditAction.COLLECT_MULTINAMESPACE_REFERENCES,
|
||||
savedObject: { type: obj.type, id: obj.id },
|
||||
error: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -14,8 +14,12 @@ import type {
|
|||
SavedObjectsCollectMultiNamespaceReferencesResponse,
|
||||
SavedObjectReferenceWithContext,
|
||||
} from '@kbn/core-saved-objects-api-server';
|
||||
import type { ISavedObjectTypeRegistry } from '@kbn/core-saved-objects-server';
|
||||
import { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-utils-server';
|
||||
import {
|
||||
AuditAction,
|
||||
type ISavedObjectsSecurityExtension,
|
||||
type ISavedObjectTypeRegistry,
|
||||
} from '@kbn/core-saved-objects-server';
|
||||
import { SavedObjectsErrorHelpers, SavedObjectsUtils } from '@kbn/core-saved-objects-utils-server';
|
||||
import {
|
||||
type SavedObjectsSerializer,
|
||||
getObjectKey,
|
||||
|
@ -54,6 +58,7 @@ export interface CollectMultiNamespaceReferencesParams {
|
|||
serializer: SavedObjectsSerializer;
|
||||
getIndexForType: (type: string) => string;
|
||||
createPointInTimeFinder: CreatePointInTimeFinderFn;
|
||||
securityExtension: ISavedObjectsSecurityExtension | undefined;
|
||||
objects: SavedObjectsCollectMultiNamespaceReferencesObject[];
|
||||
options?: SavedObjectsCollectMultiNamespaceReferencesOptions;
|
||||
}
|
||||
|
@ -94,17 +99,17 @@ export async function collectMultiNamespaceReferences(
|
|||
};
|
||||
});
|
||||
|
||||
const objectsToFindAliasesFor = objectsWithContext
|
||||
.filter(({ spaces }) => spaces.length !== 0)
|
||||
.map(({ type, id }) => ({ type, id }));
|
||||
const foundObjects = objectsWithContext.filter(({ spaces }) => spaces.length !== 0); // Any objects that have a non-empty `spaces` field are "found"
|
||||
const objectsToFindAliasesFor = foundObjects.map(({ type, id }) => ({ type, id }));
|
||||
const aliasesMap = await findLegacyUrlAliases(
|
||||
createPointInTimeFinder,
|
||||
objectsToFindAliasesFor,
|
||||
ALIAS_OR_SHARED_ORIGIN_SEARCH_PER_PAGE
|
||||
);
|
||||
const objectOriginsToSearchFor = objectsWithContext
|
||||
.filter(({ spaces }) => spaces.length !== 0)
|
||||
.map(({ type, id, originId }) => ({ type, origin: originId || id }));
|
||||
const objectOriginsToSearchFor = foundObjects.map(({ type, id, originId }) => ({
|
||||
type,
|
||||
origin: originId || id,
|
||||
}));
|
||||
const originsMap = await findSharedOriginObjects(
|
||||
createPointInTimeFinder,
|
||||
objectOriginsToSearchFor,
|
||||
|
@ -118,8 +123,12 @@ export async function collectMultiNamespaceReferences(
|
|||
return { ...obj, spacesWithMatchingAliases, spacesWithMatchingOrigins };
|
||||
});
|
||||
|
||||
// Now that we have *all* information for the object graph, if the Security extension is enabled, we can: check/enforce authorization,
|
||||
// write audit events, filter the object graph, and redact spaces from the objects.
|
||||
const filteredAndRedactedResults = await optionallyUseSecurity(results, params);
|
||||
|
||||
return {
|
||||
objects: results,
|
||||
objects: filteredAndRedactedResults,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -211,3 +220,196 @@ async function getObjectsAndReferences({
|
|||
|
||||
return { objectMap, inboundReferencesMap };
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks/enforces authorization, writes audit events, filters the object graph, and redacts spaces from the share_to_space/bulk_get
|
||||
* response. In other SavedObjectsRepository functions we do this before decrypting attributes. However, because of the
|
||||
* share_to_space/bulk_get response logic involved in deciding between the exact match or alias match, it's cleaner to do authorization,
|
||||
* auditing, filtering, and redaction all afterwards.
|
||||
*/
|
||||
async function optionallyUseSecurity(
|
||||
objectsWithContext: SavedObjectReferenceWithContext[],
|
||||
params: CollectMultiNamespaceReferencesParams
|
||||
) {
|
||||
const { securityExtension, objects, options = {} } = params;
|
||||
const { purpose, namespace } = options;
|
||||
const namespaceString = SavedObjectsUtils.namespaceIdToString(namespace);
|
||||
if (!securityExtension) {
|
||||
return objectsWithContext;
|
||||
}
|
||||
|
||||
// Check authorization based on all *found* object types / spaces
|
||||
const typesToAuthorize = new Set<string>();
|
||||
const spacesToAuthorize = new Set<string>([namespaceString]);
|
||||
const addSpacesToAuthorize = (spaces: string[] = []) => {
|
||||
for (const space of spaces) spacesToAuthorize.add(space);
|
||||
};
|
||||
for (const obj of objectsWithContext) {
|
||||
typesToAuthorize.add(obj.type);
|
||||
addSpacesToAuthorize(obj.spaces);
|
||||
addSpacesToAuthorize(obj.spacesWithMatchingAliases);
|
||||
addSpacesToAuthorize(obj.spacesWithMatchingOrigins);
|
||||
}
|
||||
const action =
|
||||
purpose === 'updateObjectsSpaces' ? ('share_to_space' as const) : ('bulk_get' as const);
|
||||
const { typeMap } = await securityExtension.checkAuthorization({
|
||||
types: typesToAuthorize,
|
||||
spaces: spacesToAuthorize,
|
||||
actions: new Set([action]),
|
||||
});
|
||||
|
||||
// Enforce authorization based on all *requested* object types and the current space
|
||||
const typesAndSpaces = objects.reduce(
|
||||
(acc, { type }) => (acc.has(type) ? acc : acc.set(type, new Set([namespaceString]))), // Always enforce authZ for the active space
|
||||
new Map<string, Set<string>>()
|
||||
);
|
||||
securityExtension!.enforceAuthorization({
|
||||
typesAndSpaces,
|
||||
action,
|
||||
typeMap,
|
||||
auditCallback: (error) => {
|
||||
if (!error) return; // We will audit success results below, after redaction
|
||||
for (const { type, id } of objects) {
|
||||
securityExtension!.addAuditEvent({
|
||||
action: AuditAction.COLLECT_MULTINAMESPACE_REFERENCES,
|
||||
savedObject: { type, id },
|
||||
error,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Now, filter/redact the results. Most SOR functions just redact the `namespaces` field from each returned object. However, this function
|
||||
// will actually filter the returned object graph itself.
|
||||
// This is done in two steps: (1) objects which the user can't access *in this space* are filtered from the graph, and the
|
||||
// graph is rearranged to avoid leaking information. (2) any spaces that the user can't access are redacted from each individual object.
|
||||
// After we finish filtering, we can write audit events for each object that is going to be returned to the user.
|
||||
const requestedObjectsSet = objects.reduce(
|
||||
(acc, { type, id }) => acc.add(`${type}:${id}`),
|
||||
new Set<string>()
|
||||
);
|
||||
const retrievedObjectsSet = objectsWithContext.reduce(
|
||||
(acc, { type, id }) => acc.add(`${type}:${id}`),
|
||||
new Set<string>()
|
||||
);
|
||||
const traversedObjects = new Set<string>();
|
||||
const filteredObjectsMap = new Map<string, SavedObjectReferenceWithContext>();
|
||||
const getIsAuthorizedForInboundReference = (inbound: { type: string; id: string }) => {
|
||||
const found = filteredObjectsMap.get(`${inbound.type}:${inbound.id}`);
|
||||
return found && !found.isMissing; // If true, this object can be linked back to one of the requested objects
|
||||
};
|
||||
let objectsToProcess = [...objectsWithContext];
|
||||
while (objectsToProcess.length > 0) {
|
||||
const obj = objectsToProcess.shift()!;
|
||||
const { type, id, spaces, inboundReferences } = obj;
|
||||
const objKey = `${type}:${id}`;
|
||||
traversedObjects.add(objKey);
|
||||
// Is the user authorized to access this object in this space?
|
||||
let isAuthorizedForObject = true;
|
||||
try {
|
||||
securityExtension.enforceAuthorization({
|
||||
typesAndSpaces: new Map([[type, new Set([namespaceString])]]),
|
||||
action,
|
||||
typeMap,
|
||||
});
|
||||
} catch (err) {
|
||||
isAuthorizedForObject = false;
|
||||
}
|
||||
// Redact the inbound references so we don't leak any info about other objects that the user is not authorized to access
|
||||
const redactedInboundReferences = inboundReferences.filter((inbound) => {
|
||||
if (inbound.type === type && inbound.id === id) {
|
||||
// circular reference, don't redact it
|
||||
return true;
|
||||
}
|
||||
return getIsAuthorizedForInboundReference(inbound);
|
||||
});
|
||||
// If the user is not authorized to access at least one inbound reference of this object, then we should omit this object.
|
||||
const isAuthorizedForGraph =
|
||||
requestedObjectsSet.has(objKey) || // If true, this is one of the requested objects, and we checked authorization above
|
||||
redactedInboundReferences.some(getIsAuthorizedForInboundReference);
|
||||
|
||||
if (isAuthorizedForObject && isAuthorizedForGraph) {
|
||||
if (spaces.length) {
|
||||
// Only generate success audit records for "non-empty results" with 1+ spaces
|
||||
// ("empty result" means the object was a non-multi-namespace type, or hidden type, or not found)
|
||||
securityExtension.addAuditEvent({
|
||||
action: AuditAction.COLLECT_MULTINAMESPACE_REFERENCES,
|
||||
savedObject: { type, id },
|
||||
});
|
||||
}
|
||||
filteredObjectsMap.set(objKey, obj);
|
||||
} else if (!isAuthorizedForObject && isAuthorizedForGraph) {
|
||||
filteredObjectsMap.set(objKey, { ...obj, spaces: [], isMissing: true });
|
||||
} else if (isAuthorizedForObject && !isAuthorizedForGraph) {
|
||||
const hasUntraversedInboundReferences = inboundReferences.some(
|
||||
(ref) =>
|
||||
!traversedObjects.has(`${ref.type}:${ref.id}`) &&
|
||||
retrievedObjectsSet.has(`${ref.type}:${ref.id}`)
|
||||
);
|
||||
|
||||
if (hasUntraversedInboundReferences) {
|
||||
// this object has inbound reference(s) that we haven't traversed yet; bump it to the back of the list
|
||||
objectsToProcess = [...objectsToProcess, obj];
|
||||
} else {
|
||||
// There should never be a missing inbound reference.
|
||||
// If there is, then something has gone terribly wrong.
|
||||
const missingInboundReference = inboundReferences.find(
|
||||
(ref) =>
|
||||
!traversedObjects.has(`${ref.type}:${ref.id}`) &&
|
||||
!retrievedObjectsSet.has(`${ref.type}:${ref.id}`)
|
||||
);
|
||||
|
||||
if (missingInboundReference) {
|
||||
throw new Error(
|
||||
`Unexpected inbound reference to "${missingInboundReference.type}:${missingInboundReference.id}"`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const filteredAndRedactedObjects = [
|
||||
...filteredObjectsMap.values(),
|
||||
].map<SavedObjectReferenceWithContext>((obj) => {
|
||||
const {
|
||||
type,
|
||||
id,
|
||||
spaces,
|
||||
spacesWithMatchingAliases,
|
||||
spacesWithMatchingOrigins,
|
||||
inboundReferences,
|
||||
} = obj;
|
||||
// Redact the inbound references so we don't leak any info about other objects that the user is not authorized to access
|
||||
const redactedInboundReferences = inboundReferences.filter((inbound) => {
|
||||
if (inbound.type === type && inbound.id === id) {
|
||||
// circular reference, don't redact it
|
||||
return true;
|
||||
}
|
||||
return getIsAuthorizedForInboundReference(inbound);
|
||||
});
|
||||
|
||||
/** Simple wrapper for the `redactNamespaces` function that expects a saved object in its params. */
|
||||
const getRedactedSpaces = (spacesArray: string[] | undefined) => {
|
||||
if (!spacesArray) return;
|
||||
const savedObject = { type, namespaces: spacesArray } as SavedObject; // Other SavedObject attributes aren't required
|
||||
const result = securityExtension.redactNamespaces({ savedObject, typeMap });
|
||||
return result.namespaces;
|
||||
};
|
||||
const redactedSpaces = getRedactedSpaces(spaces)!;
|
||||
const redactedSpacesWithMatchingAliases = getRedactedSpaces(spacesWithMatchingAliases);
|
||||
const redactedSpacesWithMatchingOrigins = getRedactedSpaces(spacesWithMatchingOrigins);
|
||||
return {
|
||||
...obj,
|
||||
spaces: redactedSpaces,
|
||||
...(redactedSpacesWithMatchingAliases && {
|
||||
spacesWithMatchingAliases: redactedSpacesWithMatchingAliases,
|
||||
}),
|
||||
...(redactedSpacesWithMatchingOrigins && {
|
||||
spacesWithMatchingOrigins: redactedSpacesWithMatchingOrigins,
|
||||
}),
|
||||
inboundReferences: redactedInboundReferences,
|
||||
};
|
||||
});
|
||||
|
||||
return filteredAndRedactedObjects;
|
||||
}
|
||||
|
|
|
@ -6,10 +6,11 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import type { DeeplyMockedKeys } from '@kbn/utility-types-jest';
|
||||
import type { CreatePointInTimeFinderFn, PointInTimeFinder } from './point_in_time_finder';
|
||||
import { DeeplyMockedKeys } from '@kbn/utility-types-jest';
|
||||
import { CreatePointInTimeFinderFn, PointInTimeFinder } from './point_in_time_finder';
|
||||
import { savedObjectsPointInTimeFinderMock } from '../mocks/point_in_time_finder.mock';
|
||||
import { findSharedOriginObjects } from './find_shared_origin_objects';
|
||||
import { SavedObjectsPointInTimeFinderClient } from '@kbn/core-saved-objects-api-server';
|
||||
|
||||
interface MockFindResultParams {
|
||||
type: string;
|
||||
|
@ -19,16 +20,13 @@ interface MockFindResultParams {
|
|||
}
|
||||
|
||||
describe('findSharedOriginObjects', () => {
|
||||
let savedObjectsMock: ReturnType<typeof savedObjectsPointInTimeFinderMock.createClient>;
|
||||
let pitFinderClientMock: jest.Mocked<SavedObjectsPointInTimeFinderClient>;
|
||||
let pointInTimeFinder: DeeplyMockedKeys<PointInTimeFinder>;
|
||||
let createPointInTimeFinder: jest.MockedFunction<CreatePointInTimeFinderFn>;
|
||||
|
||||
beforeEach(() => {
|
||||
savedObjectsMock = savedObjectsPointInTimeFinderMock.createClient();
|
||||
savedObjectsMock.openPointInTimeForType.mockResolvedValueOnce({
|
||||
id: 'abc123',
|
||||
});
|
||||
savedObjectsMock.find.mockResolvedValue({
|
||||
pitFinderClientMock = savedObjectsPointInTimeFinderMock.createClient();
|
||||
pitFinderClientMock.find.mockResolvedValue({
|
||||
pit_id: 'foo',
|
||||
saved_objects: [],
|
||||
// the rest of these fields don't matter but are included for type safety
|
||||
|
@ -36,12 +34,14 @@ describe('findSharedOriginObjects', () => {
|
|||
page: 1,
|
||||
per_page: 100,
|
||||
});
|
||||
pointInTimeFinder = savedObjectsPointInTimeFinderMock.create({ savedObjectsMock })(); // PIT finder mock uses the actual implementation, but it doesn't need to be created with real params because the SOR is mocked too
|
||||
pointInTimeFinder = savedObjectsPointInTimeFinderMock.create({
|
||||
savedObjectsMock: pitFinderClientMock,
|
||||
})(); // PIT finder mock uses the actual implementation, but it doesn't need to be created with real params because the SOR is mocked too
|
||||
createPointInTimeFinder = jest.fn().mockReturnValue(pointInTimeFinder);
|
||||
});
|
||||
|
||||
function mockFindResults(...results: MockFindResultParams[]) {
|
||||
savedObjectsMock.find.mockResolvedValueOnce({
|
||||
pitFinderClientMock.find.mockResolvedValueOnce({
|
||||
pit_id: 'foo',
|
||||
saved_objects: results.map(({ type, id, originId, namespaces }) => ({
|
||||
type,
|
||||
|
@ -79,21 +79,23 @@ describe('findSharedOriginObjects', () => {
|
|||
const result = await findSharedOriginObjects(createPointInTimeFinder, objects);
|
||||
expect(createPointInTimeFinder).toHaveBeenCalledTimes(1);
|
||||
expect(createPointInTimeFinder).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: ['type-1', 'type-2', 'type-3', 'type-4'] }) // filter assertions are below
|
||||
expect.objectContaining({ type: ['type-1', 'type-2', 'type-3', 'type-4'] }), // filter assertions are below
|
||||
undefined,
|
||||
{ disableExtensions: true }
|
||||
);
|
||||
const kueryFilterArgs = createPointInTimeFinder.mock.calls[0][0].filter.arguments;
|
||||
expect(kueryFilterArgs).toHaveLength(8); // 2 for each object
|
||||
[obj1, obj2, obj3].forEach(({ type, origin }, i) => {
|
||||
expect(kueryFilterArgs[i * 2].arguments).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ type: 'literal', value: `${type}.id`, isQuoted: false },
|
||||
{ type: 'literal', value: `${type}:${origin}`, isQuoted: false },
|
||||
{ isQuoted: false, type: 'literal', value: `${type}.id` },
|
||||
{ isQuoted: false, type: 'literal', value: `${type}:${origin}` },
|
||||
])
|
||||
);
|
||||
expect(kueryFilterArgs[i * 2 + 1].arguments).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ type: 'literal', value: `${type}.originId`, isQuoted: false },
|
||||
{ type: 'literal', value: origin, isQuoted: false },
|
||||
{ isQuoted: false, type: 'literal', value: `${type}.originId` },
|
||||
{ isQuoted: false, type: 'literal', value: origin },
|
||||
])
|
||||
);
|
||||
});
|
||||
|
@ -119,7 +121,11 @@ describe('findSharedOriginObjects', () => {
|
|||
const objects = [obj1, obj2, obj3];
|
||||
await findSharedOriginObjects(createPointInTimeFinder, objects, 999);
|
||||
expect(createPointInTimeFinder).toHaveBeenCalledTimes(1);
|
||||
expect(createPointInTimeFinder).toHaveBeenCalledWith(expect.objectContaining({ perPage: 999 }));
|
||||
expect(createPointInTimeFinder).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ perPage: 999 }),
|
||||
undefined,
|
||||
{ disableExtensions: true }
|
||||
);
|
||||
});
|
||||
|
||||
it('does not create a PointInTimeFinder if no objects are passed in', async () => {
|
||||
|
@ -128,7 +134,7 @@ describe('findSharedOriginObjects', () => {
|
|||
});
|
||||
|
||||
it('handles PointInTimeFinder.find errors', async () => {
|
||||
savedObjectsMock.find.mockRejectedValue(new Error('Oh no!'));
|
||||
pitFinderClientMock.find.mockRejectedValue(new Error('Oh no!'));
|
||||
|
||||
const objects = [obj1, obj2, obj3];
|
||||
await expect(() => findSharedOriginObjects(createPointInTimeFinder, objects)).rejects.toThrow(
|
||||
|
|
|
@ -34,13 +34,17 @@ export async function findSharedOriginObjects(
|
|||
|
||||
const uniqueObjectTypes = objects.reduce((acc, { type }) => acc.add(type), new Set<string>());
|
||||
const filter = createAliasKueryFilter(objects);
|
||||
const finder = createPointInTimeFinder({
|
||||
type: [...uniqueObjectTypes],
|
||||
perPage,
|
||||
filter,
|
||||
fields: ['not-a-field'], // Specify a non-existent field to avoid fetching all type-level fields (we only care about root-level fields)
|
||||
namespaces: [ALL_NAMESPACES_STRING], // We need to search across all spaces to have accurate results
|
||||
});
|
||||
const finder = createPointInTimeFinder(
|
||||
{
|
||||
type: [...uniqueObjectTypes],
|
||||
perPage,
|
||||
filter,
|
||||
fields: ['not-a-field'], // Specify a non-existent field to avoid fetching all type-level fields (we only care about root-level fields)
|
||||
namespaces: [ALL_NAMESPACES_STRING], // We need to search across all spaces to have accurate results
|
||||
},
|
||||
undefined,
|
||||
{ disableExtensions: true }
|
||||
);
|
||||
// NOTE: this objectsMap is only used internally (not in an API that is documented for public consumption), and it contains the minimal
|
||||
// amount of information to satisfy our UI needs today. We will need to change this in the future when we implement merging in #130311.
|
||||
const objectsMap = new Map<string, Set<string>>();
|
||||
|
|
|
@ -24,12 +24,31 @@ import {
|
|||
LEGACY_URL_ALIAS_TYPE,
|
||||
} from '@kbn/core-saved-objects-base-server-internal';
|
||||
import { typeRegistryMock } from '@kbn/core-saved-objects-base-server-mocks';
|
||||
import { internalBulkResolve, InternalBulkResolveParams } from './internal_bulk_resolve';
|
||||
import { internalBulkResolve, type InternalBulkResolveParams } from './internal_bulk_resolve';
|
||||
import { normalizeNamespace } from './internal_utils';
|
||||
import {
|
||||
AuditAction,
|
||||
type ISavedObjectsEncryptionExtension,
|
||||
type ISavedObjectsSecurityExtension,
|
||||
type ISavedObjectTypeRegistry,
|
||||
} from '@kbn/core-saved-objects-server';
|
||||
import {
|
||||
authMap,
|
||||
enforceError,
|
||||
typeMapsAreEqual,
|
||||
setsAreEqual,
|
||||
setupCheckAuthorized,
|
||||
setupCheckUnauthorized,
|
||||
setupEnforceFailure,
|
||||
setupEnforceSuccess,
|
||||
setupRedactPassthrough,
|
||||
} from '../test_helpers/repository.test.common';
|
||||
import { savedObjectsExtensionsMock } from '../mocks/saved_objects_extensions.mock';
|
||||
|
||||
const VERSION_PROPS = { _seq_no: 1, _primary_term: 1 };
|
||||
const OBJ_TYPE = 'obj-type';
|
||||
const UNSUPPORTED_TYPE = 'unsupported-type';
|
||||
const ENCRYPTED_TYPE = 'encrypted-type';
|
||||
|
||||
beforeEach(() => {
|
||||
mockGetSavedObjectFromSource.mockReset();
|
||||
|
@ -46,23 +65,30 @@ describe('internalBulkResolve', () => {
|
|||
let client: ReturnType<typeof elasticsearchClientMock.createElasticsearchClient>;
|
||||
let serializer: SavedObjectsSerializer;
|
||||
let incrementCounterInternal: jest.Mock<any, any>;
|
||||
let registry: jest.Mocked<ISavedObjectTypeRegistry>;
|
||||
|
||||
/** Sets up the type registry, saved objects client, etc. and return the full parameters object to be passed to `internalBulkResolve` */
|
||||
function setup(
|
||||
objects: SavedObjectsBulkResolveObject[],
|
||||
options: SavedObjectsBaseOptions = {}
|
||||
options: SavedObjectsBaseOptions = {},
|
||||
extensions?: {
|
||||
encryptionExt?: ISavedObjectsEncryptionExtension;
|
||||
securityExt?: ISavedObjectsSecurityExtension;
|
||||
}
|
||||
): InternalBulkResolveParams {
|
||||
const registry = typeRegistryMock.create();
|
||||
registry = typeRegistryMock.create();
|
||||
client = elasticsearchClientMock.createElasticsearchClient();
|
||||
serializer = new SavedObjectsSerializer(registry);
|
||||
incrementCounterInternal = jest.fn().mockRejectedValue(new Error('increment error')); // mock error to implicitly test that it is caught and swallowed
|
||||
return {
|
||||
registry: typeRegistryMock.create(), // doesn't need additional mocks for this test suite
|
||||
allowedTypes: [OBJ_TYPE],
|
||||
registry,
|
||||
allowedTypes: [OBJ_TYPE, ENCRYPTED_TYPE],
|
||||
client,
|
||||
serializer,
|
||||
getIndexForType: (type: string) => `index-for-${type}`,
|
||||
incrementCounterInternal,
|
||||
encryptionExtension: extensions?.encryptionExt,
|
||||
securityExtension: extensions?.securityExt,
|
||||
objects,
|
||||
options,
|
||||
};
|
||||
|
@ -343,4 +369,231 @@ describe('internalBulkResolve', () => {
|
|||
]);
|
||||
});
|
||||
}
|
||||
|
||||
describe('with encryption extension', () => {
|
||||
const namespace = 'foo';
|
||||
|
||||
const attributes = {
|
||||
attrNotSoSecret: '*not-so-secret*',
|
||||
attrOne: 'one',
|
||||
attrSecret: '*secret*',
|
||||
attrThree: 'three',
|
||||
title: 'Testing',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockGetSavedObjectFromSource.mockImplementation((_registry, type, id) => {
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
namespaces: [namespace],
|
||||
attributes,
|
||||
references: [],
|
||||
} as SavedObject;
|
||||
});
|
||||
});
|
||||
|
||||
it('only attempts to decrypt and strip attributes for types that are encryptable', async () => {
|
||||
const objects = [
|
||||
{ type: OBJ_TYPE, id: '11' }, // non encryptable type
|
||||
{ type: ENCRYPTED_TYPE, id: '12' }, // encryptable type
|
||||
];
|
||||
const mockEncryptionExt = savedObjectsExtensionsMock.createEncryptionExtension();
|
||||
const params = setup(objects, { namespace }, { encryptionExt: mockEncryptionExt });
|
||||
mockBulkResults(
|
||||
// No alias matches
|
||||
{ found: false },
|
||||
{ found: false }
|
||||
);
|
||||
mockMgetResults(
|
||||
// exact matches
|
||||
{ found: true },
|
||||
{ found: true }
|
||||
);
|
||||
|
||||
mockEncryptionExt.isEncryptableType.mockReturnValueOnce(false);
|
||||
mockEncryptionExt.isEncryptableType.mockReturnValueOnce(true);
|
||||
|
||||
await internalBulkResolve(params);
|
||||
|
||||
expect(mockEncryptionExt.isEncryptableType).toBeCalledTimes(2);
|
||||
expect(mockEncryptionExt.isEncryptableType).toBeCalledWith(OBJ_TYPE);
|
||||
expect(mockEncryptionExt.isEncryptableType).toBeCalledWith(ENCRYPTED_TYPE);
|
||||
|
||||
expect(mockEncryptionExt.decryptOrStripResponseAttributes).toBeCalledTimes(1);
|
||||
expect(mockEncryptionExt.decryptOrStripResponseAttributes).toBeCalledWith(
|
||||
expect.objectContaining({ type: ENCRYPTED_TYPE, id: '12', attributes })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with security extension', () => {
|
||||
const namespace = 'foo';
|
||||
const objects = [
|
||||
{ type: OBJ_TYPE, id: '13' },
|
||||
{ type: OBJ_TYPE, id: '14' },
|
||||
];
|
||||
let mockSecurityExt: jest.Mocked<ISavedObjectsSecurityExtension>;
|
||||
let params: InternalBulkResolveParams;
|
||||
|
||||
beforeEach(() => {
|
||||
mockGetSavedObjectFromSource.mockReset();
|
||||
mockGetSavedObjectFromSource.mockImplementation((_registry, type, id) => {
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
namespaces: [namespace],
|
||||
attributes: {},
|
||||
references: [],
|
||||
} as SavedObject;
|
||||
});
|
||||
|
||||
mockSecurityExt = savedObjectsExtensionsMock.createSecurityExtension();
|
||||
params = setup(objects, { namespace }, { securityExt: mockSecurityExt });
|
||||
|
||||
mockBulkResults(
|
||||
// No alias matches
|
||||
{ found: false },
|
||||
{ found: false }
|
||||
);
|
||||
mockMgetResults(
|
||||
// exact matches
|
||||
{ found: true },
|
||||
{ found: true }
|
||||
);
|
||||
});
|
||||
|
||||
test(`propagates decorated error when unauthorized`, async () => {
|
||||
setupCheckUnauthorized(mockSecurityExt);
|
||||
setupEnforceFailure(mockSecurityExt);
|
||||
|
||||
await expect(internalBulkResolve(params)).rejects.toThrow(enforceError);
|
||||
expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1);
|
||||
expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test(`returns result when authorized`, async () => {
|
||||
setupCheckAuthorized(mockSecurityExt);
|
||||
setupEnforceSuccess(mockSecurityExt);
|
||||
setupRedactPassthrough(mockSecurityExt);
|
||||
|
||||
const result = await internalBulkResolve(params);
|
||||
expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1);
|
||||
expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1);
|
||||
|
||||
const bulkIds = objects.map((obj) => obj.id);
|
||||
const expectedNamespaceString = SavedObjectsUtils.namespaceIdToString(namespace);
|
||||
expectBulkArgs(expectedNamespaceString, bulkIds);
|
||||
const mgetIds = bulkIds;
|
||||
expectMgetArgs(namespace, mgetIds);
|
||||
expect(result.resolved_objects).toEqual([
|
||||
expect.objectContaining({
|
||||
outcome: 'exactMatch',
|
||||
saved_object: expect.objectContaining({ id: objects[0].id }),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
outcome: 'exactMatch',
|
||||
saved_object: expect.objectContaining({ id: objects[1].id }),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test(`calls checkAuthorization with type, actions, namespace, and object namespaces`, async () => {
|
||||
setupCheckAuthorized(mockSecurityExt);
|
||||
setupEnforceSuccess(mockSecurityExt);
|
||||
|
||||
await internalBulkResolve(params);
|
||||
expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1);
|
||||
const expectedActions = new Set(['bulk_get']);
|
||||
const expectedSpaces = new Set([namespace]);
|
||||
const expectedTypes = new Set([objects[0].type]);
|
||||
|
||||
const {
|
||||
actions: actualActions,
|
||||
spaces: actualSpaces,
|
||||
types: actualTypes,
|
||||
} = mockSecurityExt.checkAuthorization.mock.calls[0][0];
|
||||
|
||||
expect(setsAreEqual(actualActions, expectedActions)).toBeTruthy();
|
||||
expect(setsAreEqual(actualSpaces, expectedSpaces)).toBeTruthy();
|
||||
expect(setsAreEqual(actualTypes, expectedTypes)).toBeTruthy();
|
||||
});
|
||||
|
||||
test(`calls enforceAuthorization with action, type map, and auth map`, async () => {
|
||||
setupCheckAuthorized(mockSecurityExt);
|
||||
setupEnforceSuccess(mockSecurityExt);
|
||||
|
||||
await internalBulkResolve(params);
|
||||
expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1);
|
||||
expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1);
|
||||
expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'bulk_get',
|
||||
})
|
||||
);
|
||||
|
||||
const expectedTypesAndSpaces = new Map([[objects[0].type, new Set([namespace])]]);
|
||||
|
||||
const { typesAndSpaces: actualTypesAndSpaces, typeMap: actualTypeMap } =
|
||||
mockSecurityExt.enforceAuthorization.mock.calls[0][0];
|
||||
|
||||
expect(typeMapsAreEqual(actualTypesAndSpaces, expectedTypesAndSpaces)).toBeTruthy();
|
||||
expect(actualTypeMap).toBe(authMap);
|
||||
});
|
||||
|
||||
test(`calls redactNamespaces with authorization map`, async () => {
|
||||
setupCheckAuthorized(mockSecurityExt);
|
||||
setupEnforceSuccess(mockSecurityExt);
|
||||
setupRedactPassthrough(mockSecurityExt);
|
||||
|
||||
await internalBulkResolve(params);
|
||||
expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1);
|
||||
expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(mockSecurityExt.redactNamespaces).toHaveBeenCalledTimes(objects.length);
|
||||
objects.forEach((obj, i) => {
|
||||
const { savedObject, typeMap } = mockSecurityExt.redactNamespaces.mock.calls[i][0];
|
||||
expect(savedObject).toEqual(
|
||||
expect.objectContaining({
|
||||
type: obj.type,
|
||||
id: obj.id,
|
||||
namespaces: [namespace],
|
||||
})
|
||||
);
|
||||
expect(typeMap).toBe(authMap);
|
||||
});
|
||||
});
|
||||
|
||||
test(`adds audit event per object when successful`, async () => {
|
||||
setupCheckAuthorized(mockSecurityExt);
|
||||
setupEnforceSuccess(mockSecurityExt);
|
||||
|
||||
await internalBulkResolve(params);
|
||||
|
||||
expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledTimes(objects.length);
|
||||
objects.forEach((obj) => {
|
||||
expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledWith({
|
||||
action: AuditAction.RESOLVE,
|
||||
savedObject: { type: obj.type, id: obj.id },
|
||||
error: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test(`adds audit event per object when not successful`, async () => {
|
||||
setupCheckAuthorized(mockSecurityExt);
|
||||
setupEnforceFailure(mockSecurityExt);
|
||||
|
||||
await expect(internalBulkResolve(params)).rejects.toThrow(enforceError);
|
||||
|
||||
expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledTimes(objects.length);
|
||||
objects.forEach((obj) => {
|
||||
expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledWith({
|
||||
action: AuditAction.RESOLVE,
|
||||
savedObject: { type: obj.type, id: obj.id },
|
||||
error: enforceError,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -17,12 +17,16 @@ import type {
|
|||
SavedObjectsIncrementCounterField,
|
||||
SavedObjectsIncrementCounterOptions,
|
||||
} from '@kbn/core-saved-objects-api-server';
|
||||
import type {
|
||||
ISavedObjectTypeRegistry,
|
||||
SavedObjectsRawDocSource,
|
||||
import {
|
||||
AuditAction,
|
||||
type ISavedObjectsEncryptionExtension,
|
||||
type ISavedObjectsSecurityExtension,
|
||||
type ISavedObjectTypeRegistry,
|
||||
type SavedObjectsRawDocSource,
|
||||
} from '@kbn/core-saved-objects-server';
|
||||
import {
|
||||
SavedObjectsErrorHelpers,
|
||||
SavedObjectsUtils,
|
||||
type DecoratedError,
|
||||
} from '@kbn/core-saved-objects-utils-server';
|
||||
import {
|
||||
|
@ -35,18 +39,21 @@ import {
|
|||
CORE_USAGE_STATS_TYPE,
|
||||
REPOSITORY_RESOLVE_OUTCOME_STATS,
|
||||
} from '@kbn/core-usage-data-base-server-internal';
|
||||
import pMap from 'p-map';
|
||||
import {
|
||||
getCurrentTime,
|
||||
getSavedObjectFromSource,
|
||||
normalizeNamespace,
|
||||
rawDocExistsInNamespace,
|
||||
Either,
|
||||
Right,
|
||||
type Either,
|
||||
type Right,
|
||||
isLeft,
|
||||
isRight,
|
||||
} from './internal_utils';
|
||||
import type { RepositoryEsClient } from './repository_es_client';
|
||||
|
||||
const MAX_CONCURRENT_RESOLVE = 10;
|
||||
|
||||
/**
|
||||
* Parameters for the internal bulkResolve function.
|
||||
*
|
||||
|
@ -64,6 +71,8 @@ export interface InternalBulkResolveParams {
|
|||
counterFields: Array<string | SavedObjectsIncrementCounterField>,
|
||||
options?: SavedObjectsIncrementCounterOptions<T>
|
||||
) => Promise<SavedObject<T>>;
|
||||
encryptionExtension: ISavedObjectsEncryptionExtension | undefined;
|
||||
securityExtension: ISavedObjectsSecurityExtension | undefined;
|
||||
objects: SavedObjectsBulkResolveObject[];
|
||||
options?: SavedObjectsBaseOptions;
|
||||
}
|
||||
|
@ -88,6 +97,13 @@ export interface InternalBulkResolveError {
|
|||
error: DecoratedError;
|
||||
}
|
||||
|
||||
/** Type guard used in the repository. */
|
||||
export function isBulkResolveError<T>(
|
||||
result: SavedObjectsResolveResponse<T> | InternalBulkResolveError
|
||||
): result is InternalBulkResolveError {
|
||||
return !!(result as InternalBulkResolveError).error;
|
||||
}
|
||||
|
||||
type AliasInfo = Pick<LegacyUrlAlias, 'targetId' | 'purpose'>;
|
||||
|
||||
export async function internalBulkResolve<T>(
|
||||
|
@ -100,6 +116,8 @@ export async function internalBulkResolve<T>(
|
|||
serializer,
|
||||
getIndexForType,
|
||||
incrementCounterInternal,
|
||||
encryptionExtension,
|
||||
securityExtension,
|
||||
objects,
|
||||
options = {},
|
||||
} = params;
|
||||
|
@ -166,70 +184,85 @@ export async function internalBulkResolve<T>(
|
|||
|
||||
let getResponseIndex = 0;
|
||||
let aliasInfoIndex = 0;
|
||||
const resolveCounter = new ResolveCounter();
|
||||
const resolvedObjects = allObjects.map<SavedObjectsResolveResponse<T> | InternalBulkResolveError>(
|
||||
(either) => {
|
||||
if (isLeft(either)) {
|
||||
return either.value;
|
||||
}
|
||||
const exactMatchDoc = bulkGetResponse?.body.docs[getResponseIndex++];
|
||||
let aliasMatchDoc: MgetResponseItem<SavedObjectsRawDocSource> | undefined;
|
||||
const aliasInfo = aliasInfoArray[aliasInfoIndex++];
|
||||
if (aliasInfo !== undefined) {
|
||||
aliasMatchDoc = bulkGetResponse?.body.docs[getResponseIndex++];
|
||||
}
|
||||
const foundExactMatch =
|
||||
// @ts-expect-error MultiGetHit._source is optional
|
||||
exactMatchDoc.found && rawDocExistsInNamespace(registry, exactMatchDoc, namespace);
|
||||
const foundAliasMatch =
|
||||
// @ts-expect-error MultiGetHit._source is optional
|
||||
aliasMatchDoc?.found && rawDocExistsInNamespace(registry, aliasMatchDoc, namespace);
|
||||
|
||||
const { type, id } = either.value;
|
||||
let result: SavedObjectsResolveResponse<T> | null = null;
|
||||
if (foundExactMatch && foundAliasMatch) {
|
||||
result = {
|
||||
// @ts-expect-error MultiGetHit._source is optional
|
||||
saved_object: getSavedObjectFromSource(registry, type, id, exactMatchDoc),
|
||||
outcome: 'conflict',
|
||||
alias_target_id: aliasInfo!.targetId,
|
||||
alias_purpose: aliasInfo!.purpose,
|
||||
};
|
||||
resolveCounter.recordOutcome(REPOSITORY_RESOLVE_OUTCOME_STATS.CONFLICT);
|
||||
} else if (foundExactMatch) {
|
||||
result = {
|
||||
// @ts-expect-error MultiGetHit._source is optional
|
||||
saved_object: getSavedObjectFromSource(registry, type, id, exactMatchDoc),
|
||||
outcome: 'exactMatch',
|
||||
};
|
||||
resolveCounter.recordOutcome(REPOSITORY_RESOLVE_OUTCOME_STATS.EXACT_MATCH);
|
||||
} else if (foundAliasMatch) {
|
||||
result = {
|
||||
saved_object: getSavedObjectFromSource(
|
||||
registry,
|
||||
type,
|
||||
aliasInfo!.targetId,
|
||||
// @ts-expect-error MultiGetHit._source is optional
|
||||
aliasMatchDoc!
|
||||
),
|
||||
outcome: 'aliasMatch',
|
||||
alias_target_id: aliasInfo!.targetId,
|
||||
alias_purpose: aliasInfo!.purpose,
|
||||
};
|
||||
resolveCounter.recordOutcome(REPOSITORY_RESOLVE_OUTCOME_STATS.ALIAS_MATCH);
|
||||
}
|
||||
|
||||
if (result !== null) {
|
||||
return result;
|
||||
}
|
||||
resolveCounter.recordOutcome(REPOSITORY_RESOLVE_OUTCOME_STATS.NOT_FOUND);
|
||||
return {
|
||||
type,
|
||||
id,
|
||||
error: SavedObjectsErrorHelpers.createGenericNotFoundError(type, id),
|
||||
};
|
||||
// Helper function for the map block below
|
||||
async function getSavedObject(
|
||||
objectType: string,
|
||||
objectId: string,
|
||||
doc: MgetResponseItem<SavedObjectsRawDocSource>
|
||||
) {
|
||||
// Encryption
|
||||
// @ts-expect-error MultiGetHit._source is optional
|
||||
const object = getSavedObjectFromSource<T>(registry, objectType, objectId, doc);
|
||||
if (!encryptionExtension?.isEncryptableType(object.type)) {
|
||||
return object;
|
||||
}
|
||||
);
|
||||
return encryptionExtension.decryptOrStripResponseAttributes(object);
|
||||
}
|
||||
|
||||
// map function for pMap below
|
||||
const mapper = async (
|
||||
either: Either<InternalBulkResolveError, SavedObjectsBulkResolveObject>
|
||||
) => {
|
||||
if (isLeft(either)) {
|
||||
return either.value;
|
||||
}
|
||||
const exactMatchDoc = bulkGetResponse?.body.docs[getResponseIndex++];
|
||||
let aliasMatchDoc: MgetResponseItem<SavedObjectsRawDocSource> | undefined;
|
||||
const aliasInfo = aliasInfoArray[aliasInfoIndex++];
|
||||
if (aliasInfo !== undefined) {
|
||||
aliasMatchDoc = bulkGetResponse?.body.docs[getResponseIndex++];
|
||||
}
|
||||
const foundExactMatch =
|
||||
// @ts-expect-error MultiGetHit._source is optional
|
||||
exactMatchDoc.found && rawDocExistsInNamespace(registry, exactMatchDoc, namespace);
|
||||
const foundAliasMatch =
|
||||
// @ts-expect-error MultiGetHit._source is optional
|
||||
aliasMatchDoc?.found && rawDocExistsInNamespace(registry, aliasMatchDoc, namespace);
|
||||
|
||||
const { type, id } = either.value;
|
||||
let result: SavedObjectsResolveResponse<T> | null = null;
|
||||
|
||||
if (foundExactMatch && foundAliasMatch) {
|
||||
result = {
|
||||
saved_object: await getSavedObject(type, id, exactMatchDoc!),
|
||||
outcome: 'conflict',
|
||||
alias_target_id: aliasInfo!.targetId,
|
||||
alias_purpose: aliasInfo!.purpose,
|
||||
};
|
||||
resolveCounter.recordOutcome(REPOSITORY_RESOLVE_OUTCOME_STATS.CONFLICT);
|
||||
} else if (foundExactMatch) {
|
||||
result = {
|
||||
saved_object: await getSavedObject(type, id, exactMatchDoc!),
|
||||
outcome: 'exactMatch',
|
||||
};
|
||||
resolveCounter.recordOutcome(REPOSITORY_RESOLVE_OUTCOME_STATS.EXACT_MATCH);
|
||||
} else if (foundAliasMatch) {
|
||||
result = {
|
||||
saved_object: await getSavedObject(type, aliasInfo!.targetId, aliasMatchDoc!),
|
||||
outcome: 'aliasMatch',
|
||||
alias_target_id: aliasInfo!.targetId,
|
||||
alias_purpose: aliasInfo!.purpose,
|
||||
};
|
||||
resolveCounter.recordOutcome(REPOSITORY_RESOLVE_OUTCOME_STATS.ALIAS_MATCH);
|
||||
}
|
||||
|
||||
if (result !== null) {
|
||||
return result;
|
||||
}
|
||||
resolveCounter.recordOutcome(REPOSITORY_RESOLVE_OUTCOME_STATS.NOT_FOUND);
|
||||
return {
|
||||
type,
|
||||
id,
|
||||
error: SavedObjectsErrorHelpers.createGenericNotFoundError(type, id),
|
||||
};
|
||||
};
|
||||
|
||||
const resolveCounter = new ResolveCounter();
|
||||
|
||||
const resolvedObjects = await pMap(allObjects, mapper, {
|
||||
concurrency: MAX_CONCURRENT_RESOLVE,
|
||||
});
|
||||
|
||||
incrementCounterInternal(
|
||||
CORE_USAGE_STATS_TYPE,
|
||||
|
@ -238,7 +271,91 @@ export async function internalBulkResolve<T>(
|
|||
{ refresh: false }
|
||||
).catch(() => {}); // if the call fails for some reason, intentionally swallow the error
|
||||
|
||||
return { resolved_objects: resolvedObjects };
|
||||
const redacted = await authorizeAuditAndRedact(resolvedObjects, securityExtension, namespace);
|
||||
return { resolved_objects: redacted };
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks authorization, writes audit events, and redacts namespaces from the bulkResolve response. In other SavedObjectsRepository
|
||||
* functions we do this before decrypting attributes. However, because of the bulkResolve logic involved in deciding between the exact match
|
||||
* or alias match, it's cleaner to do authorization, auditing, and redaction all afterwards.
|
||||
*/
|
||||
async function authorizeAuditAndRedact<T>(
|
||||
resolvedObjects: Array<SavedObjectsResolveResponse<T> | InternalBulkResolveError>,
|
||||
securityExtension: ISavedObjectsSecurityExtension | undefined,
|
||||
namespace: string | undefined
|
||||
) {
|
||||
if (!securityExtension) {
|
||||
return resolvedObjects;
|
||||
}
|
||||
|
||||
const namespaceString = SavedObjectsUtils.namespaceIdToString(namespace);
|
||||
const typesAndSpaces = new Map<string, Set<string>>();
|
||||
const spacesToAuthorize = new Set<string>();
|
||||
const auditableObjects: Array<{ type: string; id: string }> = [];
|
||||
|
||||
for (const result of resolvedObjects) {
|
||||
let auditableObject: { type: string; id: string } | undefined;
|
||||
if (isBulkResolveError(result)) {
|
||||
const { type, id, error } = result;
|
||||
if (!SavedObjectsErrorHelpers.isBadRequestError(error)) {
|
||||
// Only "not found" errors should show up as audit events (not "unsupported type" errors)
|
||||
auditableObject = { type, id };
|
||||
}
|
||||
} else {
|
||||
const { type, id, namespaces = [] } = result.saved_object;
|
||||
auditableObject = { type, id };
|
||||
for (const space of namespaces) {
|
||||
spacesToAuthorize.add(space);
|
||||
}
|
||||
}
|
||||
if (auditableObject) {
|
||||
auditableObjects.push(auditableObject);
|
||||
const spacesToEnforce =
|
||||
typesAndSpaces.get(auditableObject.type) ?? new Set([namespaceString]); // Always enforce authZ for the active space
|
||||
spacesToEnforce.add(namespaceString);
|
||||
typesAndSpaces.set(auditableObject.type, spacesToEnforce);
|
||||
spacesToAuthorize.add(namespaceString);
|
||||
}
|
||||
}
|
||||
|
||||
if (typesAndSpaces.size === 0) {
|
||||
// We only had "unsupported type" errors, there are no types to check privileges for, just return early
|
||||
return resolvedObjects;
|
||||
}
|
||||
|
||||
const authorizationResult = await securityExtension.checkAuthorization({
|
||||
types: new Set(typesAndSpaces.keys()),
|
||||
spaces: spacesToAuthorize,
|
||||
actions: new Set(['bulk_get']),
|
||||
});
|
||||
securityExtension.enforceAuthorization({
|
||||
typesAndSpaces,
|
||||
action: 'bulk_get',
|
||||
typeMap: authorizationResult.typeMap,
|
||||
auditCallback: (error) => {
|
||||
for (const { type, id } of auditableObjects) {
|
||||
securityExtension.addAuditEvent({
|
||||
action: AuditAction.RESOLVE,
|
||||
savedObject: { type, id },
|
||||
error,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return resolvedObjects.map((result) => {
|
||||
if (isBulkResolveError(result)) {
|
||||
return result;
|
||||
}
|
||||
return {
|
||||
...result,
|
||||
saved_object: securityExtension.redactNamespaces({
|
||||
typeMap: authorizationResult.typeMap,
|
||||
savedObject: result.saved_object,
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Separates valid and invalid object types */
|
||||
|
@ -317,7 +434,6 @@ async function fetchAndUpdateAliases(
|
|||
return item.update?.get;
|
||||
});
|
||||
}
|
||||
|
||||
class ResolveCounter {
|
||||
private record = new Map<string, number>();
|
||||
|
||||
|
|
|
@ -6,24 +6,25 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import type { DeeplyMockedKeys } from '@kbn/utility-types-jest';
|
||||
import { DeeplyMockedKeys } from '@kbn/utility-types-jest';
|
||||
|
||||
import {
|
||||
type LegacyUrlAlias,
|
||||
LEGACY_URL_ALIAS_TYPE,
|
||||
} from '@kbn/core-saved-objects-base-server-internal';
|
||||
import type { CreatePointInTimeFinderFn, PointInTimeFinder } from '../point_in_time_finder';
|
||||
import { CreatePointInTimeFinderFn, PointInTimeFinder } from '../point_in_time_finder';
|
||||
import { findLegacyUrlAliases } from './find_legacy_url_aliases';
|
||||
import { savedObjectsPointInTimeFinderMock } from '../../mocks';
|
||||
import { SavedObjectsPointInTimeFinderClient } from '@kbn/core-saved-objects-api-server';
|
||||
|
||||
describe('findLegacyUrlAliases', () => {
|
||||
let savedObjectsMock: ReturnType<typeof savedObjectsPointInTimeFinderMock.createClient>;
|
||||
let pitFinderClientMock: jest.Mocked<SavedObjectsPointInTimeFinderClient>;
|
||||
let pointInTimeFinder: DeeplyMockedKeys<PointInTimeFinder>;
|
||||
let createPointInTimeFinder: jest.MockedFunction<CreatePointInTimeFinderFn>;
|
||||
|
||||
beforeEach(() => {
|
||||
savedObjectsMock = savedObjectsPointInTimeFinderMock.createClient();
|
||||
savedObjectsMock.find.mockResolvedValue({
|
||||
pitFinderClientMock = savedObjectsPointInTimeFinderMock.createClient();
|
||||
pitFinderClientMock.find.mockResolvedValue({
|
||||
pit_id: 'foo',
|
||||
saved_objects: [],
|
||||
// the rest of these fields don't matter but are included for type safety
|
||||
|
@ -31,15 +32,14 @@ describe('findLegacyUrlAliases', () => {
|
|||
page: 1,
|
||||
per_page: 100,
|
||||
});
|
||||
savedObjectsMock.openPointInTimeForType.mockResolvedValueOnce({
|
||||
id: 'abc123',
|
||||
});
|
||||
pointInTimeFinder = savedObjectsPointInTimeFinderMock.create({ savedObjectsMock })(); // PIT finder mock uses the actual implementation, but it doesn't need to be created with real params because the SOR is mocked too
|
||||
pointInTimeFinder = savedObjectsPointInTimeFinderMock.create({
|
||||
savedObjectsMock: pitFinderClientMock,
|
||||
})(); // PIT finder mock uses the actual implementation, but it doesn't need to be created with real params because the SOR is mocked too
|
||||
createPointInTimeFinder = jest.fn().mockReturnValue(pointInTimeFinder);
|
||||
});
|
||||
|
||||
function mockFindResults(...results: LegacyUrlAlias[]) {
|
||||
savedObjectsMock.find.mockResolvedValueOnce({
|
||||
pitFinderClientMock.find.mockResolvedValueOnce({
|
||||
pit_id: 'foo',
|
||||
saved_objects: results.map((attributes) => ({
|
||||
id: 'doesnt-matter',
|
||||
|
@ -74,10 +74,14 @@ describe('findLegacyUrlAliases', () => {
|
|||
const objects = [obj1, obj2, obj3];
|
||||
const result = await findLegacyUrlAliases(createPointInTimeFinder, objects);
|
||||
expect(createPointInTimeFinder).toHaveBeenCalledTimes(1);
|
||||
expect(createPointInTimeFinder).toHaveBeenCalledWith({
|
||||
type: LEGACY_URL_ALIAS_TYPE,
|
||||
filter: expect.any(Object), // assertions are below
|
||||
});
|
||||
expect(createPointInTimeFinder).toHaveBeenCalledWith(
|
||||
{
|
||||
type: LEGACY_URL_ALIAS_TYPE,
|
||||
filter: expect.any(Object), // assertions are below
|
||||
},
|
||||
undefined,
|
||||
{ disableExtensions: true }
|
||||
);
|
||||
const kueryFilterArgs = createPointInTimeFinder.mock.calls[0][0].filter.arguments;
|
||||
expect(kueryFilterArgs).toHaveLength(2);
|
||||
const typeAndIdFilters = kueryFilterArgs[1].arguments;
|
||||
|
@ -86,10 +90,10 @@ describe('findLegacyUrlAliases', () => {
|
|||
const typeAndIdFilter = typeAndIdFilters[i].arguments;
|
||||
expect(typeAndIdFilter).toEqual([
|
||||
expect.objectContaining({
|
||||
arguments: expect.arrayContaining([{ type: 'literal', value: type, isQuoted: false }]),
|
||||
arguments: expect.arrayContaining([{ isQuoted: false, type: 'literal', value: type }]),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
arguments: expect.arrayContaining([{ type: 'literal', value: id, isQuoted: false }]),
|
||||
arguments: expect.arrayContaining([{ isQuoted: false, type: 'literal', value: id }]),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
@ -107,11 +111,15 @@ describe('findLegacyUrlAliases', () => {
|
|||
const objects = [obj1, obj2, obj3];
|
||||
await findLegacyUrlAliases(createPointInTimeFinder, objects, 999);
|
||||
expect(createPointInTimeFinder).toHaveBeenCalledTimes(1);
|
||||
expect(createPointInTimeFinder).toHaveBeenCalledWith({
|
||||
type: LEGACY_URL_ALIAS_TYPE,
|
||||
perPage: 999,
|
||||
filter: expect.any(Object),
|
||||
});
|
||||
expect(createPointInTimeFinder).toHaveBeenCalledWith(
|
||||
{
|
||||
type: LEGACY_URL_ALIAS_TYPE,
|
||||
perPage: 999,
|
||||
filter: expect.any(Object),
|
||||
},
|
||||
undefined,
|
||||
{ disableExtensions: true }
|
||||
);
|
||||
});
|
||||
|
||||
it('does not create a PointInTimeFinder if no objects are passed in', async () => {
|
||||
|
@ -120,7 +128,7 @@ describe('findLegacyUrlAliases', () => {
|
|||
});
|
||||
|
||||
it('handles PointInTimeFinder.find errors', async () => {
|
||||
savedObjectsMock.find.mockRejectedValue(new Error('Oh no!'));
|
||||
pitFinderClientMock.find.mockRejectedValue(new Error('Oh no!'));
|
||||
|
||||
const objects = [obj1, obj2, obj3];
|
||||
await expect(() => findLegacyUrlAliases(createPointInTimeFinder, objects)).rejects.toThrow(
|
||||
|
|
|
@ -34,11 +34,11 @@ export async function findLegacyUrlAliases(
|
|||
}
|
||||
|
||||
const filter = createAliasKueryFilter(objects);
|
||||
const finder = createPointInTimeFinder<LegacyUrlAlias>({
|
||||
type: LEGACY_URL_ALIAS_TYPE,
|
||||
perPage,
|
||||
filter,
|
||||
});
|
||||
const finder = createPointInTimeFinder<LegacyUrlAlias>(
|
||||
{ type: LEGACY_URL_ALIAS_TYPE, perPage, filter },
|
||||
undefined,
|
||||
{ disableExtensions: true }
|
||||
);
|
||||
const aliasesMap = new Map<string, Set<string>>();
|
||||
let error: Error | undefined;
|
||||
try {
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { loggerMock, MockedLogger } from '@kbn/logging-mocks';
|
||||
import { loggerMock, type MockedLogger } from '@kbn/logging-mocks';
|
||||
import type {
|
||||
SavedObjectsFindResult,
|
||||
SavedObjectsCreatePointInTimeFinderOptions,
|
||||
|
@ -69,9 +69,11 @@ describe('createPointInTimeFinder()', () => {
|
|||
namespaces: ['ns1', 'ns2'],
|
||||
};
|
||||
|
||||
const internalOptions = {};
|
||||
const finder = new PointInTimeFinder(findOptions, {
|
||||
logger,
|
||||
client: repository,
|
||||
internalOptions,
|
||||
});
|
||||
|
||||
expect(repository.openPointInTimeForType).not.toHaveBeenCalled();
|
||||
|
@ -79,150 +81,158 @@ describe('createPointInTimeFinder()', () => {
|
|||
await finder.find().next();
|
||||
|
||||
expect(repository.openPointInTimeForType).toHaveBeenCalledTimes(1);
|
||||
expect(repository.openPointInTimeForType).toHaveBeenCalledWith(findOptions.type, {
|
||||
namespaces: findOptions.namespaces,
|
||||
});
|
||||
});
|
||||
|
||||
test('throws if a PIT is already open', async () => {
|
||||
repository.openPointInTimeForType.mockResolvedValueOnce({
|
||||
id: 'abc123',
|
||||
});
|
||||
repository.find
|
||||
.mockResolvedValueOnce({
|
||||
total: 2,
|
||||
saved_objects: mockHits,
|
||||
pit_id: 'abc123',
|
||||
per_page: 1,
|
||||
page: 0,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
total: 2,
|
||||
saved_objects: mockHits,
|
||||
pit_id: 'abc123',
|
||||
per_page: 1,
|
||||
page: 1,
|
||||
});
|
||||
|
||||
const findOptions: SavedObjectsCreatePointInTimeFinderOptions = {
|
||||
type: ['visualization'],
|
||||
search: 'foo*',
|
||||
perPage: 1,
|
||||
};
|
||||
|
||||
const finder = new PointInTimeFinder(findOptions, {
|
||||
logger,
|
||||
client: repository,
|
||||
});
|
||||
await finder.find().next();
|
||||
|
||||
expect(repository.find).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(async () => {
|
||||
await finder.find().next();
|
||||
}).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"Point In Time has already been opened for this finder instance. Please call \`close()\` before calling \`find()\` again."`
|
||||
expect(repository.openPointInTimeForType).toHaveBeenCalledWith(
|
||||
findOptions.type,
|
||||
{ namespaces: findOptions.namespaces },
|
||||
internalOptions
|
||||
);
|
||||
expect(repository.find).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
test('works with a single page of results', async () => {
|
||||
repository.openPointInTimeForType.mockResolvedValueOnce({
|
||||
id: 'abc123',
|
||||
});
|
||||
repository.find.mockResolvedValueOnce({
|
||||
test('throws if a PIT is already open', async () => {
|
||||
repository.openPointInTimeForType.mockResolvedValueOnce({
|
||||
id: 'abc123',
|
||||
});
|
||||
repository.find
|
||||
.mockResolvedValueOnce({
|
||||
total: 2,
|
||||
saved_objects: mockHits,
|
||||
pit_id: 'abc123',
|
||||
per_page: 2,
|
||||
page: 0,
|
||||
});
|
||||
|
||||
const findOptions: SavedObjectsCreatePointInTimeFinderOptions = {
|
||||
type: ['visualization'],
|
||||
search: 'foo*',
|
||||
};
|
||||
|
||||
const finder = new PointInTimeFinder(findOptions, {
|
||||
logger,
|
||||
client: repository,
|
||||
});
|
||||
const hits: SavedObjectsFindResult[] = [];
|
||||
for await (const result of finder.find()) {
|
||||
hits.push(...result.saved_objects);
|
||||
}
|
||||
|
||||
expect(hits.length).toBe(2);
|
||||
expect(repository.openPointInTimeForType).toHaveBeenCalledTimes(1);
|
||||
expect(repository.closePointInTime).toHaveBeenCalledTimes(1);
|
||||
expect(repository.find).toHaveBeenCalledTimes(1);
|
||||
expect(repository.find).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
pit: expect.objectContaining({ id: 'abc123', keepAlive: '2m' }),
|
||||
sortField: 'updated_at',
|
||||
sortOrder: 'desc',
|
||||
type: ['visualization'],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test('works with multiple pages of results', async () => {
|
||||
repository.openPointInTimeForType.mockResolvedValueOnce({
|
||||
id: 'abc123',
|
||||
});
|
||||
repository.find
|
||||
.mockResolvedValueOnce({
|
||||
total: 2,
|
||||
saved_objects: [mockHits[0]],
|
||||
pit_id: 'abc123',
|
||||
per_page: 1,
|
||||
page: 0,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
total: 2,
|
||||
saved_objects: [mockHits[1]],
|
||||
pit_id: 'abc123',
|
||||
per_page: 1,
|
||||
page: 0,
|
||||
});
|
||||
repository.find.mockResolvedValueOnce({
|
||||
total: 2,
|
||||
saved_objects: [],
|
||||
per_page: 1,
|
||||
page: 0,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
total: 2,
|
||||
saved_objects: mockHits,
|
||||
pit_id: 'abc123',
|
||||
per_page: 1,
|
||||
page: 1,
|
||||
});
|
||||
|
||||
const findOptions: SavedObjectsCreatePointInTimeFinderOptions = {
|
||||
type: ['visualization'],
|
||||
search: 'foo*',
|
||||
perPage: 1,
|
||||
};
|
||||
|
||||
const finder = new PointInTimeFinder(findOptions, {
|
||||
logger,
|
||||
client: repository,
|
||||
});
|
||||
await finder.find().next();
|
||||
|
||||
expect(repository.find).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(async () => {
|
||||
await finder.find().next();
|
||||
}).rejects.toThrowErrorMatchingInlineSnapshot(
|
||||
`"Point In Time has already been opened for this finder instance. Please call \`close()\` before calling \`find()\` again."`
|
||||
);
|
||||
expect(repository.find).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('works with a single page of results', async () => {
|
||||
repository.openPointInTimeForType.mockResolvedValueOnce({
|
||||
id: 'abc123',
|
||||
});
|
||||
repository.find.mockResolvedValueOnce({
|
||||
total: 2,
|
||||
saved_objects: mockHits,
|
||||
pit_id: 'abc123',
|
||||
per_page: 2,
|
||||
page: 0,
|
||||
});
|
||||
|
||||
const findOptions: SavedObjectsCreatePointInTimeFinderOptions = {
|
||||
type: ['visualization'],
|
||||
search: 'foo*',
|
||||
};
|
||||
|
||||
const internalOptions = {};
|
||||
const finder = new PointInTimeFinder(findOptions, {
|
||||
logger,
|
||||
client: repository,
|
||||
internalOptions,
|
||||
});
|
||||
const hits: SavedObjectsFindResult[] = [];
|
||||
for await (const result of finder.find()) {
|
||||
hits.push(...result.saved_objects);
|
||||
}
|
||||
|
||||
expect(hits.length).toBe(2);
|
||||
expect(repository.openPointInTimeForType).toHaveBeenCalledTimes(1);
|
||||
expect(repository.closePointInTime).toHaveBeenCalledTimes(1);
|
||||
expect(repository.find).toHaveBeenCalledTimes(1);
|
||||
expect(repository.find).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
pit: expect.objectContaining({ id: 'abc123', keepAlive: '2m' }),
|
||||
sortField: 'updated_at',
|
||||
sortOrder: 'desc',
|
||||
type: ['visualization'],
|
||||
}),
|
||||
internalOptions
|
||||
);
|
||||
});
|
||||
|
||||
test('works with multiple pages of results', async () => {
|
||||
repository.openPointInTimeForType.mockResolvedValueOnce({
|
||||
id: 'abc123',
|
||||
});
|
||||
repository.find
|
||||
.mockResolvedValueOnce({
|
||||
total: 2,
|
||||
saved_objects: [mockHits[0]],
|
||||
pit_id: 'abc123',
|
||||
per_page: 1,
|
||||
page: 0,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
total: 2,
|
||||
saved_objects: [mockHits[1]],
|
||||
pit_id: 'abc123',
|
||||
per_page: 1,
|
||||
page: 0,
|
||||
});
|
||||
|
||||
const findOptions: SavedObjectsCreatePointInTimeFinderOptions = {
|
||||
type: ['visualization'],
|
||||
search: 'foo*',
|
||||
perPage: 1,
|
||||
};
|
||||
|
||||
const finder = new PointInTimeFinder(findOptions, {
|
||||
logger,
|
||||
client: repository,
|
||||
});
|
||||
const hits: SavedObjectsFindResult[] = [];
|
||||
for await (const result of finder.find()) {
|
||||
hits.push(...result.saved_objects);
|
||||
}
|
||||
|
||||
expect(hits.length).toBe(2);
|
||||
expect(repository.openPointInTimeForType).toHaveBeenCalledTimes(1);
|
||||
expect(repository.closePointInTime).toHaveBeenCalledTimes(1);
|
||||
// called 3 times since we need a 3rd request to check if we
|
||||
// are done paginating through results.
|
||||
expect(repository.find).toHaveBeenCalledTimes(3);
|
||||
expect(repository.find).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
pit: expect.objectContaining({ id: 'abc123', keepAlive: '2m' }),
|
||||
sortField: 'updated_at',
|
||||
sortOrder: 'desc',
|
||||
type: ['visualization'],
|
||||
})
|
||||
);
|
||||
repository.find.mockResolvedValueOnce({
|
||||
total: 2,
|
||||
saved_objects: [],
|
||||
per_page: 1,
|
||||
pit_id: 'abc123',
|
||||
page: 0,
|
||||
});
|
||||
|
||||
const findOptions: SavedObjectsCreatePointInTimeFinderOptions = {
|
||||
type: ['visualization'],
|
||||
search: 'foo*',
|
||||
perPage: 1,
|
||||
};
|
||||
|
||||
const internalOptions = {};
|
||||
const finder = new PointInTimeFinder(findOptions, {
|
||||
logger,
|
||||
client: repository,
|
||||
internalOptions,
|
||||
});
|
||||
const hits: SavedObjectsFindResult[] = [];
|
||||
for await (const result of finder.find()) {
|
||||
hits.push(...result.saved_objects);
|
||||
}
|
||||
|
||||
expect(hits.length).toBe(2);
|
||||
expect(repository.openPointInTimeForType).toHaveBeenCalledTimes(1);
|
||||
expect(repository.closePointInTime).toHaveBeenCalledTimes(1);
|
||||
// called 3 times since we need a 3rd request to check if we
|
||||
// are done paginating through results.
|
||||
expect(repository.find).toHaveBeenCalledTimes(3);
|
||||
expect(repository.find).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
pit: expect.objectContaining({ id: 'abc123', keepAlive: '2m' }),
|
||||
sortField: 'updated_at',
|
||||
sortOrder: 'desc',
|
||||
type: ['visualization'],
|
||||
}),
|
||||
internalOptions
|
||||
);
|
||||
});
|
||||
|
||||
describe('#close', () => {
|
||||
|
@ -244,9 +254,11 @@ describe('createPointInTimeFinder()', () => {
|
|||
perPage: 2,
|
||||
};
|
||||
|
||||
const internalOptions = {};
|
||||
const finder = new PointInTimeFinder(findOptions, {
|
||||
logger,
|
||||
client: repository,
|
||||
internalOptions,
|
||||
});
|
||||
const hits: SavedObjectsFindResult[] = [];
|
||||
for await (const result of finder.find()) {
|
||||
|
@ -254,7 +266,7 @@ describe('createPointInTimeFinder()', () => {
|
|||
await finder.close();
|
||||
}
|
||||
|
||||
expect(repository.closePointInTime).toHaveBeenCalledWith('test');
|
||||
expect(repository.closePointInTime).toHaveBeenCalledWith('test', undefined, internalOptions);
|
||||
});
|
||||
|
||||
test('causes generator to stop', async () => {
|
||||
|
@ -315,9 +327,11 @@ describe('createPointInTimeFinder()', () => {
|
|||
perPage: 2,
|
||||
};
|
||||
|
||||
const internalOptions = {};
|
||||
const finder = new PointInTimeFinder(findOptions, {
|
||||
logger,
|
||||
client: repository,
|
||||
internalOptions,
|
||||
});
|
||||
const hits: SavedObjectsFindResult[] = [];
|
||||
try {
|
||||
|
@ -328,7 +342,7 @@ describe('createPointInTimeFinder()', () => {
|
|||
// intentionally empty
|
||||
}
|
||||
|
||||
expect(repository.closePointInTime).toHaveBeenCalledWith('test');
|
||||
expect(repository.closePointInTime).toHaveBeenCalledWith('test', undefined, internalOptions);
|
||||
});
|
||||
|
||||
test('finder can be reused after closing', async () => {
|
||||
|
|
|
@ -14,6 +14,7 @@ import type {
|
|||
SavedObjectsCreatePointInTimeFinderOptions,
|
||||
ISavedObjectsPointInTimeFinder,
|
||||
SavedObjectsPointInTimeFinderClient,
|
||||
SavedObjectsFindInternalOptions,
|
||||
} from '@kbn/core-saved-objects-api-server';
|
||||
|
||||
/**
|
||||
|
@ -22,13 +23,16 @@ import type {
|
|||
export interface PointInTimeFinderDependencies
|
||||
extends SavedObjectsCreatePointInTimeFinderDependencies {
|
||||
logger: Logger;
|
||||
internalOptions?: SavedObjectsFindInternalOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export type CreatePointInTimeFinderFn = <T = unknown, A = unknown>(
|
||||
findOptions: SavedObjectsCreatePointInTimeFinderOptions
|
||||
findOptions: SavedObjectsCreatePointInTimeFinderOptions,
|
||||
dependencies?: SavedObjectsCreatePointInTimeFinderDependencies,
|
||||
internalOptions?: SavedObjectsFindInternalOptions
|
||||
) => ISavedObjectsPointInTimeFinder<T, A>;
|
||||
|
||||
/**
|
||||
|
@ -40,15 +44,17 @@ export class PointInTimeFinder<T = unknown, A = unknown>
|
|||
readonly #log: Logger;
|
||||
readonly #client: SavedObjectsPointInTimeFinderClient;
|
||||
readonly #findOptions: SavedObjectsFindOptions;
|
||||
readonly #internalOptions: SavedObjectsFindInternalOptions | undefined;
|
||||
#open: boolean = false;
|
||||
#pitId?: string;
|
||||
|
||||
constructor(
|
||||
findOptions: SavedObjectsCreatePointInTimeFinderOptions,
|
||||
{ logger, client }: PointInTimeFinderDependencies
|
||||
{ logger, client, internalOptions }: PointInTimeFinderDependencies
|
||||
) {
|
||||
this.#log = logger.get('point-in-time-finder');
|
||||
this.#client = client;
|
||||
this.#internalOptions = internalOptions;
|
||||
this.#findOptions = {
|
||||
// Default to 1000 items per page as a tradeoff between
|
||||
// speed and memory consumption.
|
||||
|
@ -99,7 +105,7 @@ export class PointInTimeFinder<T = unknown, A = unknown>
|
|||
try {
|
||||
if (this.#pitId) {
|
||||
this.#log.debug(`Closing PIT for types [${this.#findOptions.type}]`);
|
||||
await this.#client.closePointInTime(this.#pitId);
|
||||
await this.#client.closePointInTime(this.#pitId, undefined, this.#internalOptions);
|
||||
this.#pitId = undefined;
|
||||
}
|
||||
this.#open = false;
|
||||
|
@ -111,9 +117,11 @@ export class PointInTimeFinder<T = unknown, A = unknown>
|
|||
|
||||
private async open() {
|
||||
try {
|
||||
const { id } = await this.#client.openPointInTimeForType(this.#findOptions.type, {
|
||||
namespaces: this.#findOptions.namespaces,
|
||||
});
|
||||
const { id } = await this.#client.openPointInTimeForType(
|
||||
this.#findOptions.type,
|
||||
{ namespaces: this.#findOptions.namespaces },
|
||||
this.#internalOptions
|
||||
);
|
||||
this.#pitId = id;
|
||||
this.#open = true;
|
||||
} catch (e) {
|
||||
|
@ -137,16 +145,19 @@ export class PointInTimeFinder<T = unknown, A = unknown>
|
|||
searchAfter?: estypes.Id[];
|
||||
}) {
|
||||
try {
|
||||
return await this.#client.find<T, A>({
|
||||
// Sort fields are required to use searchAfter, so we set some defaults here
|
||||
sortField: 'updated_at',
|
||||
sortOrder: 'desc',
|
||||
// Bump keep_alive by 2m on every new request to allow for the ES client
|
||||
// to make multiple retries in the event of a network failure.
|
||||
pit: id ? { id, keepAlive: '2m' } : undefined,
|
||||
searchAfter,
|
||||
...findOptions,
|
||||
});
|
||||
return await this.#client.find<T, A>(
|
||||
{
|
||||
// Sort fields are required to use searchAfter, so we set some defaults here
|
||||
sortField: 'updated_at',
|
||||
sortOrder: 'desc',
|
||||
// Bump keep_alive by 2m on every new request to allow for the ES client
|
||||
// to make multiple retries in the event of a network failure.
|
||||
pit: id ? { id, keepAlive: '2m' } : undefined,
|
||||
searchAfter,
|
||||
...findOptions,
|
||||
},
|
||||
this.#internalOptions
|
||||
);
|
||||
} catch (e) {
|
||||
if (id) {
|
||||
// Clean up PIT on any errors.
|
||||
|
|
|
@ -23,8 +23,8 @@ import { typeRegistryMock } from '@kbn/core-saved-objects-base-server-mocks';
|
|||
import type { CreatePointInTimeFinderFn } from './point_in_time_finder';
|
||||
import {
|
||||
ALIAS_SEARCH_PER_PAGE,
|
||||
PreflightCheckForCreateObject,
|
||||
PreflightCheckForCreateParams,
|
||||
type PreflightCheckForCreateObject,
|
||||
type PreflightCheckForCreateParams,
|
||||
} from './preflight_check_for_create';
|
||||
import { preflightCheckForCreate } from './preflight_check_for_create';
|
||||
|
||||
|
|
|
@ -24,7 +24,7 @@ import {
|
|||
type SavedObjectsSerializer,
|
||||
} from '@kbn/core-saved-objects-base-server-internal';
|
||||
import { findLegacyUrlAliases } from './legacy_url_aliases';
|
||||
import { Either, rawDocExistsInNamespaces } from './internal_utils';
|
||||
import { type Either, rawDocExistsInNamespaces } from './internal_utils';
|
||||
import { isLeft, isRight } from './internal_utils';
|
||||
import type { CreatePointInTimeFinderFn } from './point_in_time_finder';
|
||||
import type { RepositoryEsClient } from './repository_es_client';
|
||||
|
|
|
@ -0,0 +1,687 @@
|
|||
/*
|
||||
* 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 {
|
||||
pointInTimeFinderMock,
|
||||
mockGetCurrentTime,
|
||||
mockPreflightCheckForCreate,
|
||||
mockGetSearchDsl,
|
||||
} from './repository.test.mock';
|
||||
|
||||
import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
|
||||
|
||||
import { SavedObjectsRepository } from './repository';
|
||||
import { loggerMock } from '@kbn/logging-mocks';
|
||||
|
||||
import { elasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks';
|
||||
import { kibanaMigratorMock } from '../mocks';
|
||||
import { SavedObjectsSerializer } from '@kbn/core-saved-objects-base-server-internal';
|
||||
import {
|
||||
ISavedObjectsEncryptionExtension,
|
||||
SavedObjectsRawDocSource,
|
||||
} from '@kbn/core-saved-objects-server';
|
||||
import {
|
||||
bulkCreateSuccess,
|
||||
bulkGetSuccess,
|
||||
bulkUpdateSuccess,
|
||||
createDocumentMigrator,
|
||||
createRegistry,
|
||||
createSpySerializer,
|
||||
ENCRYPTED_TYPE,
|
||||
findSuccess,
|
||||
getMockGetResponse,
|
||||
mappings,
|
||||
mockTimestamp,
|
||||
mockTimestampFields,
|
||||
mockVersion,
|
||||
mockVersionProps,
|
||||
MULTI_NAMESPACE_ENCRYPTED_TYPE,
|
||||
TypeIdTuple,
|
||||
updateSuccess,
|
||||
} from '../test_helpers/repository.test.common';
|
||||
import { savedObjectsExtensionsMock } from '../mocks/saved_objects_extensions.mock';
|
||||
|
||||
// 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.
|
||||
|
||||
describe('SavedObjectsRepository Encryption Extension', () => {
|
||||
let client: ReturnType<typeof elasticsearchClientMock.createElasticsearchClient>;
|
||||
let repository: SavedObjectsRepository;
|
||||
let migrator: ReturnType<typeof kibanaMigratorMock.create>;
|
||||
let logger: ReturnType<typeof loggerMock.create>;
|
||||
let serializer: jest.Mocked<SavedObjectsSerializer>;
|
||||
let mockEncryptionExt: jest.Mocked<ISavedObjectsEncryptionExtension>;
|
||||
|
||||
const registry = createRegistry();
|
||||
const documentMigrator = createDocumentMigrator(registry);
|
||||
|
||||
const namespace = 'foo-namespace';
|
||||
|
||||
const encryptedSO = {
|
||||
id: 'encrypted-id',
|
||||
type: ENCRYPTED_TYPE,
|
||||
namespaces: [namespace],
|
||||
attributes: {
|
||||
attrNotSoSecret: '*not-so-secret*',
|
||||
attrOne: 'one',
|
||||
attrSecret: '*secret*',
|
||||
attrThree: 'three',
|
||||
title: 'Testing',
|
||||
},
|
||||
references: [],
|
||||
};
|
||||
const decryptedStrippedAttributes = {
|
||||
attributes: { attrOne: 'one', attrNotSoSecret: 'not-so-secret', attrThree: 'three' },
|
||||
};
|
||||
const nonEncryptedSO = {
|
||||
id: 'non-encrypted-id',
|
||||
type: 'index-pattern',
|
||||
namespaces: [namespace],
|
||||
attributes: { title: 'Logstash' },
|
||||
references: [],
|
||||
};
|
||||
|
||||
const instantiateRepository = () => {
|
||||
const allTypes = registry.getAllTypes().map((type) => type.name);
|
||||
const allowedTypes = [...new Set(allTypes.filter((type) => !registry.isHidden(type)))];
|
||||
|
||||
// @ts-expect-error must use the private constructor to use the mocked serializer
|
||||
return new SavedObjectsRepository({
|
||||
index: '.kibana-test',
|
||||
mappings,
|
||||
client,
|
||||
migrator,
|
||||
typeRegistry: registry,
|
||||
serializer,
|
||||
allowedTypes,
|
||||
logger,
|
||||
extensions: { encryptionExtension: mockEncryptionExt },
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
pointInTimeFinderMock.mockClear();
|
||||
client = elasticsearchClientMock.createElasticsearchClient();
|
||||
migrator = kibanaMigratorMock.create();
|
||||
documentMigrator.prepareMigrations();
|
||||
migrator.migrateDocument = jest.fn().mockImplementation(documentMigrator.migrate);
|
||||
migrator.runMigrations = jest.fn().mockResolvedValue([{ status: 'skipped' }]);
|
||||
logger = loggerMock.create();
|
||||
|
||||
// create a mock serializer "shim" so we can track function calls, but use the real serializer's implementation
|
||||
serializer = createSpySerializer(registry);
|
||||
|
||||
// create a mock saved objects encryption extension
|
||||
mockEncryptionExt = savedObjectsExtensionsMock.createEncryptionExtension();
|
||||
|
||||
mockGetCurrentTime.mockReturnValue(mockTimestamp);
|
||||
mockGetSearchDsl.mockClear();
|
||||
|
||||
repository = instantiateRepository();
|
||||
});
|
||||
|
||||
describe('#get', () => {
|
||||
it('does not attempt to decrypt or strip attributes if type is not encryptable', async () => {
|
||||
const options = { namespace };
|
||||
|
||||
const response = getMockGetResponse(registry, {
|
||||
type: nonEncryptedSO.type,
|
||||
id: nonEncryptedSO.id,
|
||||
namespace,
|
||||
});
|
||||
|
||||
client.get.mockResponseOnce(response);
|
||||
mockEncryptionExt.isEncryptableType.mockReturnValue(false);
|
||||
const result = await repository.get(nonEncryptedSO.type, nonEncryptedSO.id, options);
|
||||
expect(client.get).toHaveBeenCalledTimes(1);
|
||||
expect(mockEncryptionExt.isEncryptableType).toHaveBeenCalledTimes(1);
|
||||
expect(mockEncryptionExt.isEncryptableType).toHaveBeenCalledWith(nonEncryptedSO.type);
|
||||
expect(mockEncryptionExt.decryptOrStripResponseAttributes).not.toBeCalled();
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
type: nonEncryptedSO.type,
|
||||
id: nonEncryptedSO.id,
|
||||
namespaces: [namespace],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('decrypts and strips attributes if type is encryptable', async () => {
|
||||
const options = { namespace };
|
||||
|
||||
const response = getMockGetResponse(registry, {
|
||||
type: encryptedSO.type,
|
||||
id: encryptedSO.id,
|
||||
namespace: options.namespace,
|
||||
});
|
||||
client.get.mockResponseOnce(response);
|
||||
mockEncryptionExt.isEncryptableType.mockReturnValue(true);
|
||||
mockEncryptionExt.decryptOrStripResponseAttributes.mockResolvedValue({
|
||||
...encryptedSO,
|
||||
...decryptedStrippedAttributes,
|
||||
});
|
||||
|
||||
const result = await repository.get(encryptedSO.type, encryptedSO.id, options);
|
||||
expect(client.get).toHaveBeenCalledTimes(1);
|
||||
expect(mockEncryptionExt.isEncryptableType).toHaveBeenCalledTimes(1);
|
||||
expect(mockEncryptionExt.isEncryptableType).toHaveBeenCalledWith(encryptedSO.type);
|
||||
expect(mockEncryptionExt.decryptOrStripResponseAttributes).toHaveBeenCalledTimes(1);
|
||||
expect(mockEncryptionExt.decryptOrStripResponseAttributes).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
...encryptedSO,
|
||||
}),
|
||||
undefined
|
||||
);
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
...decryptedStrippedAttributes,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#create', () => {
|
||||
beforeEach(() => {
|
||||
mockPreflightCheckForCreate.mockReset();
|
||||
mockPreflightCheckForCreate.mockImplementation(({ objects }) => {
|
||||
return Promise.resolve(objects.map(({ type, id }) => ({ type, id }))); // respond with no errors by default
|
||||
});
|
||||
client.create.mockResponseImplementation((params) => {
|
||||
return {
|
||||
body: {
|
||||
_id: params.id,
|
||||
...mockVersionProps,
|
||||
} as estypes.CreateResponse,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
it('does not attempt to encrypt or decrypt if type is not encryptable', async () => {
|
||||
mockEncryptionExt.isEncryptableType.mockReturnValue(false);
|
||||
const result = await repository.create(nonEncryptedSO.type, nonEncryptedSO.attributes, {
|
||||
namespace,
|
||||
});
|
||||
expect(mockPreflightCheckForCreate).not.toHaveBeenCalled();
|
||||
expect(client.create).toHaveBeenCalled();
|
||||
expect(mockEncryptionExt.isEncryptableType).toHaveBeenCalledTimes(3); // getValidId, optionallyEncryptAttributes, optionallyDecryptAndRedactSingleResult
|
||||
expect(mockEncryptionExt.isEncryptableType).toHaveBeenCalledWith(nonEncryptedSO.type);
|
||||
expect(mockEncryptionExt.encryptAttributes).not.toHaveBeenCalled();
|
||||
expect(mockEncryptionExt.decryptOrStripResponseAttributes).not.toBeCalled();
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
type: nonEncryptedSO.type,
|
||||
id: expect.objectContaining(/index-pattern:[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}/),
|
||||
namespaces: [namespace],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('encrypts attributes and strips them from response if type is encryptable', async () => {
|
||||
mockEncryptionExt.isEncryptableType.mockReturnValue(true);
|
||||
mockEncryptionExt.decryptOrStripResponseAttributes.mockResolvedValue({
|
||||
...encryptedSO,
|
||||
...decryptedStrippedAttributes,
|
||||
});
|
||||
|
||||
const result = await repository.create(encryptedSO.type, encryptedSO.attributes, {
|
||||
namespace,
|
||||
});
|
||||
expect(mockPreflightCheckForCreate).not.toHaveBeenCalled();
|
||||
expect(client.create).toHaveBeenCalled();
|
||||
expect(mockEncryptionExt.isEncryptableType).toHaveBeenCalledTimes(3); // getValidId, optionallyEncryptAttributes, optionallyDecryptAndRedactSingleResult
|
||||
expect(mockEncryptionExt.isEncryptableType).toHaveBeenCalledWith(encryptedSO.type);
|
||||
expect(mockEncryptionExt.encryptAttributes).toHaveBeenCalledTimes(1);
|
||||
expect(mockEncryptionExt.encryptAttributes).toHaveBeenCalledWith(
|
||||
{
|
||||
id: expect.objectContaining(/index-pattern:[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}/),
|
||||
namespace,
|
||||
type: ENCRYPTED_TYPE,
|
||||
},
|
||||
encryptedSO.attributes
|
||||
);
|
||||
expect(mockEncryptionExt.decryptOrStripResponseAttributes).toHaveBeenCalledTimes(1);
|
||||
expect(mockEncryptionExt.decryptOrStripResponseAttributes).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
...encryptedSO,
|
||||
id: expect.objectContaining(/index-pattern:[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}/),
|
||||
attributes: undefined,
|
||||
}),
|
||||
encryptedSO.attributes // original attributes
|
||||
);
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
...decryptedStrippedAttributes,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it(`fails if non-UUID ID is specified for encrypted type`, async () => {
|
||||
mockEncryptionExt.isEncryptableType.mockReturnValue(true);
|
||||
mockEncryptionExt.decryptOrStripResponseAttributes.mockResolvedValue({
|
||||
...encryptedSO,
|
||||
...decryptedStrippedAttributes,
|
||||
});
|
||||
|
||||
await expect(
|
||||
repository.create(encryptedSO.type, encryptedSO.attributes, {
|
||||
id: 'this-should-throw-an-error',
|
||||
})
|
||||
).rejects.toThrow(
|
||||
'Predefined IDs are not allowed for saved objects with encrypted attributes unless the ID is a UUID.: Bad Request'
|
||||
);
|
||||
});
|
||||
|
||||
it(`allows a specified ID when overwriting an existing object`, async () => {
|
||||
mockEncryptionExt.isEncryptableType.mockReturnValue(true);
|
||||
mockEncryptionExt.decryptOrStripResponseAttributes.mockResolvedValue({
|
||||
...encryptedSO,
|
||||
...decryptedStrippedAttributes,
|
||||
});
|
||||
|
||||
await expect(
|
||||
repository.create(encryptedSO.type, encryptedSO.attributes, {
|
||||
id: encryptedSO.id,
|
||||
overwrite: true,
|
||||
version: mockVersion,
|
||||
})
|
||||
).resolves.not.toThrowError();
|
||||
});
|
||||
|
||||
describe('namespace', () => {
|
||||
const doTest = async (optNamespace: string, expectNamespaceInDescriptor: boolean) => {
|
||||
const options = { overwrite: true, namespace: optNamespace };
|
||||
mockEncryptionExt.isEncryptableType.mockReturnValue(true);
|
||||
|
||||
await repository.create(
|
||||
expectNamespaceInDescriptor ? ENCRYPTED_TYPE : MULTI_NAMESPACE_ENCRYPTED_TYPE,
|
||||
encryptedSO.attributes,
|
||||
options
|
||||
);
|
||||
expect(mockPreflightCheckForCreate).not.toHaveBeenCalled();
|
||||
expect(client.index).toHaveBeenCalled(); // if overwrite is true, index will be called instead of create
|
||||
expect(mockEncryptionExt.isEncryptableType).toHaveBeenCalledTimes(3); // getValidId, optionallyEncryptAttributes, optionallyDecryptAndRedactSingleResult
|
||||
expect(mockEncryptionExt.isEncryptableType).toHaveBeenCalledWith(
|
||||
expectNamespaceInDescriptor ? ENCRYPTED_TYPE : MULTI_NAMESPACE_ENCRYPTED_TYPE
|
||||
);
|
||||
expect(mockEncryptionExt.encryptAttributes).toHaveBeenCalledTimes(1);
|
||||
expect(mockEncryptionExt.encryptAttributes).toHaveBeenCalledWith(
|
||||
{
|
||||
id: expect.objectContaining(/index-pattern:[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}/),
|
||||
namespace: expectNamespaceInDescriptor ? namespace : undefined,
|
||||
type: expectNamespaceInDescriptor ? ENCRYPTED_TYPE : MULTI_NAMESPACE_ENCRYPTED_TYPE,
|
||||
},
|
||||
encryptedSO.attributes
|
||||
);
|
||||
};
|
||||
|
||||
it('uses `namespace` to encrypt attributes if it is specified when type is single-namespace', async () => {
|
||||
await doTest(namespace, true);
|
||||
});
|
||||
|
||||
it('does not use `namespace` to encrypt attributes if it is specified when type is not single-namespace', async () => {
|
||||
await doTest(namespace, false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#update', () => {
|
||||
const attributes = { title: 'Testing' };
|
||||
|
||||
beforeEach(() => {
|
||||
mockPreflightCheckForCreate.mockReset();
|
||||
mockPreflightCheckForCreate.mockImplementation(({ objects }) => {
|
||||
return Promise.resolve(objects.map(({ type, id }) => ({ type, id }))); // respond with no errors by default
|
||||
});
|
||||
});
|
||||
|
||||
it('does not attempt to encrypt or decrypt if type is not encryptable', async () => {
|
||||
mockEncryptionExt.isEncryptableType.mockReturnValue(false);
|
||||
const result = await updateSuccess(
|
||||
client,
|
||||
repository,
|
||||
registry,
|
||||
nonEncryptedSO.type,
|
||||
nonEncryptedSO.id,
|
||||
attributes,
|
||||
{
|
||||
namespace,
|
||||
}
|
||||
);
|
||||
expect(client.update).toHaveBeenCalledTimes(1);
|
||||
expect(mockEncryptionExt.isEncryptableType).toHaveBeenCalledTimes(2); // (no upsert) optionallyEncryptAttributes, optionallyDecryptAndRedactSingleResult
|
||||
expect(mockEncryptionExt.isEncryptableType).toHaveBeenCalledWith(nonEncryptedSO.type);
|
||||
expect(mockEncryptionExt.encryptAttributes).not.toHaveBeenCalled();
|
||||
expect(mockEncryptionExt.decryptOrStripResponseAttributes).not.toBeCalled();
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
type: nonEncryptedSO.type,
|
||||
id: nonEncryptedSO.id,
|
||||
namespaces: [namespace],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('encrypts attributes and strips them from response if type is encryptable', async () => {
|
||||
mockEncryptionExt.isEncryptableType.mockReturnValue(true);
|
||||
mockEncryptionExt.decryptOrStripResponseAttributes.mockResolvedValue({
|
||||
...encryptedSO,
|
||||
...decryptedStrippedAttributes,
|
||||
});
|
||||
const result = await updateSuccess(
|
||||
client,
|
||||
repository,
|
||||
registry,
|
||||
encryptedSO.type,
|
||||
encryptedSO.id,
|
||||
encryptedSO.attributes,
|
||||
{
|
||||
namespace,
|
||||
references: encryptedSO.references,
|
||||
}
|
||||
);
|
||||
expect(client.update).toHaveBeenCalledTimes(1);
|
||||
expect(mockEncryptionExt.isEncryptableType).toHaveBeenCalledTimes(2); // (no upsert) optionallyEncryptAttributes, optionallyDecryptAndRedactSingleResult
|
||||
expect(mockEncryptionExt.isEncryptableType).toHaveBeenCalledWith(encryptedSO.type);
|
||||
expect(mockEncryptionExt.encryptAttributes).toHaveBeenCalledTimes(1);
|
||||
expect(mockEncryptionExt.encryptAttributes).toHaveBeenCalledWith(
|
||||
{
|
||||
id: encryptedSO.id,
|
||||
namespace,
|
||||
type: ENCRYPTED_TYPE,
|
||||
},
|
||||
encryptedSO.attributes
|
||||
);
|
||||
expect(mockEncryptionExt.decryptOrStripResponseAttributes).toHaveBeenCalledTimes(1);
|
||||
expect(mockEncryptionExt.decryptOrStripResponseAttributes).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
...encryptedSO,
|
||||
}),
|
||||
encryptedSO.attributes // original attributes
|
||||
);
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
...decryptedStrippedAttributes,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#bulkGet', () => {
|
||||
const _expectClientCallArgs = (
|
||||
objects: TypeIdTuple[],
|
||||
{
|
||||
_index = expect.any(String),
|
||||
getId = () => expect.any(String),
|
||||
}: { _index?: string; getId?: (type: string, id: string) => string }
|
||||
) => {
|
||||
expect(client.mget).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: {
|
||||
docs: objects.map(({ type, id }) =>
|
||||
expect.objectContaining({
|
||||
_index,
|
||||
_id: getId(type, id),
|
||||
})
|
||||
),
|
||||
},
|
||||
}),
|
||||
expect.anything()
|
||||
);
|
||||
};
|
||||
|
||||
it(`only attempts to decrypt and strip attributes for types that are encryptable`, async () => {
|
||||
mockEncryptionExt.isEncryptableType.mockReturnValueOnce(false);
|
||||
mockEncryptionExt.isEncryptableType.mockReturnValueOnce(true);
|
||||
const getId = (type: string, id: string) => `${namespace}:${type}:${id}`; // test that the raw document ID equals this (e.g., has a namespace prefix)
|
||||
await bulkGetSuccess(client, repository, registry, [nonEncryptedSO, encryptedSO], {
|
||||
namespace,
|
||||
});
|
||||
_expectClientCallArgs([nonEncryptedSO, encryptedSO], { getId });
|
||||
expect(mockEncryptionExt.isEncryptableType).toBeCalledTimes(2);
|
||||
expect(mockEncryptionExt.isEncryptableType).toBeCalledWith(nonEncryptedSO.type);
|
||||
expect(mockEncryptionExt.isEncryptableType).toBeCalledWith(encryptedSO.type);
|
||||
expect(mockEncryptionExt.decryptOrStripResponseAttributes).toBeCalledTimes(1);
|
||||
expect(mockEncryptionExt.decryptOrStripResponseAttributes).toBeCalledWith(
|
||||
expect.objectContaining({ ...encryptedSO }),
|
||||
undefined
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#bulkCreate', () => {
|
||||
it(`only attempts to encrypt and decrypt attributes for types that are encryptable`, async () => {
|
||||
mockEncryptionExt.isEncryptableType.mockReturnValueOnce(false); // getValidId
|
||||
mockEncryptionExt.isEncryptableType.mockReturnValueOnce(true);
|
||||
mockEncryptionExt.isEncryptableType.mockReturnValueOnce(false); // optionallyEncryptAttributes
|
||||
mockEncryptionExt.isEncryptableType.mockReturnValueOnce(true);
|
||||
mockEncryptionExt.isEncryptableType.mockReturnValueOnce(false); // optionallyDecryptAndRedactSingleResult
|
||||
mockEncryptionExt.isEncryptableType.mockReturnValueOnce(true);
|
||||
await bulkCreateSuccess(client, repository, [
|
||||
nonEncryptedSO,
|
||||
{ ...encryptedSO, id: undefined }, // Predefined IDs are not allowed for saved objects with encrypted attributes unless the ID is a UUID
|
||||
]);
|
||||
expect(client.bulk).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(mockEncryptionExt.isEncryptableType).toHaveBeenCalledTimes(6); // x2 getValidId, optionallyEncryptAttributes, optionallyDecryptAndRedactSingleResult
|
||||
expect(mockEncryptionExt.isEncryptableType).toHaveBeenCalledWith(nonEncryptedSO.type);
|
||||
expect(mockEncryptionExt.isEncryptableType).toHaveBeenCalledWith(encryptedSO.type);
|
||||
expect(mockEncryptionExt.encryptAttributes).toHaveBeenCalledTimes(1);
|
||||
expect(mockEncryptionExt.encryptAttributes).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: encryptedSO.type }),
|
||||
encryptedSO.attributes
|
||||
);
|
||||
expect(mockEncryptionExt.decryptOrStripResponseAttributes).toHaveBeenCalledTimes(1);
|
||||
expect(mockEncryptionExt.decryptOrStripResponseAttributes).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: encryptedSO.type }),
|
||||
encryptedSO.attributes
|
||||
);
|
||||
});
|
||||
|
||||
it(`fails if non-UUID ID is specified for encrypted type`, async () => {
|
||||
mockEncryptionExt.isEncryptableType.mockReturnValue(true);
|
||||
const result = await bulkCreateSuccess(client, repository, [
|
||||
encryptedSO, // Predefined IDs are not allowed for saved objects with encrypted attributes unless the ID is a UUID
|
||||
]);
|
||||
expect(client.bulk).not.toHaveBeenCalled();
|
||||
expect(result.saved_objects).not.toBeUndefined();
|
||||
expect(result.saved_objects.length).toBe(1);
|
||||
expect(result.saved_objects[0].error).not.toBeUndefined();
|
||||
expect(result.saved_objects[0].error).toMatchObject({
|
||||
statusCode: 400,
|
||||
error: 'Bad Request',
|
||||
message:
|
||||
'Predefined IDs are not allowed for saved objects with encrypted attributes unless the ID is a UUID.: Bad Request',
|
||||
});
|
||||
});
|
||||
|
||||
it(`does not fail if ID is specified for not encrypted type`, async () => {
|
||||
mockEncryptionExt.isEncryptableType.mockReturnValue(false);
|
||||
const result = await bulkCreateSuccess(client, repository, [nonEncryptedSO]);
|
||||
expect(client.bulk).toHaveBeenCalledTimes(1);
|
||||
expect(result.saved_objects).not.toBeUndefined();
|
||||
expect(result.saved_objects.length).toBe(1);
|
||||
expect(result.saved_objects[0].error).toBeUndefined();
|
||||
});
|
||||
|
||||
it(`allows a specified ID when overwriting an existing object`, async () => {
|
||||
mockEncryptionExt.isEncryptableType.mockReturnValue(true);
|
||||
mockEncryptionExt.decryptOrStripResponseAttributes.mockResolvedValue({
|
||||
...encryptedSO,
|
||||
version: mockVersion,
|
||||
...decryptedStrippedAttributes,
|
||||
});
|
||||
|
||||
const result = await bulkCreateSuccess(
|
||||
client,
|
||||
repository,
|
||||
[{ ...encryptedSO, version: mockVersion }],
|
||||
{
|
||||
overwrite: true,
|
||||
// version: mockVersion, // this doesn't work in bulk...looks like it checks the object itself?
|
||||
}
|
||||
);
|
||||
expect(client.bulk).toHaveBeenCalledTimes(1);
|
||||
expect(result.saved_objects).not.toBeUndefined();
|
||||
expect(result.saved_objects.length).toBe(1);
|
||||
expect(result.saved_objects[0].error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('#bulkUpdate', () => {
|
||||
it(`only attempts to encrypt and decrypt attributes for types that are encryptable`, async () => {
|
||||
mockEncryptionExt.isEncryptableType.mockReturnValueOnce(false); // optionallyEncryptAttributes
|
||||
mockEncryptionExt.isEncryptableType.mockReturnValueOnce(true);
|
||||
mockEncryptionExt.isEncryptableType.mockReturnValueOnce(false); // optionallyDecryptAndRedactSingleResult
|
||||
mockEncryptionExt.isEncryptableType.mockReturnValueOnce(true);
|
||||
|
||||
await bulkUpdateSuccess(client, repository, registry, [nonEncryptedSO, encryptedSO]);
|
||||
expect(client.bulk).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(mockEncryptionExt.isEncryptableType).toHaveBeenCalledTimes(4); // 2x optionallyEncryptAttributes, optionallyDecryptAndRedactSingleResult
|
||||
expect(mockEncryptionExt.isEncryptableType).toHaveBeenCalledWith(nonEncryptedSO.type);
|
||||
expect(mockEncryptionExt.isEncryptableType).toHaveBeenCalledWith(encryptedSO.type);
|
||||
expect(mockEncryptionExt.encryptAttributes).toHaveBeenCalledTimes(1);
|
||||
expect(mockEncryptionExt.encryptAttributes).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: encryptedSO.type }),
|
||||
encryptedSO.attributes
|
||||
);
|
||||
expect(mockEncryptionExt.decryptOrStripResponseAttributes).toHaveBeenCalledTimes(1);
|
||||
expect(mockEncryptionExt.decryptOrStripResponseAttributes).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: encryptedSO.type }),
|
||||
encryptedSO.attributes
|
||||
);
|
||||
});
|
||||
|
||||
it('does not use options `namespace` or object `namespace` to encrypt attributes if neither are specified', async () => {
|
||||
mockEncryptionExt.isEncryptableType.mockReturnValue(true);
|
||||
|
||||
await bulkUpdateSuccess(
|
||||
client,
|
||||
repository,
|
||||
registry,
|
||||
[{ ...encryptedSO, namespace: undefined }],
|
||||
{ namespace: undefined }
|
||||
);
|
||||
expect(client.bulk).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(mockEncryptionExt.isEncryptableType).toHaveBeenCalledTimes(2); // 2x optionallyEncryptAttributes, optionallyDecryptAndRedactSingleResult
|
||||
expect(mockEncryptionExt.isEncryptableType).toHaveBeenCalledWith(encryptedSO.type);
|
||||
expect(mockEncryptionExt.encryptAttributes).toHaveBeenCalledTimes(1);
|
||||
expect(mockEncryptionExt.encryptAttributes).toHaveBeenCalledWith(
|
||||
{ id: encryptedSO.id, type: encryptedSO.type, namespace: undefined },
|
||||
encryptedSO.attributes
|
||||
);
|
||||
});
|
||||
|
||||
it('with a single-namespace type...uses options `namespace` to encrypt attributes if it is specified and object `namespace` is not', async () => {
|
||||
mockEncryptionExt.isEncryptableType.mockReturnValue(true);
|
||||
const usedNamespace = 'options-namespace';
|
||||
|
||||
await bulkUpdateSuccess(
|
||||
client,
|
||||
repository,
|
||||
registry,
|
||||
[{ ...encryptedSO, namespace: undefined }],
|
||||
{ namespace: usedNamespace }
|
||||
);
|
||||
expect(client.bulk).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(mockEncryptionExt.isEncryptableType).toHaveBeenCalledTimes(2); // 2x optionallyEncryptAttributes, optionallyDecryptAndRedactSingleResult
|
||||
expect(mockEncryptionExt.isEncryptableType).toHaveBeenCalledWith(encryptedSO.type);
|
||||
expect(mockEncryptionExt.encryptAttributes).toHaveBeenCalledTimes(1);
|
||||
expect(mockEncryptionExt.encryptAttributes).toHaveBeenCalledWith(
|
||||
{ id: encryptedSO.id, type: encryptedSO.type, namespace: usedNamespace },
|
||||
encryptedSO.attributes
|
||||
);
|
||||
});
|
||||
|
||||
it('with a single-namespace type...uses object `namespace` to encrypt attributes if it is specified', async () => {
|
||||
mockEncryptionExt.isEncryptableType.mockReturnValue(true);
|
||||
const usedNamespace = 'object-namespace';
|
||||
|
||||
await bulkUpdateSuccess(
|
||||
client,
|
||||
repository,
|
||||
registry,
|
||||
[{ ...encryptedSO, namespace: usedNamespace }],
|
||||
{ namespace: undefined }
|
||||
);
|
||||
expect(client.bulk).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(mockEncryptionExt.isEncryptableType).toHaveBeenCalledTimes(2); // 2x optionallyEncryptAttributes, optionallyDecryptAndRedactSingleResult
|
||||
expect(mockEncryptionExt.isEncryptableType).toHaveBeenCalledWith(encryptedSO.type);
|
||||
expect(mockEncryptionExt.encryptAttributes).toHaveBeenCalledTimes(1);
|
||||
expect(mockEncryptionExt.encryptAttributes).toHaveBeenCalledWith(
|
||||
{ id: encryptedSO.id, type: encryptedSO.type, namespace: usedNamespace },
|
||||
encryptedSO.attributes
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#find', () => {
|
||||
const generateSearchResults = (space?: string) => {
|
||||
return {
|
||||
took: 1,
|
||||
timed_out: false,
|
||||
_shards: {} as any,
|
||||
hits: {
|
||||
total: 2,
|
||||
hits: [
|
||||
{
|
||||
_index: '.kibana',
|
||||
_id: `${space ? `${space}:` : ''}${encryptedSO.type}:${encryptedSO.id}`,
|
||||
_score: 1,
|
||||
...mockVersionProps,
|
||||
_source: {
|
||||
...encryptedSO,
|
||||
originId: 'some-origin-id', // only one of the results has an originId, this is intentional to test both a positive and negative case
|
||||
},
|
||||
},
|
||||
{
|
||||
_index: '.kibana',
|
||||
_id: `${space ? `${space}:` : ''}index-pattern:logstash-*`,
|
||||
_score: 2,
|
||||
...mockVersionProps,
|
||||
_source: {
|
||||
namespace: space,
|
||||
originId: 'some-origin-id', // only one of the results has an originId, this is intentional to test both a positive and negative case
|
||||
type: 'index-pattern',
|
||||
...mockTimestampFields,
|
||||
'index-pattern': {
|
||||
title: 'logstash-*',
|
||||
timeFieldName: '@timestamp',
|
||||
notExpandable: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as estypes.SearchResponse<SavedObjectsRawDocSource>;
|
||||
};
|
||||
|
||||
it(`only attempts to decrypt and strip attributes for types that are encryptable`, async () => {
|
||||
mockEncryptionExt.isEncryptableType.mockReturnValueOnce(true);
|
||||
await findSuccess(
|
||||
client,
|
||||
repository,
|
||||
{ type: [encryptedSO.type, 'index-pattern'] },
|
||||
undefined,
|
||||
generateSearchResults
|
||||
);
|
||||
expect(client.search).toHaveBeenCalledTimes(1);
|
||||
expect(mockEncryptionExt.isEncryptableType).toBeCalledTimes(2);
|
||||
expect(mockEncryptionExt.isEncryptableType).toBeCalledWith(encryptedSO.type);
|
||||
expect(mockEncryptionExt.isEncryptableType).toBeCalledWith('index-pattern');
|
||||
expect(mockEncryptionExt.decryptOrStripResponseAttributes).toBeCalledTimes(1);
|
||||
expect(mockEncryptionExt.decryptOrStripResponseAttributes).toBeCalledWith(
|
||||
expect.objectContaining({ type: encryptedSO.type, id: encryptedSO.id }),
|
||||
undefined
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
File diff suppressed because it is too large
Load diff
|
@ -0,0 +1,925 @@
|
|||
/*
|
||||
* 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 {
|
||||
pointInTimeFinderMock,
|
||||
mockGetCurrentTime,
|
||||
mockPreflightCheckForCreate,
|
||||
mockUpdateObjectsSpaces,
|
||||
mockGetSearchDsl,
|
||||
mockCollectMultiNamespaceReferences,
|
||||
mockInternalBulkResolve,
|
||||
mockDeleteLegacyUrlAliases,
|
||||
} from './repository.test.mock';
|
||||
|
||||
import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
|
||||
|
||||
import { SavedObjectsRepository } from './repository';
|
||||
import { loggerMock } from '@kbn/logging-mocks';
|
||||
import { elasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks';
|
||||
import {
|
||||
SavedObjectsResolveResponse,
|
||||
SavedObjectsBulkUpdateObject,
|
||||
} from '@kbn/core-saved-objects-api-server';
|
||||
import { SavedObjectsSerializer } from '@kbn/core-saved-objects-base-server-internal';
|
||||
import { SavedObject } from '@kbn/core-saved-objects-common';
|
||||
import {
|
||||
ISavedObjectsSpacesExtension,
|
||||
ISavedObjectsSecurityExtension,
|
||||
} from '@kbn/core-saved-objects-server';
|
||||
import { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-utils-server';
|
||||
import { kibanaMigratorMock } from '../mocks';
|
||||
import {
|
||||
createRegistry,
|
||||
createDocumentMigrator,
|
||||
mappings,
|
||||
DEFAULT_SPACE,
|
||||
createSpySerializer,
|
||||
mockTimestamp,
|
||||
CUSTOM_INDEX_TYPE,
|
||||
getMockGetResponse,
|
||||
updateSuccess,
|
||||
deleteSuccess,
|
||||
removeReferencesToSuccess,
|
||||
MULTI_NAMESPACE_ISOLATED_TYPE,
|
||||
checkConflictsSuccess,
|
||||
MULTI_NAMESPACE_TYPE,
|
||||
bulkGetSuccess,
|
||||
bulkCreateSuccess,
|
||||
bulkUpdateSuccess,
|
||||
findSuccess,
|
||||
setupCheckUnauthorized,
|
||||
generateIndexPatternSearchResults,
|
||||
bulkDeleteSuccess,
|
||||
} from '../test_helpers/repository.test.common';
|
||||
import { savedObjectsExtensionsMock } from '../mocks/saved_objects_extensions.mock';
|
||||
|
||||
// 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.
|
||||
|
||||
const ERROR_NAMESPACE_SPECIFIED = 'Spaces currently determines the namespaces';
|
||||
|
||||
describe('SavedObjectsRepository Spaces Extension', () => {
|
||||
let client: ReturnType<typeof elasticsearchClientMock.createElasticsearchClient>;
|
||||
let repository: SavedObjectsRepository;
|
||||
let migrator: ReturnType<typeof kibanaMigratorMock.create>;
|
||||
let logger: ReturnType<typeof loggerMock.create>;
|
||||
let serializer: jest.Mocked<SavedObjectsSerializer>;
|
||||
let mockSpacesExt: jest.Mocked<ISavedObjectsSpacesExtension>;
|
||||
let mockSecurityExt: jest.Mocked<ISavedObjectsSecurityExtension>;
|
||||
|
||||
const registry = createRegistry();
|
||||
const documentMigrator = createDocumentMigrator(registry);
|
||||
|
||||
// const currentSpace = 'foo-namespace';
|
||||
const defaultOptions = { ignore: [404], maxRetries: 0, meta: true }; // These are just the hard-coded options passed in via the repo
|
||||
|
||||
const instantiateRepository = () => {
|
||||
const allTypes = registry.getAllTypes().map((type) => type.name);
|
||||
const allowedTypes = [...new Set(allTypes.filter((type) => !registry.isHidden(type)))];
|
||||
|
||||
// @ts-expect-error must use the private constructor to use the mocked serializer
|
||||
return new SavedObjectsRepository({
|
||||
index: '.kibana-test',
|
||||
mappings,
|
||||
client,
|
||||
migrator,
|
||||
typeRegistry: registry,
|
||||
serializer,
|
||||
allowedTypes,
|
||||
logger,
|
||||
extensions: { spacesExtension: mockSpacesExt, securityExtension: mockSecurityExt },
|
||||
});
|
||||
};
|
||||
|
||||
const availableSpaces = [
|
||||
{ id: 'default', name: '', disabledFeatures: [] },
|
||||
{ id: 'ns-1', name: '', disabledFeatures: [] },
|
||||
{ id: 'ns-2', name: '', disabledFeatures: [] },
|
||||
{ id: 'ns-3', name: '', disabledFeatures: [] },
|
||||
{ id: 'ns-4', name: '', disabledFeatures: [] },
|
||||
];
|
||||
|
||||
[
|
||||
{ id: DEFAULT_SPACE, expectedNamespace: undefined },
|
||||
{ id: 'ns-1', expectedNamespace: 'ns-1' },
|
||||
].forEach((currentSpace) => {
|
||||
describe(`${currentSpace.id} space`, () => {
|
||||
beforeEach(() => {
|
||||
pointInTimeFinderMock.mockClear();
|
||||
client = elasticsearchClientMock.createElasticsearchClient();
|
||||
migrator = kibanaMigratorMock.create();
|
||||
documentMigrator.prepareMigrations();
|
||||
migrator.migrateDocument = jest.fn().mockImplementation(documentMigrator.migrate);
|
||||
migrator.runMigrations = jest.fn().mockResolvedValue([{ status: 'skipped' }]);
|
||||
logger = loggerMock.create();
|
||||
|
||||
// create a mock serializer "shim" so we can track function calls, but use the real serializer's implementation
|
||||
serializer = createSpySerializer(registry);
|
||||
|
||||
// create a mock saved objects spaces extension
|
||||
mockSpacesExt = savedObjectsExtensionsMock.createSpacesExtension();
|
||||
|
||||
mockGetCurrentTime.mockReturnValue(mockTimestamp);
|
||||
mockGetSearchDsl.mockClear();
|
||||
|
||||
repository = instantiateRepository();
|
||||
|
||||
mockSpacesExt.getCurrentNamespace.mockImplementation((namespace: string | undefined) => {
|
||||
if (namespace) {
|
||||
throw SavedObjectsErrorHelpers.createBadRequestError(ERROR_NAMESPACE_SPECIFIED);
|
||||
}
|
||||
return currentSpace.expectedNamespace;
|
||||
});
|
||||
|
||||
mockSpacesExt.getSearchableNamespaces.mockImplementation(
|
||||
(namespaces: string[] | undefined): Promise<string[]> => {
|
||||
if (!namespaces) {
|
||||
return Promise.resolve(['current-space'] as string[]);
|
||||
} else if (!namespaces.length) {
|
||||
return Promise.resolve(namespaces);
|
||||
}
|
||||
|
||||
if (namespaces?.includes('*')) {
|
||||
return Promise.resolve(availableSpaces.map((space) => space.id));
|
||||
} else {
|
||||
return Promise.resolve(
|
||||
namespaces?.filter((namespace) =>
|
||||
availableSpaces.some((space) => space.id === namespace)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('#get', () => {
|
||||
test(`throws error if options.namespace is specified`, async () => {
|
||||
// Just makes sure the error propagates from the extension through the repo call
|
||||
await expect(repository.get('foo', '', { namespace: 'bar' })).rejects.toThrowError(
|
||||
SavedObjectsErrorHelpers.createBadRequestError(ERROR_NAMESPACE_SPECIFIED)
|
||||
);
|
||||
expect(mockSpacesExt.getCurrentNamespace).toBeCalledTimes(1);
|
||||
expect(mockSpacesExt.getCurrentNamespace).toHaveBeenCalledWith('bar');
|
||||
});
|
||||
|
||||
test(`supplements id with the current namespace`, async () => {
|
||||
const type = CUSTOM_INDEX_TYPE;
|
||||
const id = 'some-id';
|
||||
|
||||
const response = getMockGetResponse(registry, {
|
||||
type,
|
||||
id,
|
||||
});
|
||||
|
||||
client.get.mockResponseOnce(response);
|
||||
await repository.get(type, id);
|
||||
expect(mockSpacesExt.getCurrentNamespace).toBeCalledTimes(1);
|
||||
expect(mockSpacesExt.getCurrentNamespace).toHaveBeenCalledWith(undefined);
|
||||
expect(client.get).toHaveBeenCalledTimes(1);
|
||||
expect(client.get).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: `${
|
||||
currentSpace.expectedNamespace ? `${currentSpace.expectedNamespace}:` : ''
|
||||
}${type}:${id}`,
|
||||
}),
|
||||
defaultOptions
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#update', () => {
|
||||
test(`throws error if options.namespace is specified`, async () => {
|
||||
// Just makes sure the error propagates from the extension through the repo call
|
||||
await expect(
|
||||
repository.update('foo', 'some-id', { attr: 'value' }, { namespace: 'bar' })
|
||||
).rejects.toThrowError(
|
||||
SavedObjectsErrorHelpers.createBadRequestError(ERROR_NAMESPACE_SPECIFIED)
|
||||
);
|
||||
expect(mockSpacesExt.getCurrentNamespace).toBeCalledTimes(1);
|
||||
expect(mockSpacesExt.getCurrentNamespace).toBeCalledWith('bar');
|
||||
});
|
||||
|
||||
test(`supplements internal parameters with the current namespace`, async () => {
|
||||
const type = CUSTOM_INDEX_TYPE;
|
||||
const id = 'some-id';
|
||||
|
||||
await updateSuccess(
|
||||
client,
|
||||
repository,
|
||||
registry,
|
||||
type,
|
||||
id,
|
||||
{},
|
||||
{ upsert: true },
|
||||
{ mockGetResponseValue: { found: false } as estypes.GetResponse }
|
||||
);
|
||||
expect(mockSpacesExt.getCurrentNamespace).toBeCalledTimes(1);
|
||||
expect(mockSpacesExt.getCurrentNamespace).toHaveBeenCalledWith(undefined);
|
||||
expect(client.update).toHaveBeenCalledTimes(1);
|
||||
expect(client.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: `${
|
||||
currentSpace.expectedNamespace ? `${currentSpace.expectedNamespace}:` : ''
|
||||
}${type}:${id}`,
|
||||
body: expect.objectContaining({
|
||||
upsert: expect.objectContaining(
|
||||
currentSpace.expectedNamespace
|
||||
? {
|
||||
namespace: currentSpace.expectedNamespace,
|
||||
}
|
||||
: {}
|
||||
),
|
||||
}),
|
||||
}),
|
||||
{ maxRetries: 0 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#create', () => {
|
||||
test(`throws error if options.namespace is specified`, async () => {
|
||||
await expect(
|
||||
repository.create('foo', { attr: 'value' }, { namespace: 'bar' })
|
||||
).rejects.toThrowError(
|
||||
SavedObjectsErrorHelpers.createBadRequestError(ERROR_NAMESPACE_SPECIFIED)
|
||||
);
|
||||
expect(mockSpacesExt.getCurrentNamespace).toBeCalledTimes(1);
|
||||
expect(mockSpacesExt.getCurrentNamespace).toBeCalledWith('bar');
|
||||
});
|
||||
|
||||
test(`supplements internal parameters with the current namespace`, async () => {
|
||||
const type = CUSTOM_INDEX_TYPE;
|
||||
const attributes = { attr: 'value' };
|
||||
|
||||
await repository.create(type, { attr: 'value' });
|
||||
expect(mockSpacesExt.getCurrentNamespace).toBeCalledTimes(1);
|
||||
expect(mockSpacesExt.getCurrentNamespace).toHaveBeenCalledWith(undefined);
|
||||
expect(client.create).toHaveBeenCalledTimes(1);
|
||||
const regex = new RegExp(
|
||||
`${
|
||||
currentSpace.expectedNamespace ? `${currentSpace.expectedNamespace}:` : ''
|
||||
}${type}:[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}`
|
||||
);
|
||||
expect(client.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: expect.stringMatching(regex),
|
||||
body: expect.objectContaining(
|
||||
currentSpace.expectedNamespace
|
||||
? {
|
||||
namespace: currentSpace.expectedNamespace,
|
||||
type: CUSTOM_INDEX_TYPE,
|
||||
customIndex: attributes,
|
||||
}
|
||||
: { type: CUSTOM_INDEX_TYPE, customIndex: attributes }
|
||||
),
|
||||
}),
|
||||
{ maxRetries: 0, meta: true }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#delete', () => {
|
||||
test(`throws error if options.namespace is specified`, async () => {
|
||||
await expect(
|
||||
repository.delete('foo', 'some-id', { namespace: 'bar' })
|
||||
).rejects.toThrowError(
|
||||
SavedObjectsErrorHelpers.createBadRequestError(ERROR_NAMESPACE_SPECIFIED)
|
||||
);
|
||||
expect(mockSpacesExt.getCurrentNamespace).toBeCalledTimes(1);
|
||||
expect(mockSpacesExt.getCurrentNamespace).toBeCalledWith('bar');
|
||||
});
|
||||
|
||||
test(`supplements id with the current namespace`, async () => {
|
||||
const type = CUSTOM_INDEX_TYPE;
|
||||
const id = 'some-id';
|
||||
|
||||
await deleteSuccess(client, repository, registry, type, id);
|
||||
expect(mockSpacesExt.getCurrentNamespace).toBeCalledTimes(1);
|
||||
expect(mockSpacesExt.getCurrentNamespace).toHaveBeenCalledWith(undefined);
|
||||
expect(client.delete).toHaveBeenCalledTimes(1);
|
||||
const regex = new RegExp(
|
||||
`${
|
||||
currentSpace.expectedNamespace ? `${currentSpace.expectedNamespace}:` : ''
|
||||
}${type}:${id}`
|
||||
);
|
||||
expect(client.delete).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: expect.stringMatching(regex),
|
||||
}),
|
||||
{ ignore: [404], maxRetries: 0, meta: true }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#removeReferencesTo', () => {
|
||||
test(`throws error if options.namespace is specified`, async () => {
|
||||
await expect(
|
||||
repository.removeReferencesTo('foo', 'some-id', { namespace: 'bar' })
|
||||
).rejects.toThrowError(
|
||||
SavedObjectsErrorHelpers.createBadRequestError(ERROR_NAMESPACE_SPECIFIED)
|
||||
);
|
||||
expect(mockSpacesExt.getCurrentNamespace).toBeCalledTimes(1);
|
||||
expect(mockSpacesExt.getCurrentNamespace).toBeCalledWith('bar');
|
||||
});
|
||||
|
||||
test(`supplements internal parameters with the current namespace`, async () => {
|
||||
const type = CUSTOM_INDEX_TYPE;
|
||||
const id = 'some-id';
|
||||
|
||||
const query = { query: 1, aggregations: 2 };
|
||||
mockGetSearchDsl.mockReturnValue(query);
|
||||
|
||||
await removeReferencesToSuccess(client, repository, type, id);
|
||||
expect(mockSpacesExt.getCurrentNamespace).toBeCalledTimes(1);
|
||||
expect(mockSpacesExt.getCurrentNamespace).toHaveBeenCalledWith(undefined);
|
||||
expect(client.updateByQuery).toHaveBeenCalledTimes(1);
|
||||
expect(mockGetSearchDsl).toHaveBeenCalledTimes(1);
|
||||
expect(mockGetSearchDsl).toHaveBeenCalledWith(
|
||||
mappings,
|
||||
registry,
|
||||
expect.objectContaining({
|
||||
namespaces: currentSpace.expectedNamespace
|
||||
? [currentSpace.expectedNamespace]
|
||||
: undefined,
|
||||
hasReference: { type, id },
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#checkConflicts', () => {
|
||||
test(`throws error if options.namespace is specified`, async () => {
|
||||
await expect(
|
||||
repository.checkConflicts(undefined, { namespace: 'bar' })
|
||||
).rejects.toThrowError(
|
||||
SavedObjectsErrorHelpers.createBadRequestError(ERROR_NAMESPACE_SPECIFIED)
|
||||
);
|
||||
expect(mockSpacesExt.getCurrentNamespace).toBeCalledTimes(1);
|
||||
expect(mockSpacesExt.getCurrentNamespace).toBeCalledWith('bar');
|
||||
});
|
||||
|
||||
test(`supplements internal parameters with the current namespace`, async () => {
|
||||
const obj1 = { type: CUSTOM_INDEX_TYPE, id: 'one' };
|
||||
const obj2 = { type: MULTI_NAMESPACE_ISOLATED_TYPE, id: 'two' };
|
||||
|
||||
await checkConflictsSuccess(client, repository, registry, [obj1, obj2]);
|
||||
expect(mockSpacesExt.getCurrentNamespace).toBeCalledTimes(1);
|
||||
expect(mockSpacesExt.getCurrentNamespace).toHaveBeenCalledWith(undefined);
|
||||
expect(client.mget).toHaveBeenCalledTimes(1);
|
||||
expect(client.mget).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
docs: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
_id: `${
|
||||
currentSpace.expectedNamespace ? `${currentSpace.expectedNamespace}:` : ''
|
||||
}${obj1.type}:${obj1.id}`,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
_id: `${obj2.type}:${obj2.id}`,
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
{ ignore: [404], maxRetries: 0, meta: true }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#updateObjectSpaces', () => {
|
||||
afterEach(() => {
|
||||
mockUpdateObjectsSpaces.mockReset();
|
||||
});
|
||||
|
||||
test(`throws error if options.namespace is specified`, async () => {
|
||||
await expect(
|
||||
repository.updateObjectsSpaces([], [], [], { namespace: 'bar' })
|
||||
).rejects.toThrowError(
|
||||
SavedObjectsErrorHelpers.createBadRequestError(ERROR_NAMESPACE_SPECIFIED)
|
||||
);
|
||||
expect(mockSpacesExt.getCurrentNamespace).toBeCalledTimes(1);
|
||||
expect(mockSpacesExt.getCurrentNamespace).toBeCalledWith('bar');
|
||||
});
|
||||
|
||||
test(`supplements internal parameters with the current namespace`, async () => {
|
||||
const obj1 = { type: CUSTOM_INDEX_TYPE, id: 'one' };
|
||||
const obj2 = { type: MULTI_NAMESPACE_ISOLATED_TYPE, id: 'two' };
|
||||
const spacesToAdd = ['space-x'];
|
||||
const spacesToRemove = ['space-y'];
|
||||
|
||||
await repository.updateObjectsSpaces([obj1, obj2], spacesToAdd, spacesToRemove);
|
||||
expect(mockSpacesExt.getCurrentNamespace).toBeCalledTimes(1);
|
||||
expect(mockSpacesExt.getCurrentNamespace).toHaveBeenCalledWith(undefined);
|
||||
expect(mockUpdateObjectsSpaces).toHaveBeenCalledTimes(1);
|
||||
expect(mockUpdateObjectsSpaces).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
objects: [obj1, obj2],
|
||||
options: {
|
||||
namespace: currentSpace.expectedNamespace
|
||||
? currentSpace.expectedNamespace
|
||||
: undefined,
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#collectMultiNamespaceReferences', () => {
|
||||
afterEach(() => {
|
||||
mockCollectMultiNamespaceReferences.mockReset();
|
||||
});
|
||||
|
||||
test(`throws error if options.namespace is specified`, async () => {
|
||||
await expect(
|
||||
repository.collectMultiNamespaceReferences([], { namespace: 'bar' })
|
||||
).rejects.toThrowError(
|
||||
SavedObjectsErrorHelpers.createBadRequestError(ERROR_NAMESPACE_SPECIFIED)
|
||||
);
|
||||
expect(mockSpacesExt.getCurrentNamespace).toBeCalledTimes(1);
|
||||
expect(mockSpacesExt.getCurrentNamespace).toBeCalledWith('bar');
|
||||
});
|
||||
|
||||
test(`supplements internal parameters with the current namespace`, async () => {
|
||||
const obj1 = { type: CUSTOM_INDEX_TYPE, id: 'one' };
|
||||
const obj2 = { type: MULTI_NAMESPACE_ISOLATED_TYPE, id: 'two' };
|
||||
|
||||
await repository.collectMultiNamespaceReferences([obj1, obj2]);
|
||||
expect(mockSpacesExt.getCurrentNamespace).toBeCalledTimes(1);
|
||||
expect(mockSpacesExt.getCurrentNamespace).toHaveBeenCalledWith(undefined);
|
||||
expect(mockCollectMultiNamespaceReferences).toHaveBeenCalledTimes(1);
|
||||
expect(mockCollectMultiNamespaceReferences).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
objects: [obj1, obj2],
|
||||
options: {
|
||||
namespace: currentSpace.expectedNamespace
|
||||
? currentSpace.expectedNamespace
|
||||
: undefined,
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#openPointInTimeForType', () => {
|
||||
test(`propagates options.namespaces: ['*']`, async () => {
|
||||
await repository.openPointInTimeForType(CUSTOM_INDEX_TYPE, { namespaces: ['*'] });
|
||||
expect(mockSpacesExt.getSearchableNamespaces).toBeCalledTimes(1);
|
||||
expect(mockSpacesExt.getSearchableNamespaces).toBeCalledWith(['*']);
|
||||
});
|
||||
|
||||
test(`supplements options with the current namespace`, async () => {
|
||||
await repository.openPointInTimeForType(CUSTOM_INDEX_TYPE);
|
||||
expect(mockSpacesExt.getSearchableNamespaces).toBeCalledTimes(1);
|
||||
expect(mockSpacesExt.getSearchableNamespaces).toBeCalledWith(undefined); // will resolve current space
|
||||
});
|
||||
});
|
||||
|
||||
describe('#resolve', () => {
|
||||
afterEach(() => {
|
||||
mockInternalBulkResolve.mockReset();
|
||||
});
|
||||
|
||||
test(`throws error if options.namespace is specified`, async () => {
|
||||
await expect(
|
||||
repository.resolve('foo', 'some-id', { namespace: 'bar' })
|
||||
).rejects.toThrowError(
|
||||
SavedObjectsErrorHelpers.createBadRequestError(ERROR_NAMESPACE_SPECIFIED)
|
||||
);
|
||||
expect(mockSpacesExt.getCurrentNamespace).toBeCalledTimes(1);
|
||||
expect(mockSpacesExt.getCurrentNamespace).toBeCalledWith('bar');
|
||||
});
|
||||
|
||||
test(`supplements internal parameters with the current namespace`, async () => {
|
||||
const type = CUSTOM_INDEX_TYPE;
|
||||
const id = 'some-id';
|
||||
|
||||
const expectedResult: SavedObjectsResolveResponse = {
|
||||
saved_object: { type, id, attributes: {}, references: [] },
|
||||
outcome: 'exactMatch',
|
||||
};
|
||||
mockInternalBulkResolve.mockResolvedValue({ resolved_objects: [expectedResult] });
|
||||
await repository.resolve(type, id);
|
||||
expect(mockInternalBulkResolve).toHaveBeenCalledTimes(1);
|
||||
expect(mockInternalBulkResolve).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
objects: [{ type, id }],
|
||||
options: {
|
||||
namespace: currentSpace.expectedNamespace
|
||||
? currentSpace.expectedNamespace
|
||||
: undefined,
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#bulkGet', () => {
|
||||
const obj1: SavedObject<unknown> = {
|
||||
type: 'config',
|
||||
id: '6.0.0-alpha1',
|
||||
attributes: { title: 'Testing' },
|
||||
references: [
|
||||
{
|
||||
name: 'ref_0',
|
||||
type: 'test',
|
||||
id: '1',
|
||||
},
|
||||
],
|
||||
originId: 'some-origin-id', // only one of the results has an originId, this is intentional to test both a positive and negative case
|
||||
};
|
||||
const obj2: SavedObject<unknown> = {
|
||||
type: MULTI_NAMESPACE_TYPE,
|
||||
id: 'logstash-*',
|
||||
attributes: { title: 'Testing' },
|
||||
references: [
|
||||
{
|
||||
name: 'ref_0',
|
||||
type: 'test',
|
||||
id: '2',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
test(`throws error if options.namespace is specified`, async () => {
|
||||
await expect(
|
||||
bulkGetSuccess(client, repository, registry, [obj1, obj2], { namespace: 'foo-bar' })
|
||||
).rejects.toThrowError(
|
||||
SavedObjectsErrorHelpers.createBadRequestError(ERROR_NAMESPACE_SPECIFIED)
|
||||
);
|
||||
expect(mockSpacesExt.getCurrentNamespace).toBeCalledTimes(1);
|
||||
expect(mockSpacesExt.getCurrentNamespace).toBeCalledWith('foo-bar');
|
||||
});
|
||||
|
||||
test(`supplements internal parameters with the current namespace`, async () => {
|
||||
await bulkGetSuccess(client, repository, registry, [obj1, obj2]);
|
||||
expect(mockSpacesExt.getCurrentNamespace).toBeCalledTimes(1);
|
||||
expect(mockSpacesExt.getCurrentNamespace).toHaveBeenCalledWith(undefined);
|
||||
expect(mockSpacesExt.getSearchableNamespaces).not.toHaveBeenCalled();
|
||||
expect(client.mget).toHaveBeenCalledTimes(1);
|
||||
expect(client.mget).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
docs: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
_id: `${
|
||||
currentSpace.expectedNamespace ? `${currentSpace.expectedNamespace}:` : ''
|
||||
}${obj1.type}:${obj1.id}`,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
_id: `${obj2.type}:${obj2.id}`,
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
{ ignore: [404], maxRetries: 0, meta: true }
|
||||
);
|
||||
});
|
||||
|
||||
test(`calls getSearchableNamespaces with '*' when object namespaces includes '*'`, async () => {
|
||||
await bulkGetSuccess(client, repository, registry, [
|
||||
obj1,
|
||||
{ ...obj2, namespaces: ['*'] },
|
||||
]);
|
||||
expect(mockSpacesExt.getCurrentNamespace).toBeCalledTimes(1);
|
||||
expect(mockSpacesExt.getCurrentNamespace).toHaveBeenCalledWith(undefined);
|
||||
expect(mockSpacesExt.getSearchableNamespaces).toHaveBeenCalledTimes(1);
|
||||
expect(mockSpacesExt.getSearchableNamespaces).toHaveBeenCalledWith(['*']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#bulkCreate', () => {
|
||||
beforeEach(() => {
|
||||
mockPreflightCheckForCreate.mockReset();
|
||||
mockPreflightCheckForCreate.mockImplementation(({ objects }) => {
|
||||
return Promise.resolve(objects.map(({ type, id }) => ({ type, id }))); // respond with no errors by default
|
||||
});
|
||||
});
|
||||
|
||||
const obj1 = {
|
||||
type: 'config',
|
||||
id: '6.0.0-alpha1',
|
||||
attributes: { title: 'Test One' },
|
||||
references: [{ name: 'ref_0', type: 'test', id: '1' }],
|
||||
};
|
||||
const obj2 = {
|
||||
type: MULTI_NAMESPACE_TYPE,
|
||||
id: 'logstash-*',
|
||||
attributes: { title: 'Test Two' },
|
||||
references: [{ name: 'ref_0', type: 'test', id: '2' }],
|
||||
};
|
||||
|
||||
test(`throws error if options.namespace is specified`, async () => {
|
||||
await expect(
|
||||
bulkCreateSuccess(client, repository, [obj1, obj2], { namespace: 'foo-bar' })
|
||||
).rejects.toThrowError(
|
||||
SavedObjectsErrorHelpers.createBadRequestError(ERROR_NAMESPACE_SPECIFIED)
|
||||
);
|
||||
expect(mockSpacesExt.getCurrentNamespace).toBeCalledTimes(1);
|
||||
expect(mockSpacesExt.getCurrentNamespace).toBeCalledWith('foo-bar');
|
||||
});
|
||||
|
||||
test(`supplements internal parameters with the current namespace`, async () => {
|
||||
await bulkCreateSuccess(client, repository, [obj1, obj2]);
|
||||
expect(mockSpacesExt.getCurrentNamespace).toBeCalledTimes(1);
|
||||
expect(mockSpacesExt.getCurrentNamespace).toHaveBeenCalledWith(undefined);
|
||||
expect(mockSpacesExt.getSearchableNamespaces).not.toHaveBeenCalled();
|
||||
expect(client.bulk).toHaveBeenCalledTimes(1);
|
||||
expect(client.bulk).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
create: expect.objectContaining({
|
||||
_id: `${
|
||||
currentSpace.expectedNamespace ? `${currentSpace.expectedNamespace}:` : ''
|
||||
}${obj1.type}:${obj1.id}`,
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
create: expect.objectContaining({
|
||||
_id: `${obj2.type}:${obj2.id}`,
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
{ maxRetries: 0 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#bulkUpdate', () => {
|
||||
const obj1: SavedObjectsBulkUpdateObject = {
|
||||
type: 'config',
|
||||
id: '6.0.0-alpha1',
|
||||
attributes: { title: 'Test One' },
|
||||
};
|
||||
const obj2: SavedObjectsBulkUpdateObject = {
|
||||
type: MULTI_NAMESPACE_TYPE,
|
||||
id: 'logstash-*',
|
||||
attributes: { title: 'Test Two' },
|
||||
};
|
||||
|
||||
test(`throws error if options.namespace is specified`, async () => {
|
||||
await expect(
|
||||
bulkUpdateSuccess(client, repository, registry, [obj1, obj2], { namespace: 'foo-bar' })
|
||||
).rejects.toThrowError(
|
||||
SavedObjectsErrorHelpers.createBadRequestError(ERROR_NAMESPACE_SPECIFIED)
|
||||
);
|
||||
expect(mockSpacesExt.getCurrentNamespace).toBeCalledTimes(1);
|
||||
expect(mockSpacesExt.getCurrentNamespace).toBeCalledWith('foo-bar');
|
||||
});
|
||||
|
||||
test(`supplements internal parameters with the current namespace`, async () => {
|
||||
await bulkUpdateSuccess(
|
||||
client,
|
||||
repository,
|
||||
registry,
|
||||
[obj1, obj2],
|
||||
undefined,
|
||||
undefined,
|
||||
currentSpace.expectedNamespace
|
||||
);
|
||||
expect(mockSpacesExt.getCurrentNamespace).toBeCalledTimes(1);
|
||||
expect(mockSpacesExt.getCurrentNamespace).toHaveBeenCalledWith(undefined);
|
||||
expect(mockSpacesExt.getSearchableNamespaces).not.toHaveBeenCalled();
|
||||
expect(client.bulk).toHaveBeenCalledTimes(1);
|
||||
expect(client.bulk).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
update: expect.objectContaining({
|
||||
_id: `${
|
||||
currentSpace.expectedNamespace ? `${currentSpace.expectedNamespace}:` : ''
|
||||
}${obj1.type}:${obj1.id}`,
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
doc: expect.objectContaining({
|
||||
config: obj1.attributes,
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
update: expect.objectContaining({
|
||||
_id: `${obj2.type}:${obj2.id}`,
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
doc: expect.objectContaining({
|
||||
multiNamespaceType: obj2.attributes,
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
{ maxRetries: 0 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#bulkResolve', () => {
|
||||
afterEach(() => {
|
||||
mockInternalBulkResolve.mockReset();
|
||||
});
|
||||
|
||||
test(`throws error if options.namespace is specified`, async () => {
|
||||
await expect(repository.bulkResolve([], { namespace: 'foo-bar' })).rejects.toThrowError(
|
||||
SavedObjectsErrorHelpers.createBadRequestError(ERROR_NAMESPACE_SPECIFIED)
|
||||
);
|
||||
expect(mockSpacesExt.getCurrentNamespace).toBeCalledTimes(1);
|
||||
expect(mockSpacesExt.getCurrentNamespace).toBeCalledWith('foo-bar');
|
||||
});
|
||||
|
||||
test(`supplements internal parameters with the current namespace`, async () => {
|
||||
mockInternalBulkResolve.mockResolvedValue({
|
||||
resolved_objects: [
|
||||
{
|
||||
saved_object: { type: 'mock', id: 'mock-object', attributes: {}, references: [] },
|
||||
outcome: 'exactMatch',
|
||||
},
|
||||
{
|
||||
type: 'obj-type',
|
||||
id: 'obj-id-2',
|
||||
error: SavedObjectsErrorHelpers.createGenericNotFoundError('obj-type', 'obj-id-2'),
|
||||
},
|
||||
],
|
||||
});
|
||||
const objects = [
|
||||
{ type: 'obj-type', id: 'obj-id-1' },
|
||||
{ type: 'obj-type', id: 'obj-id-2' },
|
||||
];
|
||||
await repository.bulkResolve(objects);
|
||||
expect(mockSpacesExt.getCurrentNamespace).toBeCalledTimes(1);
|
||||
expect(mockSpacesExt.getCurrentNamespace).toHaveBeenCalledWith(undefined);
|
||||
expect(mockSpacesExt.getSearchableNamespaces).not.toHaveBeenCalled();
|
||||
expect(mockInternalBulkResolve).toHaveBeenCalledTimes(1);
|
||||
expect(mockInternalBulkResolve).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
options: {
|
||||
namespace: currentSpace.expectedNamespace
|
||||
? `${currentSpace.expectedNamespace}`
|
||||
: undefined,
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#find', () => {
|
||||
test(`supplements internal parameters with options.type and options.namespaces`, async () => {
|
||||
const type = 'index-pattern';
|
||||
const spaceOverride = 'ns-4';
|
||||
await findSuccess(client, repository, { type, namespaces: [spaceOverride] });
|
||||
expect(mockSpacesExt.getSearchableNamespaces).toBeCalledTimes(1);
|
||||
expect(mockSpacesExt.getSearchableNamespaces).toBeCalledWith([spaceOverride]);
|
||||
expect(mockGetSearchDsl).toHaveBeenCalledWith(
|
||||
mappings,
|
||||
registry,
|
||||
expect.objectContaining({
|
||||
namespaces: [spaceOverride],
|
||||
type: [type],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test(`propagates options.namespaces: ['*']`, async () => {
|
||||
const type = 'index-pattern';
|
||||
await findSuccess(client, repository, { type, namespaces: ['*'] });
|
||||
expect(mockSpacesExt.getSearchableNamespaces).toBeCalledTimes(1);
|
||||
expect(mockSpacesExt.getSearchableNamespaces).toBeCalledWith(['*']);
|
||||
});
|
||||
|
||||
test(`supplements options with the current namespace`, async () => {
|
||||
const type = 'index-pattern';
|
||||
await findSuccess(client, repository, { type });
|
||||
expect(mockSpacesExt.getSearchableNamespaces).toBeCalledTimes(1);
|
||||
expect(mockSpacesExt.getSearchableNamespaces).toBeCalledWith(undefined); // will resolve current space
|
||||
});
|
||||
});
|
||||
|
||||
describe('#bulkDelete', () => {
|
||||
beforeEach(() => {
|
||||
mockDeleteLegacyUrlAliases.mockClear();
|
||||
mockDeleteLegacyUrlAliases.mockResolvedValue();
|
||||
});
|
||||
|
||||
const obj1: SavedObjectsBulkUpdateObject = {
|
||||
type: 'config',
|
||||
id: '6.0.0-alpha1',
|
||||
attributes: { title: 'Test One' },
|
||||
};
|
||||
const obj2: SavedObjectsBulkUpdateObject = {
|
||||
type: MULTI_NAMESPACE_TYPE,
|
||||
id: 'logstash-*',
|
||||
attributes: { title: 'Test Two' },
|
||||
};
|
||||
const testObjs = [obj1, obj2];
|
||||
const options = {
|
||||
force: true,
|
||||
};
|
||||
const internalOptions = {
|
||||
mockMGetResponseObjects: [
|
||||
{
|
||||
...obj1,
|
||||
initialNamespaces: undefined,
|
||||
},
|
||||
{
|
||||
...obj2,
|
||||
initialNamespaces: [currentSpace.id, 'NS-1', 'NS-2'],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
test(`throws error if options.namespace is specified`, async () => {
|
||||
await expect(
|
||||
bulkDeleteSuccess(client, repository, registry, testObjs, { namespace: 'foo-bar' })
|
||||
).rejects.toThrowError(
|
||||
SavedObjectsErrorHelpers.createBadRequestError(ERROR_NAMESPACE_SPECIFIED)
|
||||
);
|
||||
expect(mockSpacesExt.getCurrentNamespace).toBeCalledTimes(1);
|
||||
expect(mockSpacesExt.getCurrentNamespace).toBeCalledWith('foo-bar');
|
||||
});
|
||||
|
||||
test(`supplements internal parameters with the current namespace`, async () => {
|
||||
await bulkDeleteSuccess(client, repository, registry, testObjs, options, internalOptions);
|
||||
expect(mockSpacesExt.getCurrentNamespace).toBeCalledTimes(1);
|
||||
expect(mockSpacesExt.getCurrentNamespace).toHaveBeenCalledWith(undefined);
|
||||
expect(mockSpacesExt.getSearchableNamespaces).not.toHaveBeenCalled();
|
||||
expect(client.bulk).toHaveBeenCalledTimes(1);
|
||||
expect(client.bulk).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
delete: expect.objectContaining({
|
||||
_id: `${
|
||||
currentSpace.expectedNamespace ? `${currentSpace.expectedNamespace}:` : ''
|
||||
}${obj1.type}:${obj1.id}`,
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
delete: expect.objectContaining({
|
||||
_id: `${obj2.type}:${obj2.id}`,
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
{ maxRetries: 0 }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(`with security extension`, () => {
|
||||
beforeEach(() => {
|
||||
pointInTimeFinderMock.mockClear();
|
||||
client = elasticsearchClientMock.createElasticsearchClient();
|
||||
migrator = kibanaMigratorMock.create();
|
||||
documentMigrator.prepareMigrations();
|
||||
migrator.migrateDocument = jest.fn().mockImplementation(documentMigrator.migrate);
|
||||
migrator.runMigrations = jest.fn().mockResolvedValue([{ status: 'skipped' }]);
|
||||
logger = loggerMock.create();
|
||||
// create a mock serializer "shim" so we can track function calls, but use the real serializer's implementation
|
||||
serializer = createSpySerializer(registry);
|
||||
// create a mock extensions
|
||||
mockSpacesExt = savedObjectsExtensionsMock.createSpacesExtension();
|
||||
mockSecurityExt = savedObjectsExtensionsMock.createSecurityExtension();
|
||||
mockGetCurrentTime.mockReturnValue(mockTimestamp);
|
||||
mockGetSearchDsl.mockClear();
|
||||
repository = instantiateRepository();
|
||||
mockSpacesExt.getSearchableNamespaces.mockImplementation(
|
||||
(namespaces: string[] | undefined): Promise<string[]> => {
|
||||
if (!namespaces) {
|
||||
return Promise.resolve([] as string[]);
|
||||
} else if (!namespaces.length) {
|
||||
return Promise.resolve(namespaces);
|
||||
}
|
||||
if (namespaces?.includes('*')) {
|
||||
return Promise.resolve(availableSpaces.map((space) => space.id));
|
||||
} else {
|
||||
return Promise.resolve(
|
||||
namespaces?.filter((namespace) =>
|
||||
availableSpaces.some((space) => space.id === namespace)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe(`#find`, () => {
|
||||
test(`returns empty result if user is unauthorized`, async () => {
|
||||
setupCheckUnauthorized(mockSecurityExt);
|
||||
const type = 'index-pattern';
|
||||
const spaceOverride = 'ns-4';
|
||||
const generatedResults = generateIndexPatternSearchResults(spaceOverride);
|
||||
client.search.mockResponseOnce(generatedResults);
|
||||
const result = await repository.find({ type, namespaces: [spaceOverride] });
|
||||
expect(result).toEqual(expect.objectContaining({ total: 0 }));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -24,6 +24,7 @@ jest.mock('./collect_multi_namespace_references', () => ({
|
|||
export const mockInternalBulkResolve = jest.fn() as jest.MockedFunction<typeof internalBulkResolve>;
|
||||
|
||||
jest.mock('./internal_bulk_resolve', () => ({
|
||||
...jest.requireActual('./internal_bulk_resolve'),
|
||||
internalBulkResolve: mockInternalBulkResolve,
|
||||
}));
|
||||
|
||||
|
|
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
@ -6,14 +6,14 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
import type { Payload } from '@hapi/boom';
|
||||
import {
|
||||
import type {
|
||||
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';
|
||||
import type { Either } from './internal_utils';
|
||||
import type { DeleteLegacyUrlAliasesParams } from './legacy_url_aliases';
|
||||
|
||||
/**
|
||||
* @internal
|
||||
|
|
|
@ -90,6 +90,7 @@ describe('SavedObjectsRepository#createRepository', () => {
|
|||
callAdminCluster,
|
||||
logger,
|
||||
[],
|
||||
undefined,
|
||||
SavedObjectsRepository
|
||||
);
|
||||
expect(repository).toBeDefined();
|
||||
|
@ -109,6 +110,7 @@ describe('SavedObjectsRepository#createRepository', () => {
|
|||
callAdminCluster,
|
||||
logger,
|
||||
['hiddenType', 'hiddenType', 'hiddenType'],
|
||||
undefined,
|
||||
SavedObjectsRepository
|
||||
);
|
||||
expect(repository).toBeDefined();
|
||||
|
|
|
@ -5,17 +5,54 @@
|
|||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import type { Optional } from 'utility-types';
|
||||
import { httpServerMock } from '@kbn/core-http-server-mocks';
|
||||
import { typeRegistryMock } from '@kbn/core-saved-objects-base-server-mocks';
|
||||
import { SavedObjectsClientProvider } from './scoped_client_provider';
|
||||
import {
|
||||
type ISavedObjectTypeRegistry,
|
||||
type SavedObjectsClientFactory,
|
||||
type SavedObjectsEncryptionExtensionFactory,
|
||||
type SavedObjectsSecurityExtensionFactory,
|
||||
type SavedObjectsSpacesExtensionFactory,
|
||||
ENCRYPTION_EXTENSION_ID,
|
||||
SECURITY_EXTENSION_ID,
|
||||
SPACES_EXTENSION_ID,
|
||||
} from '@kbn/core-saved-objects-server';
|
||||
import type { KibanaRequest } from '@kbn/core-http-server';
|
||||
import { savedObjectsExtensionsMock } from '../mocks/saved_objects_extensions.mock';
|
||||
|
||||
/**
|
||||
* @internal only used for unit tests
|
||||
*/
|
||||
interface Params {
|
||||
defaultClientFactory: SavedObjectsClientFactory;
|
||||
typeRegistry: ISavedObjectTypeRegistry;
|
||||
encryptionExtensionFactory: SavedObjectsEncryptionExtensionFactory;
|
||||
securityExtensionFactory: SavedObjectsSecurityExtensionFactory;
|
||||
spacesExtensionFactory: SavedObjectsSpacesExtensionFactory;
|
||||
}
|
||||
|
||||
function createClientProvider(
|
||||
params: Optional<
|
||||
Params,
|
||||
'encryptionExtensionFactory' | 'securityExtensionFactory' | 'spacesExtensionFactory'
|
||||
>
|
||||
) {
|
||||
return new SavedObjectsClientProvider({
|
||||
encryptionExtensionFactory: undefined,
|
||||
securityExtensionFactory: undefined,
|
||||
spacesExtensionFactory: undefined,
|
||||
...params,
|
||||
});
|
||||
}
|
||||
|
||||
test(`uses default client factory when one isn't set`, () => {
|
||||
const returnValue = Symbol();
|
||||
const defaultClientFactoryMock = jest.fn().mockReturnValue(returnValue);
|
||||
const request = httpServerMock.createKibanaRequest();
|
||||
|
||||
const clientProvider = new SavedObjectsClientProvider({
|
||||
const clientProvider = createClientProvider({
|
||||
defaultClientFactory: defaultClientFactoryMock,
|
||||
typeRegistry: typeRegistryMock.create(),
|
||||
});
|
||||
|
@ -24,6 +61,7 @@ test(`uses default client factory when one isn't set`, () => {
|
|||
expect(result).toBe(returnValue);
|
||||
expect(defaultClientFactoryMock).toHaveBeenCalledTimes(1);
|
||||
expect(defaultClientFactoryMock).toHaveBeenCalledWith({
|
||||
extensions: expect.any(Object),
|
||||
request,
|
||||
});
|
||||
});
|
||||
|
@ -34,7 +72,7 @@ test(`uses custom client factory when one is set`, () => {
|
|||
const returnValue = Symbol();
|
||||
const customClientFactoryMock = jest.fn().mockReturnValue(returnValue);
|
||||
|
||||
const clientProvider = new SavedObjectsClientProvider({
|
||||
const clientProvider = createClientProvider({
|
||||
defaultClientFactory: defaultClientFactoryMock,
|
||||
typeRegistry: typeRegistryMock.create(),
|
||||
});
|
||||
|
@ -45,6 +83,7 @@ test(`uses custom client factory when one is set`, () => {
|
|||
expect(defaultClientFactoryMock).not.toHaveBeenCalled();
|
||||
expect(customClientFactoryMock).toHaveBeenCalledTimes(1);
|
||||
expect(customClientFactoryMock).toHaveBeenCalledWith({
|
||||
extensions: expect.any(Object),
|
||||
request,
|
||||
});
|
||||
});
|
||||
|
@ -53,7 +92,7 @@ test(`throws error when more than one scoped saved objects client factory is set
|
|||
const defaultClientFactory = jest.fn();
|
||||
const clientFactory = jest.fn();
|
||||
|
||||
const clientProvider = new SavedObjectsClientProvider({
|
||||
const clientProvider = createClientProvider({
|
||||
defaultClientFactory,
|
||||
typeRegistry: typeRegistryMock.create(),
|
||||
});
|
||||
|
@ -66,111 +105,114 @@ test(`throws error when more than one scoped saved objects client factory is set
|
|||
);
|
||||
});
|
||||
|
||||
test(`throws error when registering a wrapper with a duplicate id`, () => {
|
||||
const defaultClientFactoryMock = jest.fn();
|
||||
const clientProvider = new SavedObjectsClientProvider({
|
||||
defaultClientFactory: defaultClientFactoryMock,
|
||||
typeRegistry: typeRegistryMock.create(),
|
||||
});
|
||||
const firstClientWrapperFactoryMock = jest.fn();
|
||||
const secondClientWrapperFactoryMock = jest.fn();
|
||||
|
||||
clientProvider.addClientWrapperFactory(1, 'foo', secondClientWrapperFactoryMock);
|
||||
expect(() =>
|
||||
clientProvider.addClientWrapperFactory(0, 'foo', firstClientWrapperFactoryMock)
|
||||
).toThrowErrorMatchingInlineSnapshot(`"wrapper factory with id foo is already defined"`);
|
||||
});
|
||||
|
||||
test(`invokes and uses wrappers in specified order`, () => {
|
||||
describe(`allows extensions to be excluded`, () => {
|
||||
const defaultClient = Symbol();
|
||||
const typeRegistry = typeRegistryMock.create();
|
||||
const defaultClientFactoryMock = jest.fn().mockReturnValue(defaultClient);
|
||||
const clientProvider = new SavedObjectsClientProvider({
|
||||
|
||||
const mockEncryptionExt = savedObjectsExtensionsMock.createEncryptionExtension();
|
||||
const encryptionExtFactory: SavedObjectsEncryptionExtensionFactory = (params: {
|
||||
typeRegistry: ISavedObjectTypeRegistry;
|
||||
request: KibanaRequest;
|
||||
}) => mockEncryptionExt;
|
||||
|
||||
const mockSpacesExt = savedObjectsExtensionsMock.createSpacesExtension();
|
||||
const spacesExtFactory: SavedObjectsSpacesExtensionFactory = (params: {
|
||||
typeRegistry: ISavedObjectTypeRegistry;
|
||||
request: KibanaRequest;
|
||||
}) => mockSpacesExt;
|
||||
|
||||
const mockSecurityExt = savedObjectsExtensionsMock.createSecurityExtension();
|
||||
const securityExtFactory: SavedObjectsSecurityExtensionFactory = (params: {
|
||||
typeRegistry: ISavedObjectTypeRegistry;
|
||||
request: KibanaRequest;
|
||||
}) => mockSecurityExt;
|
||||
|
||||
const clientProvider = createClientProvider({
|
||||
defaultClientFactory: defaultClientFactoryMock,
|
||||
encryptionExtensionFactory: encryptionExtFactory,
|
||||
spacesExtensionFactory: spacesExtFactory,
|
||||
securityExtensionFactory: securityExtFactory,
|
||||
typeRegistry,
|
||||
});
|
||||
const firstWrappedClient = Symbol('first client');
|
||||
const firstClientWrapperFactoryMock = jest.fn().mockReturnValue(firstWrappedClient);
|
||||
const secondWrapperClient = Symbol('second client');
|
||||
const secondClientWrapperFactoryMock = jest.fn().mockReturnValue(secondWrapperClient);
|
||||
const request = httpServerMock.createKibanaRequest();
|
||||
|
||||
clientProvider.addClientWrapperFactory(1, 'foo', secondClientWrapperFactoryMock);
|
||||
clientProvider.addClientWrapperFactory(0, 'bar', firstClientWrapperFactoryMock);
|
||||
const actualClient = clientProvider.getClient(request);
|
||||
test(`calls client factory with all extensions excluded`, async () => {
|
||||
const request = httpServerMock.createKibanaRequest();
|
||||
|
||||
expect(actualClient).toBe(firstWrappedClient);
|
||||
expect(firstClientWrapperFactoryMock).toHaveBeenCalledWith({
|
||||
request,
|
||||
client: secondWrapperClient,
|
||||
typeRegistry,
|
||||
});
|
||||
expect(secondClientWrapperFactoryMock).toHaveBeenCalledWith({
|
||||
request,
|
||||
client: defaultClient,
|
||||
typeRegistry,
|
||||
});
|
||||
});
|
||||
clientProvider.getClient(request, {
|
||||
excludedExtensions: [ENCRYPTION_EXTENSION_ID, SECURITY_EXTENSION_ID, SPACES_EXTENSION_ID],
|
||||
});
|
||||
|
||||
test(`does not invoke or use excluded wrappers`, () => {
|
||||
const defaultClient = Symbol();
|
||||
const typeRegistry = typeRegistryMock.create();
|
||||
const defaultClientFactoryMock = jest.fn().mockReturnValue(defaultClient);
|
||||
const clientProvider = new SavedObjectsClientProvider({
|
||||
defaultClientFactory: defaultClientFactoryMock,
|
||||
typeRegistry,
|
||||
});
|
||||
const firstWrappedClient = Symbol('first client');
|
||||
const firstClientWrapperFactoryMock = jest.fn().mockReturnValue(firstWrappedClient);
|
||||
const secondWrapperClient = Symbol('second client');
|
||||
const secondClientWrapperFactoryMock = jest.fn().mockReturnValue(secondWrapperClient);
|
||||
const request = httpServerMock.createKibanaRequest();
|
||||
|
||||
clientProvider.addClientWrapperFactory(1, 'foo', secondClientWrapperFactoryMock);
|
||||
clientProvider.addClientWrapperFactory(0, 'bar', firstClientWrapperFactoryMock);
|
||||
|
||||
const actualClient = clientProvider.getClient(request, {
|
||||
excludedWrappers: ['foo'],
|
||||
expect(defaultClientFactoryMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
extensions: {
|
||||
encryptionExtension: undefined,
|
||||
securityExtension: undefined,
|
||||
spacesExtension: undefined,
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
expect(actualClient).toBe(firstWrappedClient);
|
||||
expect(firstClientWrapperFactoryMock).toHaveBeenCalledWith({
|
||||
request,
|
||||
client: defaultClient,
|
||||
typeRegistry,
|
||||
});
|
||||
expect(secondClientWrapperFactoryMock).not.toHaveBeenCalled();
|
||||
});
|
||||
test(`calls client factory with some extensions excluded`, async () => {
|
||||
const request = httpServerMock.createKibanaRequest();
|
||||
|
||||
test(`allows all wrappers to be excluded`, () => {
|
||||
const defaultClient = Symbol();
|
||||
const defaultClientFactoryMock = jest.fn().mockReturnValue(defaultClient);
|
||||
const clientProvider = new SavedObjectsClientProvider({
|
||||
defaultClientFactory: defaultClientFactoryMock,
|
||||
typeRegistry: typeRegistryMock.create(),
|
||||
});
|
||||
const firstWrappedClient = Symbol('first client');
|
||||
const firstClientWrapperFactoryMock = jest.fn().mockReturnValue(firstWrappedClient);
|
||||
const secondWrapperClient = Symbol('second client');
|
||||
const secondClientWrapperFactoryMock = jest.fn().mockReturnValue(secondWrapperClient);
|
||||
const request = httpServerMock.createKibanaRequest();
|
||||
clientProvider.getClient(request, {
|
||||
excludedExtensions: [ENCRYPTION_EXTENSION_ID, SPACES_EXTENSION_ID],
|
||||
});
|
||||
|
||||
clientProvider.addClientWrapperFactory(1, 'foo', secondClientWrapperFactoryMock);
|
||||
clientProvider.addClientWrapperFactory(0, 'bar', firstClientWrapperFactoryMock);
|
||||
|
||||
const actualClient = clientProvider.getClient(request, {
|
||||
excludedWrappers: ['foo', 'bar'],
|
||||
expect(defaultClientFactoryMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
extensions: {
|
||||
encryptionExtension: undefined,
|
||||
securityExtension: mockSecurityExt,
|
||||
spacesExtension: undefined,
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
expect(actualClient).toBe(defaultClient);
|
||||
expect(firstClientWrapperFactoryMock).not.toHaveBeenCalled();
|
||||
expect(secondClientWrapperFactoryMock).not.toHaveBeenCalled();
|
||||
test(`calls client factory with one extension excluded`, async () => {
|
||||
const request = httpServerMock.createKibanaRequest();
|
||||
|
||||
clientProvider.getClient(request, {
|
||||
excludedExtensions: [SECURITY_EXTENSION_ID],
|
||||
});
|
||||
|
||||
expect(defaultClientFactoryMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
extensions: {
|
||||
encryptionExtension: mockEncryptionExt,
|
||||
securityExtension: undefined,
|
||||
spacesExtension: mockSpacesExt,
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test(`calls client factory with no extensions excluded`, async () => {
|
||||
const request = httpServerMock.createKibanaRequest();
|
||||
|
||||
clientProvider.getClient(request, {
|
||||
excludedExtensions: [],
|
||||
});
|
||||
|
||||
expect(defaultClientFactoryMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
extensions: {
|
||||
encryptionExtension: mockEncryptionExt,
|
||||
securityExtension: mockSecurityExt,
|
||||
spacesExtension: mockSpacesExt,
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test(`allows hidden typed to be included`, () => {
|
||||
const defaultClient = Symbol();
|
||||
const defaultClientFactoryMock = jest.fn().mockReturnValue(defaultClient);
|
||||
const clientProvider = new SavedObjectsClientProvider({
|
||||
const clientProvider = createClientProvider({
|
||||
defaultClientFactory: defaultClientFactoryMock,
|
||||
typeRegistry: typeRegistryMock.create(),
|
||||
});
|
||||
|
@ -182,6 +224,7 @@ test(`allows hidden typed to be included`, () => {
|
|||
|
||||
expect(actualClient).toBe(defaultClient);
|
||||
expect(defaultClientFactoryMock).toHaveBeenCalledWith({
|
||||
extensions: expect.any(Object),
|
||||
request,
|
||||
includedHiddenTypes: ['task'],
|
||||
});
|
||||
|
|
|
@ -10,11 +10,19 @@ import type { KibanaRequest } from '@kbn/core-http-server';
|
|||
import type { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server';
|
||||
import type {
|
||||
ISavedObjectTypeRegistry,
|
||||
SavedObjectsClientWrapperFactory,
|
||||
SavedObjectsClientFactory,
|
||||
SavedObjectsClientProviderOptions,
|
||||
SavedObjectsEncryptionExtensionFactory,
|
||||
SavedObjectsSecurityExtensionFactory,
|
||||
SavedObjectsSpacesExtensionFactory,
|
||||
SavedObjectsExtensions,
|
||||
SavedObjectsExtensionFactory,
|
||||
} from '@kbn/core-saved-objects-server';
|
||||
import {
|
||||
ENCRYPTION_EXTENSION_ID,
|
||||
SECURITY_EXTENSION_ID,
|
||||
SPACES_EXTENSION_ID,
|
||||
} from '@kbn/core-saved-objects-server';
|
||||
import { PriorityCollection } from './priority_collection';
|
||||
|
||||
/**
|
||||
* @internal
|
||||
|
@ -30,35 +38,31 @@ export type ISavedObjectsClientProvider = Pick<
|
|||
* @internal
|
||||
*/
|
||||
export class SavedObjectsClientProvider {
|
||||
private readonly _wrapperFactories = new PriorityCollection<{
|
||||
id: string;
|
||||
factory: SavedObjectsClientWrapperFactory;
|
||||
}>();
|
||||
private _clientFactory: SavedObjectsClientFactory;
|
||||
private readonly _originalClientFactory: SavedObjectsClientFactory;
|
||||
private readonly encryptionExtensionFactory?: SavedObjectsEncryptionExtensionFactory;
|
||||
private readonly securityExtensionFactory?: SavedObjectsSecurityExtensionFactory;
|
||||
private readonly spacesExtensionFactory?: SavedObjectsSpacesExtensionFactory;
|
||||
private readonly _typeRegistry: ISavedObjectTypeRegistry;
|
||||
|
||||
constructor({
|
||||
defaultClientFactory,
|
||||
typeRegistry,
|
||||
encryptionExtensionFactory,
|
||||
securityExtensionFactory,
|
||||
spacesExtensionFactory,
|
||||
}: {
|
||||
defaultClientFactory: SavedObjectsClientFactory;
|
||||
typeRegistry: ISavedObjectTypeRegistry;
|
||||
encryptionExtensionFactory?: SavedObjectsEncryptionExtensionFactory;
|
||||
securityExtensionFactory?: SavedObjectsSecurityExtensionFactory;
|
||||
spacesExtensionFactory?: SavedObjectsSpacesExtensionFactory;
|
||||
}) {
|
||||
this._originalClientFactory = this._clientFactory = defaultClientFactory;
|
||||
this._typeRegistry = typeRegistry;
|
||||
}
|
||||
|
||||
addClientWrapperFactory(
|
||||
priority: number,
|
||||
id: string,
|
||||
factory: SavedObjectsClientWrapperFactory
|
||||
): void {
|
||||
if (this._wrapperFactories.has((entry) => entry.id === id)) {
|
||||
throw new Error(`wrapper factory with id ${id} is already defined`);
|
||||
}
|
||||
|
||||
this._wrapperFactories.add(priority, { id, factory });
|
||||
this.encryptionExtensionFactory = encryptionExtensionFactory;
|
||||
this.securityExtensionFactory = securityExtensionFactory;
|
||||
this.spacesExtensionFactory = spacesExtensionFactory;
|
||||
}
|
||||
|
||||
setClientFactory(customClientFactory: SavedObjectsClientFactory) {
|
||||
|
@ -71,25 +75,28 @@ export class SavedObjectsClientProvider {
|
|||
|
||||
getClient(
|
||||
request: KibanaRequest,
|
||||
{ includedHiddenTypes, excludedWrappers = [] }: SavedObjectsClientProviderOptions = {}
|
||||
{ includedHiddenTypes, excludedExtensions = [] }: SavedObjectsClientProviderOptions = {}
|
||||
): SavedObjectsClientContract {
|
||||
const client = this._clientFactory({
|
||||
return this._clientFactory({
|
||||
request,
|
||||
includedHiddenTypes,
|
||||
extensions: this.getExtensions(request, excludedExtensions),
|
||||
});
|
||||
}
|
||||
|
||||
return this._wrapperFactories
|
||||
.toPrioritizedArray()
|
||||
.reduceRight((clientToWrap, { id, factory }) => {
|
||||
if (excludedWrappers.includes(id)) {
|
||||
return clientToWrap;
|
||||
}
|
||||
getExtensions(request: KibanaRequest, excludedExtensions: string[]): SavedObjectsExtensions {
|
||||
const createExt = <T>(
|
||||
extensionId: string,
|
||||
extensionFactory?: SavedObjectsExtensionFactory<T | undefined>
|
||||
): T | undefined =>
|
||||
!excludedExtensions.includes(extensionId) && !!extensionFactory
|
||||
? extensionFactory?.({ typeRegistry: this._typeRegistry, request })
|
||||
: undefined;
|
||||
|
||||
return factory({
|
||||
request,
|
||||
client: clientToWrap,
|
||||
typeRegistry: this._typeRegistry,
|
||||
});
|
||||
}, client);
|
||||
return {
|
||||
encryptionExtension: createExt(ENCRYPTION_EXTENSION_ID, this.encryptionExtensionFactory),
|
||||
securityExtension: createExt(SECURITY_EXTENSION_ID, this.securityExtensionFactory),
|
||||
spacesExtension: createExt(SPACES_EXTENSION_ID, this.spacesExtensionFactory),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,7 +13,7 @@ import type { SavedObjectsPitParams } from '@kbn/core-saved-objects-api-server';
|
|||
import type { ISavedObjectTypeRegistry } from '@kbn/core-saved-objects-server';
|
||||
import type { IndexMapping } from '@kbn/core-saved-objects-base-server-internal';
|
||||
import type { SavedObjectTypeIdTuple } from '@kbn/core-saved-objects-common';
|
||||
import { getQueryParams, SearchOperator } from './query_params';
|
||||
import { getQueryParams, type SearchOperator } from './query_params';
|
||||
import { getPitParams } from './pit_params';
|
||||
import { getSortingParams } from './sorting_params';
|
||||
|
||||
|
|
|
@ -25,6 +25,20 @@ import { SavedObjectsSerializer } from '@kbn/core-saved-objects-base-server-inte
|
|||
import { typeRegistryMock } from '@kbn/core-saved-objects-base-server-mocks';
|
||||
import type { UpdateObjectsSpacesParams } from './update_objects_spaces';
|
||||
import { updateObjectsSpaces } from './update_objects_spaces';
|
||||
import { AuditAction, type ISavedObjectsSecurityExtension } from '@kbn/core-saved-objects-server';
|
||||
import {
|
||||
authMap,
|
||||
checkAuthError,
|
||||
enforceError,
|
||||
typeMapsAreEqual,
|
||||
setsAreEqual,
|
||||
setupCheckAuthorized,
|
||||
setupCheckUnauthorized,
|
||||
setupEnforceFailure,
|
||||
setupEnforceSuccess,
|
||||
setupRedactPassthrough,
|
||||
} from '../test_helpers/repository.test.common';
|
||||
import { savedObjectsExtensionsMock } from '../mocks/saved_objects_extensions.mock';
|
||||
|
||||
type SetupParams = Partial<
|
||||
Pick<UpdateObjectsSpacesParams, 'objects' | 'spacesToAdd' | 'spacesToRemove' | 'options'>
|
||||
|
@ -67,7 +81,10 @@ describe('#updateObjectsSpaces', () => {
|
|||
let client: ReturnType<typeof elasticsearchClientMock.createElasticsearchClient>;
|
||||
|
||||
/** Sets up the type registry, saved objects client, etc. and return the full parameters object to be passed to `updateObjectsSpaces` */
|
||||
function setup({ objects = [], spacesToAdd = [], spacesToRemove = [], options }: SetupParams) {
|
||||
function setup(
|
||||
{ objects = [], spacesToAdd = [], spacesToRemove = [], options }: SetupParams,
|
||||
securityExtension?: ISavedObjectsSecurityExtension
|
||||
) {
|
||||
const registry = typeRegistryMock.create();
|
||||
registry.isShareable.mockImplementation(
|
||||
(type) => [SHAREABLE_OBJ_TYPE, SHAREABLE_HIDDEN_OBJ_TYPE].includes(type) // NON_SHAREABLE_OBJ_TYPE is excluded
|
||||
|
@ -82,6 +99,7 @@ describe('#updateObjectsSpaces', () => {
|
|||
serializer,
|
||||
logger: loggerMock.create(),
|
||||
getIndexForType: (type: string) => `index-for-${type}`,
|
||||
securityExtension,
|
||||
objects,
|
||||
spacesToAdd,
|
||||
spacesToRemove,
|
||||
|
@ -90,14 +108,16 @@ describe('#updateObjectsSpaces', () => {
|
|||
}
|
||||
|
||||
/** Mocks the saved objects client so it returns the expected results */
|
||||
function mockMgetResults(...results: Array<{ found: boolean }>) {
|
||||
function mockMgetResults(
|
||||
...results: Array<{ found: false } | { found: true; namespaces: string[] }>
|
||||
) {
|
||||
client.mget.mockResponseOnce({
|
||||
docs: results.map((x) =>
|
||||
x.found
|
||||
? {
|
||||
_id: 'doesnt-matter',
|
||||
_index: 'doesnt-matter',
|
||||
_source: { namespaces: [EXISTING_SPACE] },
|
||||
_source: { namespaces: x.namespaces },
|
||||
...VERSION_PROPS,
|
||||
found: true,
|
||||
}
|
||||
|
@ -111,27 +131,8 @@ describe('#updateObjectsSpaces', () => {
|
|||
}
|
||||
|
||||
/** Mocks the saved objects client so as to test unsupported server responding with 404 */
|
||||
function mockMgetResultsNotFound(...results: Array<{ found: boolean }>) {
|
||||
client.mget.mockResponseOnce(
|
||||
{
|
||||
docs: results.map((x) =>
|
||||
x.found
|
||||
? {
|
||||
_id: 'doesnt-matter',
|
||||
_index: 'doesnt-matter',
|
||||
_source: { namespaces: [EXISTING_SPACE] },
|
||||
...VERSION_PROPS,
|
||||
found: true,
|
||||
}
|
||||
: {
|
||||
_id: 'doesnt-matter',
|
||||
_index: 'doesnt-matter',
|
||||
found: false,
|
||||
}
|
||||
),
|
||||
},
|
||||
{ statusCode: 404, headers: {} }
|
||||
);
|
||||
function mockMgetResultsNotFound() {
|
||||
client.mget.mockResponseOnce({ docs: [] }, { statusCode: 404, headers: {} });
|
||||
}
|
||||
|
||||
/** Asserts that mget is called for the given objects */
|
||||
|
@ -220,7 +221,7 @@ describe('#updateObjectsSpaces', () => {
|
|||
const objects = [{ type: SHAREABLE_OBJ_TYPE, id: 'id-1' }];
|
||||
const spacesToAdd = ['foo-space'];
|
||||
const params = setup({ objects, spacesToAdd });
|
||||
mockMgetResults({ found: true });
|
||||
mockMgetResults({ found: true, namespaces: [EXISTING_SPACE] });
|
||||
client.bulk.mockReturnValueOnce(
|
||||
elasticsearchClientMock.createErrorTransportRequestPromise(new Error('bulk error'))
|
||||
);
|
||||
|
@ -231,39 +232,38 @@ describe('#updateObjectsSpaces', () => {
|
|||
it('returns mix of type errors, mget/bulk cluster errors, and successes', async () => {
|
||||
const obj1 = { type: SHAREABLE_HIDDEN_OBJ_TYPE, id: 'id-1' }; // invalid type (Not Found)
|
||||
const obj2 = { type: NON_SHAREABLE_OBJ_TYPE, id: 'id-2' }; // non-shareable type (Bad Request)
|
||||
// obj3 below is mocking an example where a SOC wrapper attempted to retrieve it in a pre-flight request but it was not found.
|
||||
// Since it has 'spaces: []', that indicates it should be skipped for cluster calls and just returned as a Not Found error.
|
||||
// Realistically this would not be intermingled with other requested objects that do not have 'spaces' arrays, but it's fine for this
|
||||
// specific test case.
|
||||
const obj3 = { type: SHAREABLE_OBJ_TYPE, id: 'id-3', spaces: [] }; // does not exist (Not Found)
|
||||
const obj4 = { type: SHAREABLE_OBJ_TYPE, id: 'id-4' }; // mget error (found but doesn't exist in the current space)
|
||||
const obj5 = { type: SHAREABLE_OBJ_TYPE, id: 'id-5' }; // mget error (Not Found)
|
||||
const obj6 = { type: SHAREABLE_OBJ_TYPE, id: 'id-6' }; // bulk error (mocked as BULK_ERROR)
|
||||
const obj7 = { type: SHAREABLE_OBJ_TYPE, id: 'id-7' }; // success
|
||||
const obj3 = { type: SHAREABLE_OBJ_TYPE, id: 'id-3' }; // mget error (found but doesn't exist in the current space)
|
||||
const obj4 = { type: SHAREABLE_OBJ_TYPE, id: 'id-4' }; // mget error (Not Found)
|
||||
const obj5 = { type: SHAREABLE_OBJ_TYPE, id: 'id-5' }; // bulk error (mocked as BULK_ERROR)
|
||||
const obj6 = { type: SHAREABLE_OBJ_TYPE, id: 'id-6' }; // success
|
||||
|
||||
const objects = [obj1, obj2, obj3, obj4, obj5, obj6, obj7];
|
||||
const objects = [obj1, obj2, obj3, obj4, obj5, obj6];
|
||||
const spacesToAdd = ['foo-space'];
|
||||
const params = setup({ objects, spacesToAdd });
|
||||
mockMgetResults({ found: true }, { found: false }, { found: true }, { found: true }); // results for obj4, obj5, obj6, and obj7
|
||||
mockRawDocExistsInNamespace.mockReturnValueOnce(false); // for obj4
|
||||
mockMgetResults(
|
||||
{ found: true, namespaces: ['another-space'] }, // result for obj3
|
||||
{ found: false }, // result for obj4
|
||||
{ found: true, namespaces: [EXISTING_SPACE] }, // result for obj5
|
||||
{ found: true, namespaces: [EXISTING_SPACE] } // result for obj6
|
||||
);
|
||||
mockRawDocExistsInNamespace.mockReturnValueOnce(false); // for obj3
|
||||
mockRawDocExistsInNamespace.mockReturnValueOnce(true); // for obj5
|
||||
mockRawDocExistsInNamespace.mockReturnValueOnce(true); // for obj6
|
||||
mockRawDocExistsInNamespace.mockReturnValueOnce(true); // for obj7
|
||||
mockBulkResults({ error: true }, { error: false }); // results for obj6 and obj7
|
||||
mockBulkResults({ error: true }, { error: false }); // results for obj5 and obj6
|
||||
|
||||
const result = await updateObjectsSpaces(params);
|
||||
expect(client.mget).toHaveBeenCalledTimes(1);
|
||||
expectMgetArgs(obj4, obj5, obj6, obj7);
|
||||
expectMgetArgs(obj3, obj4, obj5, obj6);
|
||||
expect(mockRawDocExistsInNamespace).toHaveBeenCalledTimes(3);
|
||||
expect(client.bulk).toHaveBeenCalledTimes(1);
|
||||
expectBulkArgs({ action: 'update', object: obj6 }, { action: 'update', object: obj7 });
|
||||
expectBulkArgs({ action: 'update', object: obj5 }, { action: 'update', object: obj6 });
|
||||
expect(result.objects).toEqual([
|
||||
{ ...obj1, spaces: [], error: expect.objectContaining({ error: 'Not Found' }) },
|
||||
{ ...obj2, spaces: [], error: expect.objectContaining({ error: 'Bad Request' }) },
|
||||
{ ...obj3, spaces: [], error: expect.objectContaining({ error: 'Not Found' }) },
|
||||
{ ...obj4, spaces: [], error: expect.objectContaining({ error: 'Not Found' }) },
|
||||
{ ...obj5, spaces: [], error: expect.objectContaining({ error: 'Not Found' }) },
|
||||
{ ...obj6, spaces: [], error: BULK_ERROR },
|
||||
{ ...obj7, spaces: [EXISTING_SPACE, 'foo-space'] },
|
||||
{ ...obj5, spaces: [], error: BULK_ERROR },
|
||||
{ ...obj6, spaces: [EXISTING_SPACE, 'foo-space'] },
|
||||
]);
|
||||
});
|
||||
|
||||
|
@ -271,7 +271,7 @@ describe('#updateObjectsSpaces', () => {
|
|||
const objects = [{ type: SHAREABLE_OBJ_TYPE, id: 'id-1' }];
|
||||
const spacesToAdd = ['foo-space'];
|
||||
const params = setup({ objects, spacesToAdd });
|
||||
mockMgetResultsNotFound({ found: true });
|
||||
mockMgetResultsNotFound();
|
||||
|
||||
await expect(() => updateObjectsSpaces(params)).rejects.toThrowError(
|
||||
SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError()
|
||||
|
@ -281,71 +281,62 @@ describe('#updateObjectsSpaces', () => {
|
|||
|
||||
// Note: these test cases do not include requested objects that will result in errors (those are covered above)
|
||||
describe('cluster and module calls', () => {
|
||||
it('mget call skips objects that have "spaces" defined', async () => {
|
||||
const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1', spaces: [EXISTING_SPACE] }; // will not be retrieved
|
||||
const obj2 = { type: SHAREABLE_OBJ_TYPE, id: 'id-2' }; // will be passed to mget
|
||||
|
||||
const objects = [obj1, obj2];
|
||||
const spacesToAdd = ['foo-space'];
|
||||
const params = setup({ objects, spacesToAdd });
|
||||
mockMgetResults({ found: true }); // result for obj2
|
||||
mockBulkResults({ error: false }, { error: false }); // results for obj1 and obj2
|
||||
|
||||
await updateObjectsSpaces(params);
|
||||
expect(client.mget).toHaveBeenCalledTimes(1);
|
||||
expectMgetArgs(obj2);
|
||||
});
|
||||
|
||||
it('does not call mget if all objects have "spaces" defined', async () => {
|
||||
const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1', spaces: [EXISTING_SPACE] }; // will not be retrieved
|
||||
|
||||
it('makes mget call for objects', async () => {
|
||||
const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1' };
|
||||
const objects = [obj1];
|
||||
const spacesToAdd = ['foo-space'];
|
||||
const params = setup({ objects, spacesToAdd });
|
||||
mockMgetResults({ found: true, namespaces: [EXISTING_SPACE] }); // result for obj1
|
||||
mockBulkResults({ error: false }); // result for obj1
|
||||
|
||||
await updateObjectsSpaces(params);
|
||||
expect(client.mget).not.toHaveBeenCalled();
|
||||
expect(client.mget).toHaveBeenCalledTimes(1);
|
||||
expectMgetArgs(obj1);
|
||||
});
|
||||
|
||||
describe('bulk call skips objects that will not be changed', () => {
|
||||
it('when adding spaces', async () => {
|
||||
const space1 = 'space-to-add';
|
||||
const space2 = 'other-space';
|
||||
const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1', spaces: [space1] }; // will not be changed
|
||||
const obj2 = { type: SHAREABLE_OBJ_TYPE, id: 'id-2', spaces: [space2] }; // will be updated
|
||||
const otherSpace = 'space-to-add';
|
||||
const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1' };
|
||||
const obj2 = { type: SHAREABLE_OBJ_TYPE, id: 'id-2' };
|
||||
|
||||
const objects = [obj1, obj2];
|
||||
const spacesToAdd = [space1];
|
||||
const spacesToAdd = [otherSpace];
|
||||
const params = setup({ objects, spacesToAdd });
|
||||
// this test case does not call mget
|
||||
mockMgetResults(
|
||||
{ found: true, namespaces: [EXISTING_SPACE, otherSpace] }, // result for obj1 -- will not be changed
|
||||
{ found: true, namespaces: [EXISTING_SPACE] } // result for obj2 -- will be updated to add otherSpace
|
||||
);
|
||||
mockBulkResults({ error: false }); // result for obj2
|
||||
|
||||
await updateObjectsSpaces(params);
|
||||
expect(client.bulk).toHaveBeenCalledTimes(1);
|
||||
expectBulkArgs({
|
||||
action: 'update',
|
||||
object: { ...obj2, namespaces: [space2, space1] },
|
||||
object: { ...obj2, namespaces: [EXISTING_SPACE, otherSpace] },
|
||||
});
|
||||
});
|
||||
|
||||
it('when removing spaces', async () => {
|
||||
const space1 = 'space-to-remove';
|
||||
const space2 = 'other-space';
|
||||
const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1', spaces: [space2] }; // will not be changed
|
||||
const obj2 = { type: SHAREABLE_OBJ_TYPE, id: 'id-2', spaces: [space1, space2] }; // will be updated to remove space1
|
||||
const obj3 = { type: SHAREABLE_OBJ_TYPE, id: 'id-3', spaces: [space1] }; // will be deleted (since it would have no spaces left)
|
||||
const otherSpace = 'other-space';
|
||||
const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1' };
|
||||
const obj2 = { type: SHAREABLE_OBJ_TYPE, id: 'id-2' };
|
||||
const obj3 = { type: SHAREABLE_OBJ_TYPE, id: 'id-3' };
|
||||
|
||||
const objects = [obj1, obj2, obj3];
|
||||
const spacesToRemove = [space1];
|
||||
const spacesToRemove = [EXISTING_SPACE];
|
||||
const params = setup({ objects, spacesToRemove });
|
||||
// this test case does not call mget
|
||||
mockMgetResults(
|
||||
{ found: true, namespaces: [ALL_NAMESPACES_STRING] }, // result for obj1 -- will not be changed
|
||||
{ found: true, namespaces: [EXISTING_SPACE, otherSpace] }, // result for obj2 -- will be updated to remove EXISTING_SPACE
|
||||
{ found: true, namespaces: [EXISTING_SPACE] } // result for obj3 -- will be deleted (since it would have no spaces left)
|
||||
);
|
||||
mockBulkResults({ error: false }, { error: false }); // results for obj2 and obj3
|
||||
|
||||
await updateObjectsSpaces(params);
|
||||
expect(client.bulk).toHaveBeenCalledTimes(1);
|
||||
expectBulkArgs(
|
||||
{ action: 'update', object: { ...obj2, namespaces: [space2] } },
|
||||
{ action: 'update', object: { ...obj2, namespaces: [otherSpace] } },
|
||||
{ action: 'delete', object: obj3 }
|
||||
);
|
||||
});
|
||||
|
@ -353,52 +344,61 @@ describe('#updateObjectsSpaces', () => {
|
|||
it('when adding and removing spaces', async () => {
|
||||
const space1 = 'space-to-add';
|
||||
const space2 = 'space-to-remove';
|
||||
const space3 = 'other-space';
|
||||
const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1', spaces: [space1] }; // will not be changed
|
||||
const obj2 = { type: SHAREABLE_OBJ_TYPE, id: 'id-2', spaces: [space3] }; // will be updated to add space1
|
||||
const obj3 = { type: SHAREABLE_OBJ_TYPE, id: 'id-3', spaces: [space1, space2] }; // will be updated to remove space2
|
||||
const obj4 = { type: SHAREABLE_OBJ_TYPE, id: 'id-4', spaces: [space2, space3] }; // will be updated to add space1 and remove space2
|
||||
const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1' };
|
||||
const obj2 = { type: SHAREABLE_OBJ_TYPE, id: 'id-2' };
|
||||
const obj3 = { type: SHAREABLE_OBJ_TYPE, id: 'id-3' };
|
||||
const obj4 = { type: SHAREABLE_OBJ_TYPE, id: 'id-4' };
|
||||
|
||||
const objects = [obj1, obj2, obj3, obj4];
|
||||
const spacesToAdd = [space1];
|
||||
const spacesToRemove = [space2];
|
||||
const params = setup({ objects, spacesToAdd, spacesToRemove });
|
||||
// this test case does not call mget
|
||||
mockMgetResults(
|
||||
{ found: true, namespaces: [EXISTING_SPACE, space1] }, // result for obj1 -- will not be changed
|
||||
{ found: true, namespaces: [EXISTING_SPACE] }, // result for obj2 -- will be updated to add space1
|
||||
{ found: true, namespaces: [EXISTING_SPACE, space1, space2] }, // result for obj3 -- will be updated to remove space2
|
||||
{ found: true, namespaces: [EXISTING_SPACE, space2] } // result for obj3 -- will be updated to add space1 and remove space2
|
||||
);
|
||||
mockBulkResults({ error: false }, { error: false }, { error: false }); // results for obj2, obj3, and obj4
|
||||
|
||||
await updateObjectsSpaces(params);
|
||||
expect(client.bulk).toHaveBeenCalledTimes(1);
|
||||
expectBulkArgs(
|
||||
{ action: 'update', object: { ...obj2, namespaces: [space3, space1] } },
|
||||
{ action: 'update', object: { ...obj3, namespaces: [space1] } },
|
||||
{ action: 'update', object: { ...obj4, namespaces: [space3, space1] } }
|
||||
{ action: 'update', object: { ...obj2, namespaces: [EXISTING_SPACE, space1] } },
|
||||
{ action: 'update', object: { ...obj3, namespaces: [EXISTING_SPACE, space1] } },
|
||||
{ action: 'update', object: { ...obj4, namespaces: [EXISTING_SPACE, space1] } }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('does not call bulk if all objects do not need to be changed', () => {
|
||||
it('when adding spaces', async () => {
|
||||
const space = 'space-to-add';
|
||||
const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1', spaces: [space] }; // will not be changed
|
||||
const otherSpace = 'space-to-add';
|
||||
const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1' };
|
||||
|
||||
const objects = [obj1];
|
||||
const spacesToAdd = [space];
|
||||
const spacesToAdd = [otherSpace];
|
||||
const params = setup({ objects, spacesToAdd });
|
||||
// this test case does not call mget or bulk
|
||||
mockMgetResults(
|
||||
{ found: true, namespaces: [EXISTING_SPACE, otherSpace] } // result for obj1 -- will not be changed
|
||||
);
|
||||
// this test case does not call bulk
|
||||
|
||||
await updateObjectsSpaces(params);
|
||||
expect(client.bulk).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('when removing spaces', async () => {
|
||||
const space1 = 'space-to-remove';
|
||||
const space2 = 'other-space';
|
||||
const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1', spaces: [space2] }; // will not be changed
|
||||
const otherSpace = 'space-to-remove';
|
||||
const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1' };
|
||||
|
||||
const objects = [obj1];
|
||||
const spacesToRemove = [space1];
|
||||
const spacesToRemove = [otherSpace];
|
||||
const params = setup({ objects, spacesToRemove });
|
||||
// this test case does not call mget or bulk
|
||||
mockMgetResults(
|
||||
{ found: true, namespaces: [EXISTING_SPACE] } // result for obj1 -- will not be changed
|
||||
);
|
||||
// this test case does not call bulk
|
||||
|
||||
await updateObjectsSpaces(params);
|
||||
expect(client.bulk).not.toHaveBeenCalled();
|
||||
|
@ -407,13 +407,16 @@ describe('#updateObjectsSpaces', () => {
|
|||
it('when adding and removing spaces', async () => {
|
||||
const space1 = 'space-to-add';
|
||||
const space2 = 'space-to-remove';
|
||||
const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1', spaces: [space1] }; // will not be changed
|
||||
const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1' };
|
||||
|
||||
const objects = [obj1];
|
||||
const spacesToAdd = [space1];
|
||||
const spacesToRemove = [space2];
|
||||
const params = setup({ objects, spacesToAdd, spacesToRemove });
|
||||
// this test case does not call mget or bulk
|
||||
mockMgetResults(
|
||||
{ found: true, namespaces: [EXISTING_SPACE, space1] } // result for obj1 -- will not be changed
|
||||
);
|
||||
// this test case does not call bulk
|
||||
|
||||
await updateObjectsSpaces(params);
|
||||
expect(client.bulk).not.toHaveBeenCalled();
|
||||
|
@ -424,33 +427,39 @@ describe('#updateObjectsSpaces', () => {
|
|||
it('does not delete aliases for objects that were not removed from any spaces', async () => {
|
||||
const space1 = 'space-to-add';
|
||||
const space2 = 'space-to-remove';
|
||||
const space3 = 'other-space';
|
||||
const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1', spaces: [space1] }; // will not be changed
|
||||
const obj2 = { type: SHAREABLE_OBJ_TYPE, id: 'id-2', spaces: [space3] }; // will be updated
|
||||
const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1' };
|
||||
const obj2 = { type: SHAREABLE_OBJ_TYPE, id: 'id-2' };
|
||||
|
||||
const objects = [obj1, obj2];
|
||||
const spacesToAdd = [space1];
|
||||
const spacesToRemove = [space2];
|
||||
const params = setup({ objects, spacesToAdd, spacesToRemove });
|
||||
// this test case does not call mget
|
||||
mockMgetResults(
|
||||
{ found: true, namespaces: [EXISTING_SPACE, space1] }, // result for obj1 -- will not be changed
|
||||
{ found: true, namespaces: [EXISTING_SPACE] } // result for obj2 -- will be updated to add space1
|
||||
);
|
||||
mockBulkResults({ error: false }); // result for obj2
|
||||
|
||||
await updateObjectsSpaces(params);
|
||||
expect(client.bulk).toHaveBeenCalledTimes(1);
|
||||
expectBulkArgs({ action: 'update', object: { ...obj2, namespaces: [space3, space1] } });
|
||||
expectBulkArgs({
|
||||
action: 'update',
|
||||
object: { ...obj2, namespaces: [EXISTING_SPACE, space1] },
|
||||
});
|
||||
expect(mockDeleteLegacyUrlAliases).not.toHaveBeenCalled();
|
||||
expect(params.logger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not delete aliases for objects that were removed from spaces but were also added to All Spaces (*)', async () => {
|
||||
const space2 = 'space-to-remove';
|
||||
const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1', spaces: [space2] };
|
||||
const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1' };
|
||||
|
||||
const objects = [obj1];
|
||||
const spacesToAdd = [ALL_NAMESPACES_STRING];
|
||||
const spacesToRemove = [space2];
|
||||
const spacesToRemove = [EXISTING_SPACE];
|
||||
const params = setup({ objects, spacesToAdd, spacesToRemove });
|
||||
// this test case does not call mget
|
||||
mockMgetResults(
|
||||
{ found: true, namespaces: [EXISTING_SPACE] } // result for obj1 -- will be updated to remove EXISTING_SPACE and add *
|
||||
);
|
||||
mockBulkResults({ error: false }); // result for obj1
|
||||
|
||||
await updateObjectsSpaces(params);
|
||||
|
@ -463,33 +472,36 @@ describe('#updateObjectsSpaces', () => {
|
|||
expect(params.logger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes aliases for objects that were removed from specific spaces using "deleteBehavior: exclusive"', async () => {
|
||||
it('deletes aliases for objects that were removed from specific spaces using "deleteBehavior: inclusive"', async () => {
|
||||
const space1 = 'space-to-remove';
|
||||
const space2 = 'another-space-to-remove';
|
||||
const space3 = 'other-space';
|
||||
const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1', spaces: [space3] }; // will not be changed
|
||||
const obj2 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1', spaces: [space1, space2, space3] }; // will be updated
|
||||
const obj3 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1', spaces: [space1] }; // will be deleted
|
||||
const space2 = 'other-space';
|
||||
const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1' };
|
||||
const obj2 = { type: SHAREABLE_OBJ_TYPE, id: 'id-2' };
|
||||
const obj3 = { type: SHAREABLE_OBJ_TYPE, id: 'id-3' };
|
||||
|
||||
const objects = [obj1, obj2, obj3];
|
||||
const spacesToRemove = [space1, space2];
|
||||
const spacesToRemove = [EXISTING_SPACE, space1];
|
||||
const params = setup({ objects, spacesToRemove });
|
||||
// this test case does not call mget
|
||||
mockMgetResults(
|
||||
{ found: true, namespaces: [ALL_NAMESPACES_STRING] }, // result for obj1 -- will not be changed
|
||||
{ found: true, namespaces: [EXISTING_SPACE, space1, space2] }, // result for obj2 -- will be updated to remove EXISTING_SPACE and space1
|
||||
{ found: true, namespaces: [EXISTING_SPACE] } // result for obj3 -- will be deleted
|
||||
);
|
||||
mockBulkResults({ error: false }, { error: false }); // result2 for obj2 and obj3
|
||||
|
||||
await updateObjectsSpaces(params);
|
||||
expect(client.bulk).toHaveBeenCalledTimes(1);
|
||||
expectBulkArgs(
|
||||
{ action: 'update', object: { ...obj2, namespaces: [space3] } },
|
||||
{ action: 'update', object: { ...obj2, namespaces: [space2] } },
|
||||
{ action: 'delete', object: obj3 }
|
||||
);
|
||||
expect(mockDeleteLegacyUrlAliases).toHaveBeenCalledTimes(2);
|
||||
expect(mockDeleteLegacyUrlAliases).toHaveBeenNthCalledWith(
|
||||
1, // the first call resulted in an error which generated a log message (see assertion below)
|
||||
1,
|
||||
expect.objectContaining({
|
||||
type: obj2.type,
|
||||
id: obj2.id,
|
||||
namespaces: [space1, space2],
|
||||
namespaces: [EXISTING_SPACE, space1],
|
||||
deleteBehavior: 'inclusive',
|
||||
})
|
||||
);
|
||||
|
@ -498,42 +510,40 @@ describe('#updateObjectsSpaces', () => {
|
|||
expect.objectContaining({
|
||||
type: obj3.type,
|
||||
id: obj3.id,
|
||||
namespaces: [space1],
|
||||
namespaces: [EXISTING_SPACE],
|
||||
deleteBehavior: 'inclusive',
|
||||
})
|
||||
);
|
||||
expect(params.logger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes aliases for objects that were removed from all spaces using "deleteBehavior: inclusive"', async () => {
|
||||
const space1 = 'space-to-add';
|
||||
const space2 = 'other-space';
|
||||
const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1', spaces: [space2] }; // will be updated to add space1
|
||||
const obj2 = {
|
||||
type: SHAREABLE_OBJ_TYPE,
|
||||
id: 'id-2',
|
||||
spaces: [space2, ALL_NAMESPACES_STRING], // will be updated to add space1 and remove *
|
||||
};
|
||||
it('deletes aliases for objects that were removed from all spaces using "deleteBehavior: exclusive"', async () => {
|
||||
const otherSpace = 'space-to-add';
|
||||
const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1' };
|
||||
const obj2 = { type: SHAREABLE_OBJ_TYPE, id: 'id-2' };
|
||||
|
||||
const objects = [obj1, obj2];
|
||||
const spacesToAdd = [space1];
|
||||
const spacesToAdd = [EXISTING_SPACE, otherSpace];
|
||||
const spacesToRemove = [ALL_NAMESPACES_STRING];
|
||||
const params = setup({ objects, spacesToAdd, spacesToRemove });
|
||||
// this test case does not call mget
|
||||
mockBulkResults({ error: false }); // result for obj1
|
||||
mockMgetResults(
|
||||
{ found: true, namespaces: [EXISTING_SPACE] }, // result for obj1 -- will be updated to add otherSpace
|
||||
{ found: true, namespaces: [ALL_NAMESPACES_STRING] } // result for obj2 -- will be updated to remove * and add EXISTING_SPACE and otherSpace
|
||||
);
|
||||
mockBulkResults({ error: false }, { error: false }); // result for obj1 and obj2
|
||||
|
||||
await updateObjectsSpaces(params);
|
||||
expect(client.bulk).toHaveBeenCalledTimes(1);
|
||||
expectBulkArgs(
|
||||
{ action: 'update', object: { ...obj1, namespaces: [space2, space1] } },
|
||||
{ action: 'update', object: { ...obj2, namespaces: [space2, space1] } }
|
||||
{ action: 'update', object: { ...obj1, namespaces: [EXISTING_SPACE, otherSpace] } },
|
||||
{ action: 'update', object: { ...obj2, namespaces: [EXISTING_SPACE, otherSpace] } }
|
||||
);
|
||||
expect(mockDeleteLegacyUrlAliases).toHaveBeenCalledTimes(1);
|
||||
expect(mockDeleteLegacyUrlAliases).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: obj2.type,
|
||||
id: obj2.id,
|
||||
namespaces: [space2, space1],
|
||||
namespaces: [EXISTING_SPACE, otherSpace],
|
||||
deleteBehavior: 'exclusive',
|
||||
})
|
||||
);
|
||||
|
@ -541,20 +551,21 @@ describe('#updateObjectsSpaces', () => {
|
|||
});
|
||||
|
||||
it('logs a message when deleteLegacyUrlAliases returns an error', async () => {
|
||||
const space1 = 'space-to-remove';
|
||||
const space2 = 'other-space';
|
||||
const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1', spaces: [space1, space2] }; // will be updated
|
||||
const otherSpace = 'other-space';
|
||||
const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1' };
|
||||
|
||||
const objects = [obj1];
|
||||
const spacesToRemove = [space1];
|
||||
const spacesToRemove = [otherSpace];
|
||||
const params = setup({ objects, spacesToRemove });
|
||||
// this test case does not call mget
|
||||
mockMgetResults(
|
||||
{ found: true, namespaces: [EXISTING_SPACE, otherSpace] } // result for obj1 -- will be updated to remove otherSpace
|
||||
);
|
||||
mockBulkResults({ error: false }); // result for obj1
|
||||
mockDeleteLegacyUrlAliases.mockRejectedValueOnce(new Error('Oh no!')); // result for deleting aliases for obj1
|
||||
|
||||
await updateObjectsSpaces(params);
|
||||
expect(client.bulk).toHaveBeenCalledTimes(1);
|
||||
expectBulkArgs({ action: 'update', object: { ...obj1, namespaces: [space2] } });
|
||||
expectBulkArgs({ action: 'update', object: { ...obj1, namespaces: [EXISTING_SPACE] } });
|
||||
expect(mockDeleteLegacyUrlAliases).toHaveBeenCalledTimes(1); // don't assert deleteLegacyUrlAliases args, we have tests for that above
|
||||
expect(params.logger.error).toHaveBeenCalledTimes(1);
|
||||
expect(params.logger.error).toHaveBeenCalledWith(
|
||||
|
@ -566,68 +577,323 @@ describe('#updateObjectsSpaces', () => {
|
|||
|
||||
describe('returns expected results', () => {
|
||||
it('when adding spaces', async () => {
|
||||
const space1 = 'space-to-add';
|
||||
const space2 = 'other-space';
|
||||
const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1', spaces: [space1] }; // will not be changed
|
||||
const obj2 = { type: SHAREABLE_OBJ_TYPE, id: 'id-2', spaces: [space2] }; // will be updated
|
||||
const otherSpace = 'space-to-add';
|
||||
const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1' };
|
||||
const obj2 = { type: SHAREABLE_OBJ_TYPE, id: 'id-2' };
|
||||
|
||||
const objects = [obj1, obj2];
|
||||
const spacesToAdd = [space1];
|
||||
const spacesToAdd = [otherSpace];
|
||||
const params = setup({ objects, spacesToAdd });
|
||||
// this test case does not call mget
|
||||
mockMgetResults(
|
||||
{ found: true, namespaces: [EXISTING_SPACE, otherSpace] }, // result for obj1 -- will not be changed
|
||||
{ found: true, namespaces: [EXISTING_SPACE] } // result for obj2 -- will be updated to add otherSpace
|
||||
);
|
||||
mockBulkResults({ error: false }); // result for obj2
|
||||
|
||||
const result = await updateObjectsSpaces(params);
|
||||
expect(result.objects).toEqual([
|
||||
{ ...obj1, spaces: [space1] },
|
||||
{ ...obj2, spaces: [space2, space1] },
|
||||
{ ...obj1, spaces: [EXISTING_SPACE, otherSpace] },
|
||||
{ ...obj2, spaces: [EXISTING_SPACE, otherSpace] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('when removing spaces', async () => {
|
||||
const space1 = 'space-to-remove';
|
||||
const space2 = 'other-space';
|
||||
const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1', spaces: [space2] }; // will not be changed
|
||||
const obj2 = { type: SHAREABLE_OBJ_TYPE, id: 'id-2', spaces: [space1, space2] }; // will be updated to remove space1
|
||||
const obj3 = { type: SHAREABLE_OBJ_TYPE, id: 'id-3', spaces: [space1] }; // will be deleted (since it would have no spaces left)
|
||||
const otherSpace = 'other-space';
|
||||
const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1' };
|
||||
const obj2 = { type: SHAREABLE_OBJ_TYPE, id: 'id-2' };
|
||||
const obj3 = { type: SHAREABLE_OBJ_TYPE, id: 'id-3' };
|
||||
|
||||
const objects = [obj1, obj2, obj3];
|
||||
const spacesToRemove = [space1];
|
||||
const spacesToRemove = [EXISTING_SPACE];
|
||||
const params = setup({ objects, spacesToRemove });
|
||||
// this test case does not call mget
|
||||
mockMgetResults(
|
||||
{ found: true, namespaces: [ALL_NAMESPACES_STRING] }, // result for obj1 -- will not be changed
|
||||
{ found: true, namespaces: [EXISTING_SPACE, otherSpace] }, // result for obj2 -- will be updated to remove EXISTING_SPACE
|
||||
{ found: true, namespaces: [EXISTING_SPACE] } // result for obj3 -- will be deleted (since it would have no spaces left)
|
||||
);
|
||||
mockBulkResults({ error: false }, { error: false }); // results for obj2 and obj3
|
||||
|
||||
const result = await updateObjectsSpaces(params);
|
||||
expect(result.objects).toEqual([
|
||||
{ ...obj1, spaces: [space2] },
|
||||
{ ...obj2, spaces: [space2] },
|
||||
{ ...obj1, spaces: [ALL_NAMESPACES_STRING] },
|
||||
{ ...obj2, spaces: [otherSpace] },
|
||||
{ ...obj3, spaces: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('when adding and removing spaces', async () => {
|
||||
const space1 = 'space-to-add';
|
||||
const space2 = 'space-to-remove';
|
||||
const space3 = 'other-space';
|
||||
const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1', spaces: [space1] }; // will not be changed
|
||||
const obj2 = { type: SHAREABLE_OBJ_TYPE, id: 'id-2', spaces: [space3] }; // will be updated to add space1
|
||||
const obj3 = { type: SHAREABLE_OBJ_TYPE, id: 'id-3', spaces: [space1, space2] }; // will be updated to remove space2
|
||||
const obj4 = { type: SHAREABLE_OBJ_TYPE, id: 'id-4', spaces: [space2, space3] }; // will be updated to add space1 and remove space2
|
||||
const otherSpace = 'space-to-add';
|
||||
const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1' };
|
||||
const obj2 = { type: SHAREABLE_OBJ_TYPE, id: 'id-2' };
|
||||
const obj3 = { type: SHAREABLE_OBJ_TYPE, id: 'id-3' };
|
||||
const obj4 = { type: SHAREABLE_OBJ_TYPE, id: 'id-4' };
|
||||
|
||||
const objects = [obj1, obj2, obj3, obj4];
|
||||
const spacesToAdd = [space1];
|
||||
const spacesToRemove = [space2];
|
||||
const spacesToAdd = [otherSpace];
|
||||
const spacesToRemove = [EXISTING_SPACE];
|
||||
const params = setup({ objects, spacesToAdd, spacesToRemove });
|
||||
// this test case does not call mget
|
||||
mockMgetResults(
|
||||
{ found: true, namespaces: [ALL_NAMESPACES_STRING, otherSpace] }, // result for obj1 -- will not be changed
|
||||
{ found: true, namespaces: [ALL_NAMESPACES_STRING] }, // result for obj2 -- will be updated to add otherSpace
|
||||
{ found: true, namespaces: [EXISTING_SPACE, otherSpace] }, // result for obj3 -- will be updated to remove EXISTING_SPACE
|
||||
{ found: true, namespaces: [EXISTING_SPACE] } // result for obj4 -- will be updated to remove EXISTING_SPACE and add otherSpace
|
||||
);
|
||||
mockBulkResults({ error: false }, { error: false }, { error: false }); // results for obj2, obj3, and obj4
|
||||
|
||||
const result = await updateObjectsSpaces(params);
|
||||
expect(result.objects).toEqual([
|
||||
{ ...obj1, spaces: [space1] },
|
||||
{ ...obj2, spaces: [space3, space1] },
|
||||
{ ...obj3, spaces: [space1] },
|
||||
{ ...obj4, spaces: [space3, space1] },
|
||||
{ ...obj1, spaces: [ALL_NAMESPACES_STRING, otherSpace] },
|
||||
{ ...obj2, spaces: [ALL_NAMESPACES_STRING, otherSpace] },
|
||||
{ ...obj3, spaces: [otherSpace] },
|
||||
{ ...obj4, spaces: [otherSpace] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe(`with security extension`, () => {
|
||||
let mockSecurityExt: jest.Mocked<ISavedObjectsSecurityExtension>;
|
||||
let params: UpdateObjectsSpacesParams;
|
||||
|
||||
describe(`errors`, () => {
|
||||
beforeEach(() => {
|
||||
const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1' };
|
||||
const objects = [obj1];
|
||||
const spacesToAdd = ['foo-space'];
|
||||
mockSecurityExt = savedObjectsExtensionsMock.createSecurityExtension();
|
||||
params = setup({ objects, spacesToAdd }, mockSecurityExt);
|
||||
mockMgetResults({ found: true, namespaces: [EXISTING_SPACE] }); // result for obj1
|
||||
mockBulkResults({ error: false }); // result for obj1
|
||||
});
|
||||
|
||||
test(`propagates error from es client bulk get`, async () => {
|
||||
setupCheckAuthorized(mockSecurityExt);
|
||||
setupEnforceSuccess(mockSecurityExt);
|
||||
setupRedactPassthrough(mockSecurityExt);
|
||||
|
||||
const error = SavedObjectsErrorHelpers.createBadRequestError('OOPS!');
|
||||
|
||||
mockGetBulkOperationError.mockReset();
|
||||
client.bulk.mockReset();
|
||||
client.bulk.mockImplementationOnce(() => {
|
||||
throw error;
|
||||
});
|
||||
|
||||
await expect(updateObjectsSpaces(params)).rejects.toThrow(error);
|
||||
});
|
||||
|
||||
test(`propagates decorated error when checkAuthorization rejects promise`, async () => {
|
||||
mockSecurityExt.checkAuthorization.mockRejectedValueOnce(checkAuthError);
|
||||
|
||||
await expect(updateObjectsSpaces(params)).rejects.toThrow(checkAuthError);
|
||||
expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1);
|
||||
expect(mockSecurityExt.enforceAuthorization).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test(`propagates decorated error when unauthorized`, async () => {
|
||||
setupCheckUnauthorized(mockSecurityExt);
|
||||
setupEnforceFailure(mockSecurityExt);
|
||||
|
||||
await expect(updateObjectsSpaces(params)).rejects.toThrow(enforceError);
|
||||
expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1);
|
||||
expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test(`adds audit event when not unauthorized`, async () => {
|
||||
setupCheckUnauthorized(mockSecurityExt);
|
||||
setupEnforceFailure(mockSecurityExt);
|
||||
|
||||
await expect(updateObjectsSpaces(params)).rejects.toThrow(enforceError);
|
||||
expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1);
|
||||
expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledTimes(1);
|
||||
expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledWith({
|
||||
action: AuditAction.UPDATE_OBJECTS_SPACES,
|
||||
addToSpaces: params.spacesToAdd,
|
||||
deleteFromSpaces: undefined,
|
||||
savedObject: { type: params.objects[0].type, id: params.objects[0].id },
|
||||
error: enforceError,
|
||||
});
|
||||
});
|
||||
|
||||
test(`returns error from es client bulk operation`, async () => {
|
||||
setupCheckAuthorized(mockSecurityExt);
|
||||
setupEnforceSuccess(mockSecurityExt);
|
||||
setupRedactPassthrough(mockSecurityExt);
|
||||
|
||||
mockGetBulkOperationError.mockReset();
|
||||
client.bulk.mockReset();
|
||||
mockBulkResults({ error: true });
|
||||
|
||||
const result = await updateObjectsSpaces(params);
|
||||
expect(result).toEqual({
|
||||
objects: [
|
||||
{
|
||||
error: BULK_ERROR,
|
||||
id: params.objects[0].id,
|
||||
spaces: [],
|
||||
type: params.objects[0].type,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('success', () => {
|
||||
const defaultSpace = 'default';
|
||||
const otherSpace = 'space-to-add';
|
||||
const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1' };
|
||||
const obj2 = { type: SHAREABLE_OBJ_TYPE, id: 'id-2' };
|
||||
const obj3 = { type: SHAREABLE_OBJ_TYPE, id: 'id-3' };
|
||||
const obj4 = { type: SHAREABLE_OBJ_TYPE, id: 'id-4' };
|
||||
|
||||
const objects = [obj1, obj2, obj3, obj4];
|
||||
const spacesToAdd = [otherSpace];
|
||||
const spacesToRemove = [EXISTING_SPACE];
|
||||
|
||||
beforeEach(() => {
|
||||
mockSecurityExt = savedObjectsExtensionsMock.createSecurityExtension();
|
||||
params = setup({ objects, spacesToAdd, spacesToRemove }, mockSecurityExt);
|
||||
mockMgetResults(
|
||||
{ found: true, namespaces: [ALL_NAMESPACES_STRING, otherSpace] }, // result for obj1 -- will not be changed
|
||||
{ found: true, namespaces: [ALL_NAMESPACES_STRING] }, // result for obj2 -- will be updated to add otherSpace
|
||||
{ found: true, namespaces: [EXISTING_SPACE, otherSpace] }, // result for obj3 -- will be updated to remove EXISTING_SPACE
|
||||
{ found: true, namespaces: [EXISTING_SPACE] } // result for obj4 -- will be updated to remove EXISTING_SPACE and add otherSpace
|
||||
);
|
||||
mockBulkResults({ error: false }, { error: false }, { error: false }); // results for obj2, obj3, and obj4
|
||||
setupCheckAuthorized(mockSecurityExt);
|
||||
setupEnforceSuccess(mockSecurityExt);
|
||||
setupRedactPassthrough(mockSecurityExt);
|
||||
});
|
||||
|
||||
test(`calls checkAuthorization with type, actions, and namespaces`, async () => {
|
||||
await updateObjectsSpaces(params);
|
||||
|
||||
expect(client.bulk).toHaveBeenCalledTimes(1);
|
||||
expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1);
|
||||
const expectedActions = new Set(['share_to_space']);
|
||||
const expectedSpaces = new Set([defaultSpace, otherSpace, EXISTING_SPACE]);
|
||||
const expectedTypes = new Set([SHAREABLE_OBJ_TYPE]);
|
||||
|
||||
const {
|
||||
actions: actualActions,
|
||||
spaces: actualSpaces,
|
||||
types: actualTypes,
|
||||
} = mockSecurityExt.checkAuthorization.mock.calls[0][0];
|
||||
|
||||
expect(setsAreEqual(actualActions, expectedActions)).toBeTruthy();
|
||||
expect(setsAreEqual(actualSpaces, expectedSpaces)).toBeTruthy();
|
||||
expect(setsAreEqual(actualTypes, expectedTypes)).toBeTruthy();
|
||||
});
|
||||
|
||||
test(`calls enforceAuthorization with action, type map, and auth map`, async () => {
|
||||
await updateObjectsSpaces(params);
|
||||
|
||||
expect(client.bulk).toHaveBeenCalledTimes(1);
|
||||
expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1);
|
||||
expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledTimes(1);
|
||||
expect(mockSecurityExt.enforceAuthorization).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'share_to_space',
|
||||
})
|
||||
);
|
||||
const expectedTypesAndSpaces = new Map([
|
||||
[SHAREABLE_OBJ_TYPE, new Set([defaultSpace, EXISTING_SPACE, otherSpace])],
|
||||
]);
|
||||
|
||||
const { typesAndSpaces: actualTypesAndSpaces, typeMap: actualTypeMap } =
|
||||
mockSecurityExt.enforceAuthorization.mock.calls[0][0];
|
||||
|
||||
expect(typeMapsAreEqual(actualTypesAndSpaces, expectedTypesAndSpaces)).toBeTruthy();
|
||||
expect(actualTypeMap).toBe(authMap);
|
||||
});
|
||||
|
||||
test(`adds audit event per object when successful`, async () => {
|
||||
await updateObjectsSpaces(params);
|
||||
|
||||
expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledTimes(objects.length);
|
||||
objects.forEach((obj) => {
|
||||
expect(mockSecurityExt.addAuditEvent).toHaveBeenCalledWith({
|
||||
action: AuditAction.UPDATE_OBJECTS_SPACES,
|
||||
savedObject: { type: obj.type, id: obj.id },
|
||||
outcome: 'unknown',
|
||||
addToSpaces: spacesToAdd,
|
||||
deleteFromSpaces: spacesToRemove,
|
||||
error: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('all spaces', () => {
|
||||
const defaultSpace = 'default';
|
||||
const otherSpace = 'space-to-add';
|
||||
const obj1 = { type: SHAREABLE_OBJ_TYPE, id: 'id-1' };
|
||||
const obj2 = { type: SHAREABLE_OBJ_TYPE, id: 'id-2' };
|
||||
const obj3 = { type: SHAREABLE_OBJ_TYPE, id: 'id-3' };
|
||||
const obj4 = { type: SHAREABLE_OBJ_TYPE, id: 'id-4' };
|
||||
const objects = [obj1, obj2, obj3, obj4];
|
||||
|
||||
const setupForAllSpaces = (spacesToAdd: string[], spacesToRemove: string[]) => {
|
||||
mockSecurityExt = savedObjectsExtensionsMock.createSecurityExtension();
|
||||
params = setup({ objects, spacesToAdd, spacesToRemove }, mockSecurityExt);
|
||||
mockMgetResults(
|
||||
{ found: true, namespaces: [ALL_NAMESPACES_STRING, otherSpace] }, // result for obj1 -- will not be changed
|
||||
{ found: true, namespaces: [ALL_NAMESPACES_STRING] }, // result for obj2 -- will be updated to add otherSpace
|
||||
{ found: true, namespaces: [EXISTING_SPACE, otherSpace] }, // result for obj3 -- will be updated to remove EXISTING_SPACE
|
||||
{ found: true, namespaces: [EXISTING_SPACE] } // result for obj4 -- will be updated to remove EXISTING_SPACE and add otherSpace
|
||||
);
|
||||
mockBulkResults({ error: false }, { error: false }, { error: false }); // results for obj2, obj3, and obj4
|
||||
setupCheckAuthorized(mockSecurityExt);
|
||||
setupEnforceSuccess(mockSecurityExt);
|
||||
setupRedactPassthrough(mockSecurityExt);
|
||||
};
|
||||
|
||||
test(`calls checkAuthorization with '*' when spacesToAdd includes '*'`, async () => {
|
||||
const spacesToAdd = ['*'];
|
||||
const spacesToRemove = [otherSpace];
|
||||
setupForAllSpaces(spacesToAdd, spacesToRemove);
|
||||
await updateObjectsSpaces(params);
|
||||
|
||||
expect(client.bulk).toHaveBeenCalledTimes(1);
|
||||
expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1);
|
||||
const expectedActions = new Set(['share_to_space']);
|
||||
const expectedSpaces = new Set(['*', defaultSpace, otherSpace, EXISTING_SPACE]);
|
||||
const expectedTypes = new Set([SHAREABLE_OBJ_TYPE]);
|
||||
|
||||
const {
|
||||
actions: actualActions,
|
||||
spaces: actualSpaces,
|
||||
types: actualTypes,
|
||||
} = mockSecurityExt.checkAuthorization.mock.calls[0][0];
|
||||
|
||||
expect(setsAreEqual(actualActions, expectedActions)).toBeTruthy();
|
||||
expect(setsAreEqual(actualSpaces, expectedSpaces)).toBeTruthy();
|
||||
expect(setsAreEqual(actualTypes, expectedTypes)).toBeTruthy();
|
||||
});
|
||||
|
||||
test(`calls checkAuthorization with '*' when spacesToRemove includes '*'`, async () => {
|
||||
const spacesToAdd = [otherSpace];
|
||||
const spacesToRemove = ['*'];
|
||||
setupForAllSpaces(spacesToAdd, spacesToRemove);
|
||||
await updateObjectsSpaces(params);
|
||||
|
||||
expect(client.bulk).toHaveBeenCalledTimes(1);
|
||||
expect(mockSecurityExt.checkAuthorization).toHaveBeenCalledTimes(1);
|
||||
const expectedActions = new Set(['share_to_space']);
|
||||
const expectedSpaces = new Set(['*', defaultSpace, otherSpace, EXISTING_SPACE]);
|
||||
const expectedTypes = new Set([SHAREABLE_OBJ_TYPE]);
|
||||
|
||||
const {
|
||||
actions: actualActions,
|
||||
spaces: actualSpaces,
|
||||
types: actualTypes,
|
||||
} = mockSecurityExt.checkAuthorization.mock.calls[0][0];
|
||||
|
||||
expect(setsAreEqual(actualActions, expectedActions)).toBeTruthy();
|
||||
expect(setsAreEqual(actualSpaces, expectedSpaces)).toBeTruthy();
|
||||
expect(setsAreEqual(actualTypes, expectedTypes)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -18,24 +18,28 @@ import type {
|
|||
SavedObjectsUpdateObjectsSpacesResponse,
|
||||
SavedObjectsUpdateObjectsSpacesResponseObject,
|
||||
} from '@kbn/core-saved-objects-api-server';
|
||||
import type {
|
||||
ISavedObjectTypeRegistry,
|
||||
SavedObjectsRawDocSource,
|
||||
import {
|
||||
AuditAction,
|
||||
type ISavedObjectsSecurityExtension,
|
||||
type ISavedObjectTypeRegistry,
|
||||
type SavedObjectsRawDocSource,
|
||||
} from '@kbn/core-saved-objects-server';
|
||||
import {
|
||||
SavedObjectsErrorHelpers,
|
||||
ALL_NAMESPACES_STRING,
|
||||
type DecoratedError,
|
||||
SavedObjectsUtils,
|
||||
} from '@kbn/core-saved-objects-utils-server';
|
||||
import type {
|
||||
IndexMapping,
|
||||
SavedObjectsSerializer,
|
||||
} from '@kbn/core-saved-objects-base-server-internal';
|
||||
import type { SavedObject } from '@kbn/core-saved-objects-common';
|
||||
import {
|
||||
getBulkOperationError,
|
||||
getExpectedVersionProperties,
|
||||
rawDocExistsInNamespace,
|
||||
Either,
|
||||
type Either,
|
||||
isLeft,
|
||||
isRight,
|
||||
} from './internal_utils';
|
||||
|
@ -57,6 +61,7 @@ export interface UpdateObjectsSpacesParams {
|
|||
serializer: SavedObjectsSerializer;
|
||||
logger: Logger;
|
||||
getIndexForType: (type: string) => string;
|
||||
securityExtension: ISavedObjectsSecurityExtension | undefined;
|
||||
objects: SavedObjectsUpdateObjectsSpacesObject[];
|
||||
spacesToAdd: string[];
|
||||
spacesToRemove: string[];
|
||||
|
@ -86,6 +91,7 @@ export async function updateObjectsSpaces({
|
|||
serializer,
|
||||
logger,
|
||||
getIndexForType,
|
||||
securityExtension,
|
||||
objects,
|
||||
spacesToAdd,
|
||||
spacesToRemove,
|
||||
|
@ -106,9 +112,12 @@ export async function updateObjectsSpaces({
|
|||
|
||||
let bulkGetRequestIndexCounter = 0;
|
||||
const expectedBulkGetResults: Array<
|
||||
Either<SavedObjectsUpdateObjectsSpacesResponseObject, Record<string, any>>
|
||||
Either<
|
||||
SavedObjectsUpdateObjectsSpacesResponseObject,
|
||||
{ type: string; id: string; version: string | undefined; esRequestIndex: number }
|
||||
>
|
||||
> = objects.map((object) => {
|
||||
const { type, id, spaces, version } = object;
|
||||
const { type, id, version } = object;
|
||||
|
||||
if (!allowedTypes.includes(type)) {
|
||||
const error = errorContent(SavedObjectsErrorHelpers.createGenericNotFoundError(type, id));
|
||||
|
@ -134,23 +143,26 @@ export async function updateObjectsSpaces({
|
|||
value: {
|
||||
type,
|
||||
id,
|
||||
spaces,
|
||||
version,
|
||||
...(!spaces && { esRequestIndex: bulkGetRequestIndexCounter++ }),
|
||||
esRequestIndex: bulkGetRequestIndexCounter++,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const bulkGetDocs = expectedBulkGetResults.reduce<estypes.MgetOperation[]>((acc, x) => {
|
||||
if (isRight(x) && x.value.esRequestIndex !== undefined) {
|
||||
acc.push({
|
||||
_id: serializer.generateRawId(undefined, x.value.type, x.value.id),
|
||||
_index: getIndexForType(x.value.type),
|
||||
_source: ['type', 'namespaces'],
|
||||
});
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
const validObjects = expectedBulkGetResults.filter(isRight);
|
||||
if (validObjects.length === 0) {
|
||||
// We only have error results; return early to avoid potentially trying authZ checks for 0 types which would result in an exception.
|
||||
return {
|
||||
// Filter with the `isLeft` comparator simply because it's a convenient type guard, we know the only expected results are errors.
|
||||
objects: expectedBulkGetResults.filter(isLeft).map(({ value }) => value),
|
||||
};
|
||||
}
|
||||
|
||||
const bulkGetDocs = validObjects.map<estypes.MgetOperation>((x) => ({
|
||||
_id: serializer.generateRawId(undefined, x.value.type, x.value.id),
|
||||
_index: getIndexForType(x.value.type),
|
||||
_source: ['type', 'namespaces'],
|
||||
}));
|
||||
const bulkGetResponse = bulkGetDocs.length
|
||||
? await client.mget<SavedObjectsRawDocSource>(
|
||||
{ body: { docs: bulkGetDocs } },
|
||||
|
@ -167,6 +179,59 @@ export async function updateObjectsSpaces({
|
|||
) {
|
||||
throw SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError();
|
||||
}
|
||||
|
||||
const namespaceString = SavedObjectsUtils.namespaceIdToString(namespace);
|
||||
const addToSpaces = spacesToAdd.length ? spacesToAdd : undefined;
|
||||
const deleteFromSpaces = spacesToRemove.length ? spacesToRemove : undefined;
|
||||
const typesAndSpaces = new Map<string, Set<string>>();
|
||||
const spacesToAuthorize = new Set<string>();
|
||||
for (const { value } of validObjects) {
|
||||
const { type, esRequestIndex: index } = value;
|
||||
const preflightResult = index !== undefined ? bulkGetResponse?.body.docs[index] : undefined;
|
||||
|
||||
const spacesToEnforce =
|
||||
typesAndSpaces.get(type) ?? new Set([...spacesToAdd, ...spacesToRemove, namespaceString]); // Always enforce authZ for the active space
|
||||
typesAndSpaces.set(type, spacesToEnforce);
|
||||
for (const space of spacesToEnforce) {
|
||||
spacesToAuthorize.add(space);
|
||||
}
|
||||
// @ts-expect-error MultiGetHit._source is optional
|
||||
for (const space of preflightResult?._source?.namespaces ?? []) {
|
||||
// Existing namespaces are included so we can later redact if necessary
|
||||
// If this is a specific space, add it to the spaces we'll check privileges for (don't accidentally check for global privileges)
|
||||
if (space === ALL_NAMESPACES_STRING) continue;
|
||||
spacesToAuthorize.add(space);
|
||||
}
|
||||
}
|
||||
|
||||
const authorizationResult = await securityExtension?.checkAuthorization({
|
||||
types: new Set(typesAndSpaces.keys()),
|
||||
spaces: spacesToAuthorize,
|
||||
actions: new Set(['share_to_space']),
|
||||
// If a user tries to share/unshare an object to/from '*', they need to have 'share_to_space' privileges for the Global Resource (e.g.,
|
||||
// All privileges for All Spaces).
|
||||
options: { allowGlobalResource: true },
|
||||
});
|
||||
if (authorizationResult) {
|
||||
securityExtension!.enforceAuthorization({
|
||||
typesAndSpaces,
|
||||
action: 'share_to_space',
|
||||
typeMap: authorizationResult.typeMap,
|
||||
auditCallback: (error) => {
|
||||
for (const { value } of validObjects) {
|
||||
securityExtension!.addAuditEvent({
|
||||
action: AuditAction.UPDATE_OBJECTS_SPACES,
|
||||
savedObject: { type: value.type, id: value.id },
|
||||
addToSpaces,
|
||||
deleteFromSpaces,
|
||||
error,
|
||||
...(!error && { outcome: 'unknown' }), // If authorization was a success, the outcome is unknown because the update operation has not occurred yet
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const time = new Date().toISOString();
|
||||
let bulkOperationRequestIndexCounter = 0;
|
||||
const bulkOperationParams: estypes.BulkOperationContainer[] = [];
|
||||
|
@ -181,40 +246,25 @@ export async function updateObjectsSpaces({
|
|||
return expectedBulkGetResult;
|
||||
}
|
||||
|
||||
const { id, type, spaces, version, esRequestIndex } = expectedBulkGetResult.value;
|
||||
|
||||
let currentSpaces: string[] = spaces;
|
||||
let versionProperties;
|
||||
if (esRequestIndex !== undefined) {
|
||||
const doc = bulkGetResponse?.body.docs[esRequestIndex];
|
||||
const isErrorDoc = isMgetError(doc);
|
||||
|
||||
if (
|
||||
isErrorDoc ||
|
||||
!doc?.found ||
|
||||
// @ts-expect-error MultiGetHit._source is optional
|
||||
!rawDocExistsInNamespace(registry, doc, namespace)
|
||||
) {
|
||||
const error = errorContent(SavedObjectsErrorHelpers.createGenericNotFoundError(type, id));
|
||||
return {
|
||||
tag: 'Left',
|
||||
value: { id, type, spaces: [], error },
|
||||
};
|
||||
}
|
||||
currentSpaces = doc._source?.namespaces ?? [];
|
||||
const { id, type, version, esRequestIndex } = expectedBulkGetResult.value;
|
||||
const doc = bulkGetResponse!.body.docs[esRequestIndex];
|
||||
if (
|
||||
isMgetError(doc) ||
|
||||
!doc?.found ||
|
||||
// @ts-expect-error MultiGetHit._source is optional
|
||||
versionProperties = getExpectedVersionProperties(version, doc);
|
||||
} else if (spaces?.length === 0) {
|
||||
// A SOC wrapper attempted to retrieve this object in a pre-flight request and it was not found.
|
||||
!rawDocExistsInNamespace(registry, doc, namespace)
|
||||
) {
|
||||
const error = errorContent(SavedObjectsErrorHelpers.createGenericNotFoundError(type, id));
|
||||
return {
|
||||
tag: 'Left',
|
||||
value: { id, type, spaces: [], error },
|
||||
};
|
||||
} else {
|
||||
versionProperties = getExpectedVersionProperties(version);
|
||||
}
|
||||
|
||||
const currentSpaces = doc._source?.namespaces ?? [];
|
||||
// @ts-expect-error MultiGetHit._source is optional
|
||||
const versionProperties = getExpectedVersionProperties(version, doc);
|
||||
|
||||
const { updatedSpaces, removedSpaces, isUpdateRequired } = analyzeSpaceChanges(
|
||||
currentSpaces,
|
||||
spacesToAdd,
|
||||
|
@ -296,6 +346,13 @@ export async function updateObjectsSpaces({
|
|||
}
|
||||
}
|
||||
|
||||
if (authorizationResult) {
|
||||
const { namespaces: redactedSpaces } = securityExtension!.redactNamespaces({
|
||||
savedObject: { type, namespaces: updatedSpaces } as SavedObject, // Other SavedObject attributes aren't required
|
||||
typeMap: authorizationResult.typeMap,
|
||||
});
|
||||
return { id, type, spaces: redactedSpaces! };
|
||||
}
|
||||
return { id, type, spaces: updatedSpaces };
|
||||
}
|
||||
),
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { loggerMock, MockedLogger } from '@kbn/logging-mocks';
|
||||
import { loggerMock, type MockedLogger } from '@kbn/logging-mocks';
|
||||
import type {
|
||||
SavedObjectsClientContract,
|
||||
ISavedObjectsRepository,
|
||||
|
@ -48,7 +48,7 @@ const createPointInTimeFinderMock = ({
|
|||
const createPointInTimeFinderClientMock = (): jest.Mocked<SavedObjectsPointInTimeFinderClient> => {
|
||||
return {
|
||||
find: jest.fn(),
|
||||
openPointInTimeForType: jest.fn(),
|
||||
openPointInTimeForType: jest.fn().mockResolvedValue({ id: 'some_pit_id' }),
|
||||
closePointInTime: jest.fn(),
|
||||
};
|
||||
};
|
||||
|
|
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* 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 {
|
||||
ISavedObjectsEncryptionExtension,
|
||||
ISavedObjectsSecurityExtension,
|
||||
ISavedObjectsSpacesExtension,
|
||||
SavedObjectsExtensions,
|
||||
} from '@kbn/core-saved-objects-server';
|
||||
|
||||
const createEncryptionExtension = (): jest.Mocked<ISavedObjectsEncryptionExtension> => ({
|
||||
isEncryptableType: jest.fn(),
|
||||
decryptOrStripResponseAttributes: jest.fn(),
|
||||
encryptAttributes: jest.fn(),
|
||||
});
|
||||
|
||||
const createSecurityExtension = (): jest.Mocked<ISavedObjectsSecurityExtension> => ({
|
||||
checkAuthorization: jest.fn(),
|
||||
enforceAuthorization: jest.fn(),
|
||||
addAuditEvent: jest.fn(),
|
||||
redactNamespaces: jest.fn(),
|
||||
});
|
||||
|
||||
const createSpacesExtension = (): jest.Mocked<ISavedObjectsSpacesExtension> => ({
|
||||
getCurrentNamespace: jest.fn(),
|
||||
getSearchableNamespaces: jest.fn(),
|
||||
});
|
||||
|
||||
const create = (): jest.Mocked<SavedObjectsExtensions> => ({
|
||||
encryptionExtension: createEncryptionExtension(),
|
||||
securityExtension: createSecurityExtension(),
|
||||
spacesExtension: createSpacesExtension(),
|
||||
});
|
||||
|
||||
export const savedObjectsExtensionsMock = {
|
||||
create,
|
||||
createEncryptionExtension,
|
||||
createSecurityExtension,
|
||||
createSpacesExtension,
|
||||
};
|
|
@ -0,0 +1,930 @@
|
|||
/*
|
||||
* 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 * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
|
||||
import { schema } from '@kbn/config-schema';
|
||||
import { loggerMock } from '@kbn/logging-mocks';
|
||||
import { isEqual } from 'lodash';
|
||||
import { Payload } from 'elastic-apm-node';
|
||||
import {
|
||||
AuthorizationTypeEntry,
|
||||
EnforceAuthorizationParams,
|
||||
ISavedObjectsSecurityExtension,
|
||||
SavedObjectsMappingProperties,
|
||||
SavedObjectsRawDocSource,
|
||||
SavedObjectsType,
|
||||
SavedObjectsTypeMappingDefinition,
|
||||
} from '@kbn/core-saved-objects-server';
|
||||
import { SavedObject, SavedObjectReference } from '@kbn/core-saved-objects-common';
|
||||
import {
|
||||
SavedObjectsBaseOptions,
|
||||
SavedObjectsBulkCreateObject,
|
||||
SavedObjectsBulkDeleteObject,
|
||||
SavedObjectsBulkDeleteOptions,
|
||||
SavedObjectsBulkGetObject,
|
||||
SavedObjectsBulkUpdateObject,
|
||||
SavedObjectsBulkUpdateOptions,
|
||||
SavedObjectsCreateOptions,
|
||||
SavedObjectsDeleteOptions,
|
||||
SavedObjectsFindOptions,
|
||||
SavedObjectsUpdateOptions,
|
||||
} from '@kbn/core-saved-objects-api-server';
|
||||
import { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-utils-server';
|
||||
|
||||
import {
|
||||
encodeHitVersion,
|
||||
SavedObjectsSerializer,
|
||||
SavedObjectTypeRegistry,
|
||||
} from '@kbn/core-saved-objects-base-server-internal';
|
||||
import {
|
||||
elasticsearchClientMock,
|
||||
ElasticsearchClientMock,
|
||||
} from '@kbn/core-elasticsearch-client-server-mocks';
|
||||
import { DocumentMigrator } from '@kbn/core-saved-objects-migration-server-internal';
|
||||
import { mockGetSearchDsl } from '../lib/repository.test.mock';
|
||||
import { SavedObjectsRepository } from '../lib/repository';
|
||||
|
||||
export const DEFAULT_SPACE = 'default';
|
||||
|
||||
export interface ExpectedErrorResult {
|
||||
type: string;
|
||||
id: string;
|
||||
error: Record<string, any>;
|
||||
}
|
||||
|
||||
export type ErrorPayload = Error & Payload;
|
||||
|
||||
export const createBadRequestErrorPayload = (reason?: string) =>
|
||||
SavedObjectsErrorHelpers.createBadRequestError(reason).output.payload as unknown as ErrorPayload;
|
||||
export const createConflictErrorPayload = (type: string, id: string, reason?: string) =>
|
||||
SavedObjectsErrorHelpers.createConflictError(type, id, reason).output
|
||||
.payload as unknown as ErrorPayload;
|
||||
export const createGenericNotFoundErrorPayload = (
|
||||
type: string | null = null,
|
||||
id: string | null = null
|
||||
) =>
|
||||
SavedObjectsErrorHelpers.createGenericNotFoundError(type, id).output
|
||||
.payload as unknown as ErrorPayload;
|
||||
export const createUnsupportedTypeErrorPayload = (type: string) =>
|
||||
SavedObjectsErrorHelpers.createUnsupportedTypeError(type).output
|
||||
.payload as unknown as ErrorPayload;
|
||||
|
||||
export const expectError = ({ type, id }: { type: string; id: string }) => ({
|
||||
type,
|
||||
id,
|
||||
error: expect.any(Object),
|
||||
});
|
||||
|
||||
export const expectErrorResult = (
|
||||
{ type, id }: TypeIdTuple,
|
||||
error: Record<string, any>,
|
||||
overrides: Record<string, unknown> = {}
|
||||
): ExpectedErrorResult => ({
|
||||
type,
|
||||
id,
|
||||
error: { ...error, ...overrides },
|
||||
});
|
||||
export const expectErrorNotFound = (obj: TypeIdTuple, overrides?: Record<string, unknown>) =>
|
||||
expectErrorResult(obj, createGenericNotFoundErrorPayload(obj.type, obj.id), overrides);
|
||||
export const expectErrorConflict = (obj: TypeIdTuple, overrides?: Record<string, unknown>) =>
|
||||
expectErrorResult(obj, createConflictErrorPayload(obj.type, obj.id), overrides);
|
||||
export const expectErrorInvalidType = (obj: TypeIdTuple, overrides?: Record<string, unknown>) =>
|
||||
expectErrorResult(obj, createUnsupportedTypeErrorPayload(obj.type), overrides);
|
||||
|
||||
export const KIBANA_VERSION = '2.0.0';
|
||||
export const CUSTOM_INDEX_TYPE = 'customIndex';
|
||||
/** This type has namespaceType: 'agnostic'. */
|
||||
export const NAMESPACE_AGNOSTIC_TYPE = 'globalType';
|
||||
/**
|
||||
* This type has namespaceType: 'multiple'.
|
||||
*
|
||||
* That means that the object is serialized with a globally unique ID across namespaces. It also means that the object is shareable across
|
||||
* namespaces.
|
||||
**/
|
||||
export const MULTI_NAMESPACE_TYPE = 'multiNamespaceType';
|
||||
/**
|
||||
* This type has namespaceType: 'multiple-isolated'.
|
||||
*
|
||||
* That means that the object is serialized with a globally unique ID across namespaces. It also means that the object is NOT shareable
|
||||
* across namespaces. This distinction only matters when using the `collectMultiNamespaceReferences` or `updateObjectsSpaces` APIs, or
|
||||
* when using the `initialNamespaces` argument with the `create` and `bulkCreate` APIs. Those allow you to define or change what
|
||||
* namespaces an object exists in.
|
||||
*
|
||||
* In a nutshell, this type is more restrictive than `MULTI_NAMESPACE_TYPE`, so we use `MULTI_NAMESPACE_ISOLATED_TYPE` for any test cases
|
||||
* where `MULTI_NAMESPACE_TYPE` would also satisfy the test case.
|
||||
**/
|
||||
export const MULTI_NAMESPACE_ISOLATED_TYPE = 'multiNamespaceIsolatedType';
|
||||
/** This type has namespaceType: 'multiple', and it uses a custom index. */
|
||||
export const MULTI_NAMESPACE_CUSTOM_INDEX_TYPE = 'multiNamespaceTypeCustomIndex';
|
||||
export const HIDDEN_TYPE = 'hiddenType';
|
||||
export const ENCRYPTED_TYPE = 'encryptedType';
|
||||
export const MULTI_NAMESPACE_ENCRYPTED_TYPE = 'multiNamespaceEncryptedType';
|
||||
export const mockVersionProps = { _seq_no: 1, _primary_term: 1 };
|
||||
export const mockVersion = encodeHitVersion(mockVersionProps);
|
||||
export const mockTimestamp = '2017-08-14T15:49:14.886Z';
|
||||
export const mockTimestampFields = { updated_at: mockTimestamp };
|
||||
export const mockTimestampFieldsWithCreated = {
|
||||
updated_at: mockTimestamp,
|
||||
created_at: mockTimestamp,
|
||||
};
|
||||
export const REMOVE_REFS_COUNT = 42;
|
||||
|
||||
export interface TypeIdTuple {
|
||||
id: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export const mappings: SavedObjectsTypeMappingDefinition = {
|
||||
properties: {
|
||||
config: {
|
||||
properties: {
|
||||
otherField: {
|
||||
type: 'keyword',
|
||||
},
|
||||
},
|
||||
},
|
||||
'index-pattern': {
|
||||
properties: {
|
||||
someField: {
|
||||
type: 'keyword',
|
||||
},
|
||||
},
|
||||
},
|
||||
dashboard: {
|
||||
properties: {
|
||||
otherField: {
|
||||
type: 'keyword',
|
||||
},
|
||||
},
|
||||
},
|
||||
[CUSTOM_INDEX_TYPE]: {
|
||||
properties: {
|
||||
otherField: {
|
||||
type: 'keyword',
|
||||
},
|
||||
},
|
||||
},
|
||||
[NAMESPACE_AGNOSTIC_TYPE]: {
|
||||
properties: {
|
||||
yetAnotherField: {
|
||||
type: 'keyword',
|
||||
},
|
||||
},
|
||||
},
|
||||
[MULTI_NAMESPACE_TYPE]: {
|
||||
properties: {
|
||||
evenYetAnotherField: {
|
||||
type: 'keyword',
|
||||
},
|
||||
},
|
||||
},
|
||||
[MULTI_NAMESPACE_ISOLATED_TYPE]: {
|
||||
properties: {
|
||||
evenYetAnotherField: {
|
||||
type: 'keyword',
|
||||
},
|
||||
},
|
||||
},
|
||||
[MULTI_NAMESPACE_CUSTOM_INDEX_TYPE]: {
|
||||
properties: {
|
||||
evenYetAnotherField: {
|
||||
type: 'keyword',
|
||||
},
|
||||
},
|
||||
},
|
||||
[HIDDEN_TYPE]: {
|
||||
properties: {
|
||||
someField: {
|
||||
type: 'keyword',
|
||||
},
|
||||
},
|
||||
},
|
||||
[ENCRYPTED_TYPE]: {
|
||||
properties: {
|
||||
encryptedField: {
|
||||
type: 'keyword',
|
||||
},
|
||||
},
|
||||
},
|
||||
[MULTI_NAMESPACE_ENCRYPTED_TYPE]: {
|
||||
properties: {
|
||||
encryptedField: {
|
||||
type: 'keyword',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const authRecord: Record<string, AuthorizationTypeEntry> = {
|
||||
find: { authorizedSpaces: ['bar'] },
|
||||
};
|
||||
export const authMap = Object.freeze(new Map([['foo', authRecord]]));
|
||||
|
||||
export const checkAuthError = SavedObjectsErrorHelpers.createBadRequestError(
|
||||
'Failed to check authorization'
|
||||
);
|
||||
|
||||
export const enforceError = SavedObjectsErrorHelpers.decorateForbiddenError(
|
||||
new Error('Unauthorized'),
|
||||
'User lacks privileges'
|
||||
);
|
||||
|
||||
export const setupCheckAuthorized = (
|
||||
mockSecurityExt: jest.Mocked<ISavedObjectsSecurityExtension>
|
||||
) => {
|
||||
mockSecurityExt.checkAuthorization.mockResolvedValue({
|
||||
status: 'fully_authorized',
|
||||
typeMap: authMap,
|
||||
});
|
||||
};
|
||||
|
||||
export const setupCheckPartiallyAuthorized = (
|
||||
mockSecurityExt: jest.Mocked<ISavedObjectsSecurityExtension>
|
||||
) => {
|
||||
mockSecurityExt.checkAuthorization.mockResolvedValue({
|
||||
status: 'partially_authorized',
|
||||
typeMap: authMap,
|
||||
});
|
||||
};
|
||||
|
||||
export const setupCheckUnauthorized = (
|
||||
mockSecurityExt: jest.Mocked<ISavedObjectsSecurityExtension>
|
||||
) => {
|
||||
mockSecurityExt.checkAuthorization.mockResolvedValue({
|
||||
status: 'unauthorized',
|
||||
typeMap: new Map([]),
|
||||
});
|
||||
};
|
||||
|
||||
export const setupEnforceSuccess = (
|
||||
mockSecurityExt: jest.Mocked<ISavedObjectsSecurityExtension>
|
||||
) => {
|
||||
mockSecurityExt.enforceAuthorization.mockImplementation(
|
||||
(params: EnforceAuthorizationParams<string>) => {
|
||||
const { auditCallback } = params;
|
||||
auditCallback?.(undefined);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const setupEnforceFailure = (
|
||||
mockSecurityExt: jest.Mocked<ISavedObjectsSecurityExtension>
|
||||
) => {
|
||||
mockSecurityExt.enforceAuthorization.mockImplementation(
|
||||
(params: EnforceAuthorizationParams<string>) => {
|
||||
const { auditCallback } = params;
|
||||
auditCallback?.(enforceError);
|
||||
throw enforceError;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const setupRedactPassthrough = (
|
||||
mockSecurityExt: jest.Mocked<ISavedObjectsSecurityExtension>
|
||||
) => {
|
||||
mockSecurityExt.redactNamespaces.mockImplementation(({ savedObject: object }) => {
|
||||
return object;
|
||||
});
|
||||
};
|
||||
|
||||
export const createType = (
|
||||
type: string,
|
||||
parts: Partial<SavedObjectsType> = {}
|
||||
): SavedObjectsType => ({
|
||||
name: type,
|
||||
hidden: false,
|
||||
namespaceType: 'single',
|
||||
mappings: {
|
||||
properties: mappings.properties[type].properties! as SavedObjectsMappingProperties,
|
||||
},
|
||||
migrations: { '1.1.1': (doc) => doc },
|
||||
...parts,
|
||||
});
|
||||
|
||||
export const createRegistry = () => {
|
||||
const registry = new SavedObjectTypeRegistry();
|
||||
registry.registerType(createType('config'));
|
||||
registry.registerType(createType('index-pattern'));
|
||||
registry.registerType(
|
||||
createType('dashboard', {
|
||||
schemas: {
|
||||
'8.0.0-testing': schema.object({
|
||||
title: schema.maybe(schema.string()),
|
||||
otherField: schema.maybe(schema.string()),
|
||||
}),
|
||||
},
|
||||
})
|
||||
);
|
||||
registry.registerType(createType(CUSTOM_INDEX_TYPE, { indexPattern: 'custom' }));
|
||||
registry.registerType(createType(NAMESPACE_AGNOSTIC_TYPE, { namespaceType: 'agnostic' }));
|
||||
registry.registerType(createType(MULTI_NAMESPACE_TYPE, { namespaceType: 'multiple' }));
|
||||
registry.registerType(
|
||||
createType(MULTI_NAMESPACE_ISOLATED_TYPE, { namespaceType: 'multiple-isolated' })
|
||||
);
|
||||
registry.registerType(
|
||||
createType(MULTI_NAMESPACE_CUSTOM_INDEX_TYPE, {
|
||||
namespaceType: 'multiple',
|
||||
indexPattern: 'custom',
|
||||
})
|
||||
);
|
||||
registry.registerType(
|
||||
createType(HIDDEN_TYPE, {
|
||||
hidden: true,
|
||||
namespaceType: 'agnostic',
|
||||
})
|
||||
);
|
||||
registry.registerType(
|
||||
createType(ENCRYPTED_TYPE, {
|
||||
namespaceType: 'single',
|
||||
})
|
||||
);
|
||||
registry.registerType(
|
||||
createType(MULTI_NAMESPACE_ENCRYPTED_TYPE, {
|
||||
namespaceType: 'multiple',
|
||||
})
|
||||
);
|
||||
return registry;
|
||||
};
|
||||
|
||||
export const createSpySerializer = (registry: SavedObjectTypeRegistry) => {
|
||||
const spyInstance = {
|
||||
isRawSavedObject: jest.fn(),
|
||||
rawToSavedObject: jest.fn(),
|
||||
savedObjectToRaw: jest.fn(),
|
||||
generateRawId: jest.fn(),
|
||||
generateRawLegacyUrlAliasId: jest.fn(),
|
||||
trimIdPrefix: jest.fn(),
|
||||
};
|
||||
const realInstance = new SavedObjectsSerializer(registry);
|
||||
Object.keys(spyInstance).forEach((key) => {
|
||||
// @ts-expect-error no proper way to do this with typing support
|
||||
spyInstance[key].mockImplementation((...args) => realInstance[key](...args));
|
||||
});
|
||||
|
||||
return spyInstance as unknown as jest.Mocked<SavedObjectsSerializer>;
|
||||
};
|
||||
|
||||
export const createDocumentMigrator = (registry: SavedObjectTypeRegistry) => {
|
||||
return new DocumentMigrator({
|
||||
typeRegistry: registry,
|
||||
kibanaVersion: KIBANA_VERSION,
|
||||
log: loggerMock.create(),
|
||||
});
|
||||
};
|
||||
|
||||
export const getMockGetResponse = (
|
||||
registry: SavedObjectTypeRegistry,
|
||||
{
|
||||
type,
|
||||
id,
|
||||
references,
|
||||
namespace: objectNamespace,
|
||||
originId,
|
||||
}: {
|
||||
type: string;
|
||||
id: string;
|
||||
namespace?: string;
|
||||
originId?: string;
|
||||
references?: SavedObjectReference[];
|
||||
},
|
||||
namespace?: string | string[]
|
||||
) => {
|
||||
let namespaces;
|
||||
if (objectNamespace) {
|
||||
namespaces = [objectNamespace];
|
||||
} else if (namespace) {
|
||||
namespaces = Array.isArray(namespace) ? namespace : [namespace];
|
||||
} else {
|
||||
namespaces = ['default'];
|
||||
}
|
||||
const namespaceId = namespaces[0] === 'default' ? undefined : namespaces[0];
|
||||
|
||||
return {
|
||||
// NOTE: Elasticsearch returns more fields (_index, _type) but the SavedObjectsRepository method ignores these
|
||||
found: true,
|
||||
_id: `${registry.isSingleNamespace(type) && namespaceId ? `${namespaceId}:` : ''}${type}:${id}`,
|
||||
...mockVersionProps,
|
||||
_source: {
|
||||
...(registry.isSingleNamespace(type) && { namespace: namespaceId }),
|
||||
...(registry.isMultiNamespace(type) && { namespaces }),
|
||||
...(originId && { originId }),
|
||||
type,
|
||||
[type]:
|
||||
type !== ENCRYPTED_TYPE && type !== MULTI_NAMESPACE_ENCRYPTED_TYPE
|
||||
? { title: 'Testing' }
|
||||
: {
|
||||
title: 'Testing',
|
||||
attrOne: 'one',
|
||||
attrSecret: '*secret*',
|
||||
attrNotSoSecret: '*not-so-secret*',
|
||||
attrThree: 'three',
|
||||
},
|
||||
references,
|
||||
specialProperty: 'specialValue',
|
||||
...mockTimestampFields,
|
||||
} as SavedObjectsRawDocSource,
|
||||
} as estypes.GetResponse<SavedObjectsRawDocSource>;
|
||||
};
|
||||
|
||||
export const getMockMgetResponse = (
|
||||
registry: SavedObjectTypeRegistry,
|
||||
objects: Array<TypeIdTuple & { found?: boolean; initialNamespaces?: string[] }>,
|
||||
namespace?: string
|
||||
) =>
|
||||
({
|
||||
docs: objects.map((obj) =>
|
||||
obj.found === false
|
||||
? obj
|
||||
: getMockGetResponse(registry, obj, obj.initialNamespaces ?? namespace)
|
||||
),
|
||||
} as estypes.MgetResponse<SavedObjectsRawDocSource>);
|
||||
|
||||
expect.extend({
|
||||
toBeDocumentWithoutError(received, type, id) {
|
||||
if (received.type === type && received.id === id && !received.error) {
|
||||
return { message: () => `expected type and id not to match without error`, pass: true };
|
||||
} else {
|
||||
return { message: () => `expected type and id to match without error`, pass: false };
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const mockUpdateResponse = (
|
||||
client: ElasticsearchClientMock,
|
||||
type: string,
|
||||
id: string,
|
||||
options?: SavedObjectsUpdateOptions,
|
||||
namespaces?: string[],
|
||||
originId?: string
|
||||
) => {
|
||||
client.update.mockResponseOnce(
|
||||
{
|
||||
_id: `${type}:${id}`,
|
||||
...mockVersionProps,
|
||||
result: 'updated',
|
||||
// don't need the rest of the source for test purposes, just the namespace and namespaces attributes
|
||||
get: {
|
||||
_source: {
|
||||
namespaces: namespaces ?? [options?.namespace ?? 'default'],
|
||||
namespace: options?.namespace,
|
||||
|
||||
// If the existing saved object contains an originId attribute, the operation will return it in the result.
|
||||
// The originId parameter is just used for test purposes to modify the mock cluster call response.
|
||||
...(!!originId && { originId }),
|
||||
},
|
||||
},
|
||||
} as estypes.UpdateResponse,
|
||||
{ statusCode: 200 }
|
||||
);
|
||||
};
|
||||
|
||||
export const updateSuccess = async <T>(
|
||||
client: ElasticsearchClientMock,
|
||||
repository: SavedObjectsRepository,
|
||||
registry: SavedObjectTypeRegistry,
|
||||
type: string,
|
||||
id: string,
|
||||
attributes: T,
|
||||
options?: SavedObjectsUpdateOptions,
|
||||
internalOptions: {
|
||||
originId?: string;
|
||||
mockGetResponseValue?: estypes.GetResponse;
|
||||
} = {},
|
||||
objNamespaces?: string[]
|
||||
) => {
|
||||
const { mockGetResponseValue, originId } = internalOptions;
|
||||
if (registry.isMultiNamespace(type)) {
|
||||
const mockGetResponse =
|
||||
mockGetResponseValue ??
|
||||
getMockGetResponse(registry, { type, id }, objNamespaces ?? options?.namespace);
|
||||
client.get.mockResponseOnce(mockGetResponse, { statusCode: 200 });
|
||||
}
|
||||
mockUpdateResponse(client, type, id, options, objNamespaces, originId);
|
||||
const result = await repository.update(type, id, attributes, options);
|
||||
expect(client.get).toHaveBeenCalledTimes(registry.isMultiNamespace(type) ? 1 : 0);
|
||||
return result;
|
||||
};
|
||||
|
||||
export const bulkGet = async (
|
||||
repository: SavedObjectsRepository,
|
||||
objects: SavedObjectsBulkGetObject[],
|
||||
options?: SavedObjectsBaseOptions
|
||||
) =>
|
||||
repository.bulkGet(
|
||||
objects.map(({ type, id, namespaces }) => ({ type, id, namespaces })), // bulkGet only uses type, id, and optionally namespaces
|
||||
options
|
||||
);
|
||||
|
||||
export const bulkGetSuccess = async (
|
||||
client: ElasticsearchClientMock,
|
||||
repository: SavedObjectsRepository,
|
||||
registry: SavedObjectTypeRegistry,
|
||||
objects: SavedObject[],
|
||||
options?: SavedObjectsBaseOptions
|
||||
) => {
|
||||
const mockResponse = getMockMgetResponse(registry, objects, options?.namespace);
|
||||
client.mget.mockResponseOnce(mockResponse);
|
||||
const result = await bulkGet(repository, objects, options);
|
||||
expect(client.mget).toHaveBeenCalledTimes(1);
|
||||
return { result, mockResponse };
|
||||
};
|
||||
|
||||
export const expectBulkGetResult = (
|
||||
{ type, id }: TypeIdTuple,
|
||||
doc: estypes.GetGetResult<SavedObjectsRawDocSource>
|
||||
) => ({
|
||||
type,
|
||||
id,
|
||||
namespaces: doc._source!.namespaces ?? [doc._source!.namespace] ?? ['default'],
|
||||
...(doc._source!.originId && { originId: doc._source!.originId }),
|
||||
...(doc._source!.updated_at && { updated_at: doc._source!.updated_at }),
|
||||
version: encodeHitVersion(doc),
|
||||
attributes: doc._source![type],
|
||||
references: doc._source!.references || [],
|
||||
migrationVersion: doc._source!.migrationVersion,
|
||||
});
|
||||
|
||||
export const getMockBulkCreateResponse = (
|
||||
objects: SavedObjectsBulkCreateObject[],
|
||||
namespace?: string
|
||||
) => {
|
||||
return {
|
||||
errors: false,
|
||||
took: 1,
|
||||
items: objects.map(({ type, id, originId, attributes, references, migrationVersion }) => ({
|
||||
create: {
|
||||
// status: 1,
|
||||
// _index: '.kibana',
|
||||
_id: `${namespace ? `${namespace}:` : ''}${type}:${id}`,
|
||||
_source: {
|
||||
[type]: attributes,
|
||||
type,
|
||||
namespace,
|
||||
...(originId && { originId }),
|
||||
references,
|
||||
...mockTimestampFieldsWithCreated,
|
||||
migrationVersion: migrationVersion || { [type]: '1.1.1' },
|
||||
},
|
||||
...mockVersionProps,
|
||||
},
|
||||
})),
|
||||
} as unknown as estypes.BulkResponse;
|
||||
};
|
||||
|
||||
export const bulkCreateSuccess = async (
|
||||
client: ElasticsearchClientMock,
|
||||
repository: SavedObjectsRepository,
|
||||
objects: SavedObjectsBulkCreateObject[],
|
||||
options?: SavedObjectsCreateOptions
|
||||
) => {
|
||||
const mockResponse = getMockBulkCreateResponse(objects, options?.namespace);
|
||||
client.bulk.mockResponse(mockResponse);
|
||||
const result = await repository.bulkCreate(objects, options);
|
||||
return result;
|
||||
};
|
||||
|
||||
export const expectCreateResult = (obj: {
|
||||
type: string;
|
||||
namespace?: string;
|
||||
namespaces?: string[];
|
||||
}) => ({
|
||||
...obj,
|
||||
migrationVersion: { [obj.type]: '1.1.1' },
|
||||
coreMigrationVersion: KIBANA_VERSION,
|
||||
version: mockVersion,
|
||||
namespaces: obj.namespaces ?? [obj.namespace ?? 'default'],
|
||||
...mockTimestampFieldsWithCreated,
|
||||
});
|
||||
|
||||
export const getMockBulkUpdateResponse = (
|
||||
registry: SavedObjectTypeRegistry,
|
||||
objects: TypeIdTuple[],
|
||||
options?: SavedObjectsBulkUpdateOptions,
|
||||
originId?: string
|
||||
) =>
|
||||
({
|
||||
items: objects.map(({ type, id }) => ({
|
||||
update: {
|
||||
_id: `${
|
||||
registry.isSingleNamespace(type) && options?.namespace ? `${options?.namespace}:` : ''
|
||||
}${type}:${id}`,
|
||||
...mockVersionProps,
|
||||
get: {
|
||||
_source: {
|
||||
// If the existing saved object contains an originId attribute, the operation will return it in the result.
|
||||
// The originId parameter is just used for test purposes to modify the mock cluster call response.
|
||||
...(!!originId && { originId }),
|
||||
},
|
||||
},
|
||||
result: 'updated',
|
||||
},
|
||||
})),
|
||||
} as estypes.BulkResponse);
|
||||
|
||||
export const bulkUpdateSuccess = async (
|
||||
client: ElasticsearchClientMock,
|
||||
repository: SavedObjectsRepository,
|
||||
registry: SavedObjectTypeRegistry,
|
||||
objects: SavedObjectsBulkUpdateObject[],
|
||||
options?: SavedObjectsBulkUpdateOptions,
|
||||
originId?: string,
|
||||
multiNamespaceSpace?: string // the space for multi namespace objects returned by mock mget (this is only needed for space ext testing)
|
||||
) => {
|
||||
const multiNamespaceObjects = objects.filter(({ type }) => registry.isMultiNamespace(type));
|
||||
if (multiNamespaceObjects?.length) {
|
||||
const response = getMockMgetResponse(
|
||||
registry,
|
||||
multiNamespaceObjects,
|
||||
multiNamespaceSpace ?? options?.namespace
|
||||
);
|
||||
client.mget.mockResponseOnce(response);
|
||||
}
|
||||
const response = getMockBulkUpdateResponse(registry, objects, options, originId);
|
||||
client.bulk.mockResponseOnce(response);
|
||||
const result = await repository.bulkUpdate(objects, options);
|
||||
expect(client.mget).toHaveBeenCalledTimes(multiNamespaceObjects?.length ? 1 : 0);
|
||||
return result;
|
||||
};
|
||||
|
||||
export const expectUpdateResult = ({
|
||||
type,
|
||||
id,
|
||||
attributes,
|
||||
references,
|
||||
}: SavedObjectsBulkUpdateObject) => ({
|
||||
type,
|
||||
id,
|
||||
attributes,
|
||||
references,
|
||||
version: mockVersion,
|
||||
namespaces: ['default'],
|
||||
...mockTimestampFields,
|
||||
});
|
||||
|
||||
export type IGenerateSearchResultsFunction = (
|
||||
namespace?: string
|
||||
) => estypes.SearchResponse<SavedObjectsRawDocSource>;
|
||||
|
||||
export const generateIndexPatternSearchResults = (namespace?: string) => {
|
||||
return {
|
||||
took: 1,
|
||||
timed_out: false,
|
||||
_shards: {} as any,
|
||||
hits: {
|
||||
total: 4,
|
||||
hits: [
|
||||
{
|
||||
_index: '.kibana',
|
||||
_id: `${namespace ? `${namespace}:` : ''}index-pattern:logstash-*`,
|
||||
_score: 1,
|
||||
...mockVersionProps,
|
||||
_source: {
|
||||
namespace,
|
||||
originId: 'some-origin-id', // only one of the results has an originId, this is intentional to test both a positive and negative case
|
||||
type: 'index-pattern',
|
||||
...mockTimestampFields,
|
||||
'index-pattern': {
|
||||
title: 'logstash-*',
|
||||
timeFieldName: '@timestamp',
|
||||
notExpandable: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
_index: '.kibana',
|
||||
_id: `${namespace ? `${namespace}:` : ''}config:6.0.0-alpha1`,
|
||||
_score: 2,
|
||||
...mockVersionProps,
|
||||
_source: {
|
||||
namespace,
|
||||
type: 'config',
|
||||
...mockTimestampFields,
|
||||
config: {
|
||||
buildNum: 8467,
|
||||
defaultIndex: 'logstash-*',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
_index: '.kibana',
|
||||
_id: `${namespace ? `${namespace}:` : ''}index-pattern:stocks-*`,
|
||||
_score: 3,
|
||||
...mockVersionProps,
|
||||
_source: {
|
||||
namespace,
|
||||
type: 'index-pattern',
|
||||
...mockTimestampFields,
|
||||
'index-pattern': {
|
||||
title: 'stocks-*',
|
||||
timeFieldName: '@timestamp',
|
||||
notExpandable: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
_index: '.kibana',
|
||||
_id: `${NAMESPACE_AGNOSTIC_TYPE}:something`,
|
||||
_score: 4,
|
||||
...mockVersionProps,
|
||||
_source: {
|
||||
type: NAMESPACE_AGNOSTIC_TYPE,
|
||||
...mockTimestampFields,
|
||||
[NAMESPACE_AGNOSTIC_TYPE]: {
|
||||
name: 'bar',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
} as estypes.SearchResponse<SavedObjectsRawDocSource>;
|
||||
};
|
||||
|
||||
export const findSuccess = async (
|
||||
client: ElasticsearchClientMock,
|
||||
repository: SavedObjectsRepository,
|
||||
options: SavedObjectsFindOptions,
|
||||
namespace?: string,
|
||||
generateSearchResultsFunc: IGenerateSearchResultsFunction = generateIndexPatternSearchResults
|
||||
) => {
|
||||
const generatedResults = generateSearchResultsFunc(namespace);
|
||||
client.search.mockResponseOnce(generatedResults);
|
||||
const result = await repository.find(options);
|
||||
expect(mockGetSearchDsl).toHaveBeenCalledTimes(1);
|
||||
expect(client.search).toHaveBeenCalledTimes(1);
|
||||
return { result, generatedResults };
|
||||
};
|
||||
|
||||
export const deleteSuccess = async (
|
||||
client: ElasticsearchClientMock,
|
||||
repository: SavedObjectsRepository,
|
||||
registry: SavedObjectTypeRegistry,
|
||||
type: string,
|
||||
id: string,
|
||||
options?: SavedObjectsDeleteOptions,
|
||||
internalOptions: { mockGetResponseValue?: estypes.GetResponse } = {}
|
||||
) => {
|
||||
const { mockGetResponseValue } = internalOptions;
|
||||
if (registry.isMultiNamespace(type)) {
|
||||
const mockGetResponse =
|
||||
mockGetResponseValue ?? getMockGetResponse(registry, { type, id }, options?.namespace);
|
||||
client.get.mockResponseOnce(mockGetResponse);
|
||||
}
|
||||
client.delete.mockResponseOnce({
|
||||
result: 'deleted',
|
||||
} as estypes.DeleteResponse);
|
||||
const result = await repository.delete(type, id, options);
|
||||
expect(client.get).toHaveBeenCalledTimes(registry.isMultiNamespace(type) ? 1 : 0);
|
||||
return result;
|
||||
};
|
||||
|
||||
export const removeReferencesToSuccess = async (
|
||||
client: ElasticsearchClientMock,
|
||||
repository: SavedObjectsRepository,
|
||||
type: string,
|
||||
id: string,
|
||||
options = {},
|
||||
updatedCount = REMOVE_REFS_COUNT
|
||||
) => {
|
||||
client.updateByQuery.mockResponseOnce({
|
||||
updated: updatedCount,
|
||||
});
|
||||
return await repository.removeReferencesTo(type, id, options);
|
||||
};
|
||||
|
||||
export const checkConflicts = async (
|
||||
repository: SavedObjectsRepository,
|
||||
objects: TypeIdTuple[],
|
||||
options?: SavedObjectsBaseOptions
|
||||
) =>
|
||||
repository.checkConflicts(
|
||||
objects.map(({ type, id }) => ({ type, id })), // checkConflicts only uses type and id
|
||||
options
|
||||
);
|
||||
|
||||
export const checkConflictsSuccess = async (
|
||||
client: ElasticsearchClientMock,
|
||||
repository: SavedObjectsRepository,
|
||||
registry: SavedObjectTypeRegistry,
|
||||
objects: TypeIdTuple[],
|
||||
options?: SavedObjectsBaseOptions
|
||||
) => {
|
||||
const response = getMockMgetResponse(registry, objects, options?.namespace);
|
||||
client.mget.mockResolvedValue(
|
||||
elasticsearchClientMock.createSuccessTransportRequestPromise(response)
|
||||
);
|
||||
const result = await checkConflicts(repository, objects, options);
|
||||
expect(client.mget).toHaveBeenCalledTimes(1);
|
||||
return result;
|
||||
};
|
||||
|
||||
export const getSuccess = async (
|
||||
client: ElasticsearchClientMock,
|
||||
repository: SavedObjectsRepository,
|
||||
registry: SavedObjectTypeRegistry,
|
||||
type: string,
|
||||
id: string,
|
||||
options?: SavedObjectsBaseOptions,
|
||||
originId?: string,
|
||||
objNamespaces?: string[]
|
||||
) => {
|
||||
const response = getMockGetResponse(
|
||||
registry,
|
||||
{
|
||||
type,
|
||||
id,
|
||||
// "includeOriginId" is not an option for the operation; however, if the existing saved object contains an originId attribute, the
|
||||
// operation will return it in the result. This flag is just used for test purposes to modify the mock cluster call response.
|
||||
originId,
|
||||
},
|
||||
objNamespaces ?? options?.namespace
|
||||
);
|
||||
client.get.mockResponseOnce(response);
|
||||
const result = await repository.get(type, id, options);
|
||||
expect(client.get).toHaveBeenCalledTimes(1);
|
||||
return result;
|
||||
};
|
||||
|
||||
export function setsAreEqual<T>(setA: Set<T>, setB: Set<T>) {
|
||||
return isEqual(Array(setA).sort(), Array(setB).sort());
|
||||
}
|
||||
|
||||
export function typeMapsAreEqual(mapA: Map<string, Set<string>>, mapB: Map<string, Set<string>>) {
|
||||
return (
|
||||
mapA.size === mapB.size &&
|
||||
Array.from(mapA.keys()).every((key) => setsAreEqual(mapA.get(key)!, mapB.get(key)!))
|
||||
);
|
||||
}
|
||||
|
||||
export function namespaceMapsAreEqual(
|
||||
mapA: Map<string, string[] | undefined>,
|
||||
mapB: Map<string, string[] | undefined>
|
||||
) {
|
||||
return (
|
||||
mapA.size === mapB.size &&
|
||||
Array.from(mapA.keys()).every((key) => isEqual(mapA.get(key)?.sort(), mapB.get(key)?.sort()))
|
||||
);
|
||||
}
|
||||
|
||||
export const getMockEsBulkDeleteResponse = (
|
||||
registry: SavedObjectTypeRegistry,
|
||||
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);
|
||||
|
||||
export const bulkDeleteSuccess = async (
|
||||
client: ElasticsearchClientMock,
|
||||
repository: SavedObjectsRepository,
|
||||
registry: SavedObjectTypeRegistry,
|
||||
objects: SavedObjectsBulkDeleteObject[] = [],
|
||||
options?: SavedObjectsBulkDeleteOptions,
|
||||
internalOptions: {
|
||||
mockMGetResponseObjects?: Array<{
|
||||
initialNamespaces: string[] | undefined;
|
||||
type: string;
|
||||
id: string;
|
||||
}>;
|
||||
} = {}
|
||||
) => {
|
||||
const multiNamespaceObjects = objects.filter(({ type }) => {
|
||||
return registry.isMultiNamespace(type);
|
||||
});
|
||||
|
||||
const { mockMGetResponseObjects } = internalOptions;
|
||||
if (multiNamespaceObjects.length > 0) {
|
||||
const mockedMGetResponse = mockMGetResponseObjects
|
||||
? getMockMgetResponse(registry, mockMGetResponseObjects, options?.namespace)
|
||||
: getMockMgetResponse(registry, multiNamespaceObjects, options?.namespace);
|
||||
client.mget.mockResponseOnce(mockedMGetResponse);
|
||||
}
|
||||
const mockedEsBulkDeleteResponse = getMockEsBulkDeleteResponse(registry, objects, options);
|
||||
|
||||
client.bulk.mockResponseOnce(mockedEsBulkDeleteResponse);
|
||||
const result = await repository.bulkDelete(objects, options);
|
||||
|
||||
expect(client.mget).toHaveBeenCalledTimes(multiNamespaceObjects?.length ? 1 : 0);
|
||||
return result;
|
||||
};
|
||||
|
||||
export const createBulkDeleteSuccessStatus = ({ type, id }: { type: string; id: string }) => ({
|
||||
type,
|
||||
id,
|
||||
success: true,
|
||||
});
|
|
@ -10,4 +10,5 @@ export {
|
|||
savedObjectsClientMock,
|
||||
savedObjectsRepositoryMock,
|
||||
savedObjectsClientProviderMock,
|
||||
savedObjectsExtensionsMock,
|
||||
} from './src';
|
||||
|
|
|
@ -9,3 +9,4 @@
|
|||
export { savedObjectsClientMock } from './saved_objects_client.mock';
|
||||
export { savedObjectsRepositoryMock } from './repository.mock';
|
||||
export { savedObjectsClientProviderMock } from './scoped_client_provider.mock';
|
||||
export { savedObjectsExtensionsMock } from './saved_objects_extensions.mock';
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { loggerMock, MockedLogger } from '@kbn/logging-mocks';
|
||||
import { loggerMock, type MockedLogger } from '@kbn/logging-mocks';
|
||||
import type {
|
||||
SavedObjectsClientContract,
|
||||
ISavedObjectsRepository,
|
||||
|
|
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* 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 {
|
||||
ISavedObjectsEncryptionExtension,
|
||||
ISavedObjectsSecurityExtension,
|
||||
ISavedObjectsSpacesExtension,
|
||||
SavedObjectsExtensions,
|
||||
} from '@kbn/core-saved-objects-server';
|
||||
|
||||
const createEncryptionExtension = (): jest.Mocked<ISavedObjectsEncryptionExtension> => ({
|
||||
isEncryptableType: jest.fn(),
|
||||
decryptOrStripResponseAttributes: jest.fn(),
|
||||
encryptAttributes: jest.fn(),
|
||||
});
|
||||
|
||||
const createSecurityExtension = (): jest.Mocked<ISavedObjectsSecurityExtension> => ({
|
||||
checkAuthorization: jest.fn(),
|
||||
enforceAuthorization: jest.fn(),
|
||||
addAuditEvent: jest.fn(),
|
||||
redactNamespaces: jest.fn(),
|
||||
});
|
||||
|
||||
const createSpacesExtension = (): jest.Mocked<ISavedObjectsSpacesExtension> => ({
|
||||
getCurrentNamespace: jest.fn(),
|
||||
getSearchableNamespaces: jest.fn(),
|
||||
});
|
||||
|
||||
const create = (): jest.Mocked<SavedObjectsExtensions> => ({
|
||||
encryptionExtension: createEncryptionExtension(),
|
||||
securityExtension: createSecurityExtension(),
|
||||
spacesExtension: createSpacesExtension(),
|
||||
});
|
||||
|
||||
export const savedObjectsExtensionsMock = {
|
||||
create,
|
||||
createEncryptionExtension,
|
||||
createSecurityExtension,
|
||||
createSpacesExtension,
|
||||
};
|
|
@ -9,9 +9,9 @@
|
|||
import type { ISavedObjectsClientProvider } from '@kbn/core-saved-objects-api-server-internal';
|
||||
|
||||
const create = (): jest.Mocked<ISavedObjectsClientProvider> => ({
|
||||
addClientWrapperFactory: jest.fn(),
|
||||
getClient: jest.fn(),
|
||||
setClientFactory: jest.fn(),
|
||||
getExtensions: jest.fn(),
|
||||
});
|
||||
|
||||
export const savedObjectsClientProviderMock = {
|
||||
|
|
|
@ -7,7 +7,10 @@
|
|||
*/
|
||||
|
||||
export type { SavedObjectsClientContract } from './src/saved_objects_client';
|
||||
export type { ISavedObjectsRepository } from './src/saved_objects_repository';
|
||||
export type {
|
||||
ISavedObjectsRepository,
|
||||
SavedObjectsFindInternalOptions,
|
||||
} from './src/saved_objects_repository';
|
||||
export type {
|
||||
MutatingOperationRefreshSetting,
|
||||
SavedObjectsBaseOptions,
|
||||
|
|
|
@ -24,9 +24,11 @@ export interface SavedObjectsBaseOptions {
|
|||
export type MutatingOperationRefreshSetting = boolean | 'wait_for';
|
||||
|
||||
/**
|
||||
* Base return for saved object bulk operations
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface SavedObjectsBulkResponse<T = unknown> {
|
||||
/** array of saved objects */
|
||||
saved_objects: Array<SavedObject<T>>;
|
||||
}
|
||||
|
|
|
@ -12,14 +12,20 @@ import type {
|
|||
} from '@kbn/core-saved-objects-common';
|
||||
|
||||
/**
|
||||
* Object parameters for the bulk create operation
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface SavedObjectsBulkCreateObject<T = unknown> {
|
||||
/** Optional ID of the object to create (the ID is generated by default) */
|
||||
id?: string;
|
||||
/** The type of object to create */
|
||||
type: string;
|
||||
/** The attributes for the object to create */
|
||||
attributes: T;
|
||||
/** The version string for the object to create */
|
||||
version?: string;
|
||||
/** Array of references to other saved objects */
|
||||
references?: SavedObjectReference[];
|
||||
/** {@inheritDoc SavedObjectsMigrationVersion} */
|
||||
migrationVersion?: SavedObjectsMigrationVersion;
|
||||
|
|
|
@ -10,15 +10,20 @@ import type { SavedObjectError } from '@kbn/core-saved-objects-common';
|
|||
import type { MutatingOperationRefreshSetting, SavedObjectsBaseOptions } from './base';
|
||||
|
||||
/**
|
||||
* Object parameters for the bulk delete operation
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface SavedObjectsBulkDeleteObject {
|
||||
/** The type of the saved object to delete */
|
||||
type: string;
|
||||
/** The ID of the saved object to delete */
|
||||
id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for the bulk delete operation
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface SavedObjectsBulkDeleteOptions extends SavedObjectsBaseOptions {
|
||||
|
@ -31,10 +36,14 @@ export interface SavedObjectsBulkDeleteOptions extends SavedObjectsBaseOptions {
|
|||
}
|
||||
|
||||
/**
|
||||
* The per-object result of a bulk delete operation
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface SavedObjectsBulkDeleteStatus {
|
||||
/** The ID of the saved object */
|
||||
id: string;
|
||||
/** The type of the saved object */
|
||||
type: string;
|
||||
/** The status of deleting the object: true for deleted, false for error */
|
||||
success: boolean;
|
||||
|
@ -43,8 +52,11 @@ export interface SavedObjectsBulkDeleteStatus {
|
|||
}
|
||||
|
||||
/**
|
||||
* Return type of the Saved Objects `bulkDelete()` method
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface SavedObjectsBulkDeleteResponse {
|
||||
/** Array of {@link SavedObjectsBulkDeleteStatus} */
|
||||
statuses: SavedObjectsBulkDeleteStatus[];
|
||||
}
|
||||
|
|
|
@ -7,11 +7,14 @@
|
|||
*/
|
||||
|
||||
/**
|
||||
* Object parameters for the bulk get operation
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface SavedObjectsBulkGetObject {
|
||||
/** ID of the object to get */
|
||||
id: string;
|
||||
/** Type of the object to get */
|
||||
type: string;
|
||||
/** SavedObject fields to include in the response */
|
||||
fields?: string[];
|
||||
|
|
|
@ -9,18 +9,23 @@
|
|||
import type { SavedObjectsResolveResponse } from './resolve';
|
||||
|
||||
/**
|
||||
* Object parameters for the bulk resolve operation
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface SavedObjectsBulkResolveObject {
|
||||
/** ID of the object to resiolve */
|
||||
id: string;
|
||||
/** Type of the object to resolve */
|
||||
type: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return type of the Saved Objects `bulkResolve()` method.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface SavedObjectsBulkResolveResponse<T = unknown> {
|
||||
/** array of {@link SavedObjectsResolveResponse} */
|
||||
resolved_objects: Array<SavedObjectsResolveResponse<T>>;
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ import type { MutatingOperationRefreshSetting, SavedObjectsBaseOptions } from '.
|
|||
import type { SavedObjectsUpdateOptions, SavedObjectsUpdateResponse } from './update';
|
||||
|
||||
/**
|
||||
* Object parameters for the bulk update operation
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
|
@ -31,6 +32,7 @@ export interface SavedObjectsBulkUpdateObject<T = unknown>
|
|||
}
|
||||
|
||||
/**
|
||||
* Options for the saved objects bulk update operation
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
|
@ -40,9 +42,11 @@ export interface SavedObjectsBulkUpdateOptions extends SavedObjectsBaseOptions {
|
|||
}
|
||||
|
||||
/**
|
||||
* Return type of the Saved Objects `bulkUpdate()` method.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface SavedObjectsBulkUpdateResponse<T = unknown> {
|
||||
/** array of {@link SavedObjectsUpdateResponse} */
|
||||
saved_objects: Array<SavedObjectsUpdateResponse<T>>;
|
||||
}
|
||||
|
|
|
@ -9,19 +9,24 @@
|
|||
import type { SavedObjectError } from '@kbn/core-saved-objects-common';
|
||||
|
||||
/**
|
||||
* Object parameters for the check conficts operation
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface SavedObjectsCheckConflictsObject {
|
||||
/** The ID of the object to check */
|
||||
id: string;
|
||||
/** The type of the object to check */
|
||||
type: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return type of the Saved Objects `checkConflicts()` method.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface SavedObjectsCheckConflictsResponse {
|
||||
/** Array of errors (contains the conflicting object ID, type, and error details) */
|
||||
errors: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
|
|
|
@ -9,21 +9,20 @@
|
|||
import type { SavedObjectsBaseOptions } from './base';
|
||||
|
||||
/**
|
||||
* Options for the close point-in-time operation
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type SavedObjectsClosePointInTimeOptions = SavedObjectsBaseOptions;
|
||||
|
||||
/**
|
||||
* Return type of the Saved Objects `closePointInTime()` method.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface SavedObjectsClosePointInTimeResponse {
|
||||
/**
|
||||
* If true, all search contexts associated with the PIT id are
|
||||
* successfully closed.
|
||||
*/
|
||||
/** If true, all search contexts associated with the PIT id are successfully closed */
|
||||
succeeded: boolean;
|
||||
/**
|
||||
* The number of search contexts that have been successfully closed.
|
||||
*/
|
||||
/** The number of search contexts that have been successfully closed */
|
||||
num_freed: number;
|
||||
}
|
||||
|
|
|
@ -18,7 +18,9 @@ import type { SavedObjectsBaseOptions } from './base';
|
|||
* @public
|
||||
*/
|
||||
export interface SavedObjectsCollectMultiNamespaceReferencesObject {
|
||||
/** The ID of the object to collect references for */
|
||||
id: string;
|
||||
/** The type of the object to collect references for */
|
||||
type: string;
|
||||
}
|
||||
|
||||
|
@ -73,5 +75,6 @@ export interface SavedObjectReferenceWithContext {
|
|||
* @public
|
||||
*/
|
||||
export interface SavedObjectsCollectMultiNamespaceReferencesResponse {
|
||||
/** array of {@link SavedObjectReferenceWithContext} */
|
||||
objects: SavedObjectReferenceWithContext[];
|
||||
}
|
||||
|
|
|
@ -13,6 +13,7 @@ import type {
|
|||
import type { MutatingOperationRefreshSetting, SavedObjectsBaseOptions } from './base';
|
||||
|
||||
/**
|
||||
* Options for the saved objects create operation
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
|
@ -38,6 +39,7 @@ export interface SavedObjectsCreateOptions extends SavedObjectsBaseOptions {
|
|||
* field set and you want to create it again.
|
||||
*/
|
||||
coreMigrationVersion?: string;
|
||||
/** Array of references to other saved objects */
|
||||
references?: SavedObjectReference[];
|
||||
/** The Elasticsearch Refresh setting for this operation */
|
||||
refresh?: MutatingOperationRefreshSetting;
|
||||
|
|
|
@ -7,9 +7,11 @@
|
|||
*/
|
||||
|
||||
import type { SavedObjectsFindOptions, SavedObjectsFindResponse } from './find';
|
||||
import type { SavedObjectsClientContract } from '../saved_objects_client';
|
||||
import type { ISavedObjectsRepository } from '../saved_objects_repository';
|
||||
|
||||
/**
|
||||
* Options for the create point-in-time finder operation
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type SavedObjectsCreatePointInTimeFinderOptions = Omit<
|
||||
|
@ -18,21 +20,31 @@ export type SavedObjectsCreatePointInTimeFinderOptions = Omit<
|
|||
>;
|
||||
|
||||
/**
|
||||
* Point-in-time finder client.
|
||||
* Partially implements {@link ISavedObjectsRepository}
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type SavedObjectsPointInTimeFinderClient = Pick<
|
||||
SavedObjectsClientContract,
|
||||
ISavedObjectsRepository,
|
||||
'find' | 'openPointInTimeForType' | 'closePointInTime'
|
||||
>;
|
||||
|
||||
/**
|
||||
* Dependencies for the create point-in-time finder operation
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface SavedObjectsCreatePointInTimeFinderDependencies {
|
||||
/** the point-in-time finder client */
|
||||
client: SavedObjectsPointInTimeFinderClient;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
/**
|
||||
* Point-in-time finder
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface ISavedObjectsPointInTimeFinder<T, A> {
|
||||
/**
|
||||
* An async generator which wraps calls to `savedObjectsClient.find` and
|
||||
|
|
|
@ -16,30 +16,44 @@ import type { SavedObject } from '@kbn/core-saved-objects-common';
|
|||
type KueryNode = any;
|
||||
|
||||
/**
|
||||
* An object reference for use in find operation options
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface SavedObjectsFindOptionsReference {
|
||||
/** The type of the saved object */
|
||||
type: string;
|
||||
/** The ID of the saved object */
|
||||
id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Point-in-time parameters
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface SavedObjectsPitParams {
|
||||
/** The ID of point-in-time */
|
||||
id: string;
|
||||
/** Optionally specify how long ES should keep the PIT alive until the next request. Defaults to `5m`. */
|
||||
keepAlive?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for finding saved objects
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface SavedObjectsFindOptions {
|
||||
/** the type or types of objects to find */
|
||||
type: string | string[];
|
||||
/** the page of results to return */
|
||||
page?: number;
|
||||
/** the number of objects per page */
|
||||
perPage?: number;
|
||||
/** which field to sort by */
|
||||
sortField?: string;
|
||||
/** sort order, ascending or descending */
|
||||
sortOrder?: SortOrder;
|
||||
/**
|
||||
* An array of fields to include in the results
|
||||
|
@ -60,33 +74,29 @@ export interface SavedObjectsFindOptions {
|
|||
* be modified. If used in conjunction with `searchFields`, both are concatenated together.
|
||||
*/
|
||||
rootSearchFields?: string[];
|
||||
|
||||
/**
|
||||
* Search for documents having a reference to the specified objects.
|
||||
* Use `hasReferenceOperator` to specify the operator to use when searching for multiple references.
|
||||
*/
|
||||
hasReference?: SavedObjectsFindOptionsReference | SavedObjectsFindOptionsReference[];
|
||||
|
||||
/**
|
||||
* The operator to use when searching by multiple references using the `hasReference` option. Defaults to `OR`
|
||||
*/
|
||||
hasReferenceOperator?: 'AND' | 'OR';
|
||||
|
||||
/**
|
||||
* Search for documents *not* having a reference to the specified objects.
|
||||
* Use `hasNoReferenceOperator` to specify the operator to use when searching for multiple references.
|
||||
*/
|
||||
hasNoReference?: SavedObjectsFindOptionsReference | SavedObjectsFindOptionsReference[];
|
||||
|
||||
/**
|
||||
* The operator to use when searching by multiple references using the `hasNoReference` option. Defaults to `OR`
|
||||
*/
|
||||
hasNoReferenceOperator?: 'AND' | 'OR';
|
||||
|
||||
/**
|
||||
* The search operator to use with the provided filter. Defaults to `OR`
|
||||
*/
|
||||
defaultSearchOperator?: 'AND' | 'OR';
|
||||
/** filter string for the search query */
|
||||
filter?: string | KueryNode;
|
||||
/**
|
||||
* A record of aggregations to perform.
|
||||
|
@ -110,6 +120,7 @@ export interface SavedObjectsFindOptions {
|
|||
* @alpha
|
||||
*/
|
||||
aggs?: Record<string, AggregationsAggregationContainer>;
|
||||
/** array of namespaces to search */
|
||||
namespaces?: string[];
|
||||
/**
|
||||
* This map defines each type to search for, and the namespace(s) to search for the type in; this is only intended to be used by a saved
|
||||
|
@ -128,6 +139,7 @@ export interface SavedObjectsFindOptions {
|
|||
}
|
||||
|
||||
/**
|
||||
* Results for a find operation
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
|
@ -176,10 +188,16 @@ export interface SavedObjectsFindResult<T = unknown> extends SavedObject<T> {
|
|||
* @public
|
||||
*/
|
||||
export interface SavedObjectsFindResponse<T = unknown, A = unknown> {
|
||||
/** aggregations from the search query response */
|
||||
aggregations?: A;
|
||||
/** array of found saved objects */
|
||||
saved_objects: Array<SavedObjectsFindResult<T>>;
|
||||
/** the total number of objects */
|
||||
total: number;
|
||||
/** the number of objects per page */
|
||||
per_page: number;
|
||||
/** the current page number */
|
||||
page: number;
|
||||
/** the point-in-time ID (undefined if not applicable) */
|
||||
pit_id?: string;
|
||||
}
|
||||
|
|
|
@ -10,6 +10,8 @@ import type { SavedObjectsMigrationVersion } from '@kbn/core-saved-objects-commo
|
|||
import type { MutatingOperationRefreshSetting, SavedObjectsBaseOptions } from './base';
|
||||
|
||||
/**
|
||||
* Options for the increment counter operation
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface SavedObjectsIncrementCounterOptions<Attributes = unknown>
|
||||
|
@ -33,6 +35,8 @@ export interface SavedObjectsIncrementCounterOptions<Attributes = unknown>
|
|||
}
|
||||
|
||||
/**
|
||||
* The field and increment details for the increment counter operation
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface SavedObjectsIncrementCounterField {
|
||||
|
|
|
@ -7,6 +7,8 @@
|
|||
*/
|
||||
|
||||
/**
|
||||
* Options for the open point-in-time for type operation
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface SavedObjectsOpenPointInTimeOptions {
|
||||
|
@ -30,11 +32,11 @@ export interface SavedObjectsOpenPointInTimeOptions {
|
|||
}
|
||||
|
||||
/**
|
||||
* Return type of the Saved Objects `openPointInTimeForType()` method.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface SavedObjectsOpenPointInTimeResponse {
|
||||
/**
|
||||
* PIT ID returned from ES.
|
||||
*/
|
||||
/** PIT ID returned from ES */
|
||||
id: string;
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ import type { SavedObjectReference, SavedObject } from '@kbn/core-saved-objects-
|
|||
import type { MutatingOperationRefreshSetting, SavedObjectsBaseOptions } from './base';
|
||||
|
||||
/**
|
||||
* Options for the saved objects update operation
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
|
@ -23,7 +24,7 @@ export interface SavedObjectsUpdateOptions<Attributes = unknown> extends SavedOb
|
|||
references?: SavedObjectReference[];
|
||||
/** The Elasticsearch Refresh setting for this operation */
|
||||
refresh?: MutatingOperationRefreshSetting;
|
||||
/** If specified, will be used to perform an upsert if the document doesn't exist */
|
||||
/** If specified, will be used to perform an upsert if the object doesn't exist */
|
||||
upsert?: Attributes;
|
||||
/**
|
||||
* The Elasticsearch `retry_on_conflict` setting for this operation.
|
||||
|
@ -33,11 +34,14 @@ export interface SavedObjectsUpdateOptions<Attributes = unknown> extends SavedOb
|
|||
}
|
||||
|
||||
/**
|
||||
* Return type of the Saved Objects `update()` method.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface SavedObjectsUpdateResponse<T = unknown>
|
||||
extends Omit<SavedObject<T>, 'attributes' | 'references'> {
|
||||
/** partial attributes of the saved object */
|
||||
attributes: Partial<T>;
|
||||
/** optionally included references to other saved objects */
|
||||
references: SavedObjectReference[] | undefined;
|
||||
}
|
||||
|
|
|
@ -49,6 +49,7 @@ export interface SavedObjectsUpdateObjectsSpacesOptions extends SavedObjectsBase
|
|||
* @public
|
||||
*/
|
||||
export interface SavedObjectsUpdateObjectsSpacesResponse {
|
||||
/** array of {@link SavedObjectsUpdateObjectsSpacesResponseObject} */
|
||||
objects: SavedObjectsUpdateObjectsSpacesResponseObject[];
|
||||
}
|
||||
|
||||
|
|
|
@ -112,9 +112,10 @@ export interface SavedObjectsClientContract {
|
|||
/**
|
||||
* Persists a SavedObject
|
||||
*
|
||||
* @param type
|
||||
* @param attributes
|
||||
* @param options
|
||||
* @param type - the type of saved object to create
|
||||
* @param attributes - attributes for the saved object
|
||||
* @param options {@link SavedObjectsCreateOptions} - options for the create operation
|
||||
* @returns the created saved object
|
||||
*/
|
||||
create<T = unknown>(
|
||||
type: string,
|
||||
|
@ -125,8 +126,9 @@ export interface SavedObjectsClientContract {
|
|||
/**
|
||||
* Persists multiple documents batched together as a single request
|
||||
*
|
||||
* @param objects
|
||||
* @param options
|
||||
* @param objects - array of objects to create (contains type, attributes, and optional fields )
|
||||
* @param options {@link SavedObjectsCreateOptions} - options for the bulk create operation
|
||||
* @returns the {@link SavedObjectsBulkResponse}
|
||||
*/
|
||||
bulkCreate<T = unknown>(
|
||||
objects: Array<SavedObjectsBulkCreateObject<T>>,
|
||||
|
@ -137,8 +139,9 @@ export interface SavedObjectsClientContract {
|
|||
* Check what conflicts will result when creating a given array of saved objects. This includes "unresolvable conflicts", which are
|
||||
* multi-namespace objects that exist in a different namespace; such conflicts cannot be resolved/overwritten.
|
||||
*
|
||||
* @param objects
|
||||
* @param options
|
||||
* @param objects - array of objects to check (contains ID and type)
|
||||
* @param options {@link SavedObjectsBaseOptions} - options for the check conflicts operation
|
||||
* @returns the {@link SavedObjectsCheckConflictsResponse}
|
||||
*/
|
||||
checkConflicts(
|
||||
objects: SavedObjectsCheckConflictsObject[],
|
||||
|
@ -148,17 +151,18 @@ export interface SavedObjectsClientContract {
|
|||
/**
|
||||
* Deletes a SavedObject
|
||||
*
|
||||
* @param type
|
||||
* @param id
|
||||
* @param options
|
||||
* @param type - the type of saved object to delete
|
||||
* @param id - the ID of the saved object to delete
|
||||
* @param options {@link SavedObjectsDeleteOptions} - options for the delete operation
|
||||
*/
|
||||
delete(type: string, id: string, options?: SavedObjectsDeleteOptions): Promise<{}>;
|
||||
|
||||
/**
|
||||
* Deletes multiple SavedObjects batched together as a single request
|
||||
*
|
||||
* @param objects
|
||||
* @param options
|
||||
* @param objects - array of objects to delete (contains ID and type)
|
||||
* @param options {@link SavedObjectsBulkDeleteOptions} - options for the bulk delete operation
|
||||
* @returns the {@link SavedObjectsBulkDeleteResponse}
|
||||
*/
|
||||
bulkDelete(
|
||||
objects: SavedObjectsBulkDeleteObject[],
|
||||
|
@ -167,7 +171,8 @@ export interface SavedObjectsClientContract {
|
|||
/**
|
||||
* Find all SavedObjects matching the search query
|
||||
*
|
||||
* @param options
|
||||
* @param options {@link SavedObjectsFindOptions} - options for the find operation
|
||||
* @returns the {@link SavedObjectsFindResponse}
|
||||
*/
|
||||
find<T = unknown, A = unknown>(
|
||||
options: SavedObjectsFindOptions
|
||||
|
@ -176,7 +181,9 @@ export interface SavedObjectsClientContract {
|
|||
/**
|
||||
* Returns an array of objects by id
|
||||
*
|
||||
* @param objects - an array of ids, or an array of objects containing id, type and optionally fields
|
||||
* @param objects - array of objects to get (contains id, type, and optional fields)
|
||||
* @param options {@link SavedObjectsBaseOptions} - options for the bulk get operation
|
||||
* @returns the {@link SavedObjectsBulkResponse}
|
||||
* @example
|
||||
*
|
||||
* bulkGet([
|
||||
|
@ -192,9 +199,9 @@ export interface SavedObjectsClientContract {
|
|||
/**
|
||||
* Retrieves a single object
|
||||
*
|
||||
* @param type - The type of SavedObject to retrieve
|
||||
* @param id - The ID of the SavedObject to retrieve
|
||||
* @param options
|
||||
* @param type - The type of the object to retrieve
|
||||
* @param id - The ID of the object to retrieve
|
||||
* @param options {@link SavedObjectsBaseOptions} - options for the get operation
|
||||
*/
|
||||
get<T = unknown>(
|
||||
type: string,
|
||||
|
@ -205,7 +212,9 @@ export interface SavedObjectsClientContract {
|
|||
/**
|
||||
* Resolves an array of objects by id, using any legacy URL aliases if they exist
|
||||
*
|
||||
* @param objects - an array of objects containing id, type
|
||||
* @param objects - an array of objects to resolve (contains id and type)
|
||||
* @param options {@link SavedObjectsBaseOptions} - options for the bulk resolve operation
|
||||
* @returns the {@link SavedObjectsBulkResolveResponse}
|
||||
* @example
|
||||
*
|
||||
* bulkResolve([
|
||||
|
@ -227,7 +236,8 @@ export interface SavedObjectsClientContract {
|
|||
*
|
||||
* @param type - The type of SavedObject to retrieve
|
||||
* @param id - The ID of the SavedObject to retrieve
|
||||
* @param options
|
||||
* @param options {@link SavedObjectsBaseOptions} - options for the resolve operation
|
||||
* @returns the {@link SavedObjectsResolveResponse}
|
||||
*/
|
||||
resolve<T = unknown>(
|
||||
type: string,
|
||||
|
@ -238,9 +248,11 @@ export interface SavedObjectsClientContract {
|
|||
/**
|
||||
* Updates an SavedObject
|
||||
*
|
||||
* @param type
|
||||
* @param id
|
||||
* @param options
|
||||
* @param type - The type of SavedObject to update
|
||||
* @param id - The ID of the SavedObject to update
|
||||
* @param attributes - Attributes to update
|
||||
* @param options {@link SavedObjectsUpdateOptions} - options for the update operation
|
||||
* @returns the {@link SavedObjectsUpdateResponse}
|
||||
*/
|
||||
update<T = unknown>(
|
||||
type: string,
|
||||
|
@ -252,7 +264,9 @@ export interface SavedObjectsClientContract {
|
|||
/**
|
||||
* Bulk Updates multiple SavedObject at once
|
||||
*
|
||||
* @param objects
|
||||
* @param objects - array of objects to update (contains ID, type, attributes, and optional namespace)
|
||||
* @param options {@link SavedObjectsBulkUpdateOptions} - options for the bulkUpdate operation
|
||||
* @returns the {@link SavedObjectsBulkUpdateResponse}
|
||||
*/
|
||||
bulkUpdate<T = unknown>(
|
||||
objects: Array<SavedObjectsBulkUpdateObject<T>>,
|
||||
|
@ -261,6 +275,11 @@ export interface SavedObjectsClientContract {
|
|||
|
||||
/**
|
||||
* Updates all objects containing a reference to the given {type, id} tuple to remove the said reference.
|
||||
*
|
||||
* @param type - the type of the object to remove references to
|
||||
* @param id - the ID of the object to remove references to
|
||||
* @param options {@link SavedObjectsRemoveReferencesToOptions} - options for the remove references opertion
|
||||
* @returns the {@link SavedObjectsRemoveReferencesToResponse}
|
||||
*/
|
||||
removeReferencesTo(
|
||||
type: string,
|
||||
|
@ -275,6 +294,10 @@ export interface SavedObjectsClientContract {
|
|||
*
|
||||
* Only use this API if you have an advanced use case that's not solved by the
|
||||
* {@link SavedObjectsClient.createPointInTimeFinder} method.
|
||||
*
|
||||
* @param type - the type or array of types
|
||||
* @param options {@link SavedObjectsOpenPointInTimeOptions} - options for the open PIT for type operation
|
||||
* @returns the {@link SavedObjectsOpenPointInTimeResponse}
|
||||
*/
|
||||
openPointInTimeForType(
|
||||
type: string | string[],
|
||||
|
@ -288,6 +311,10 @@ export interface SavedObjectsClientContract {
|
|||
*
|
||||
* Only use this API if you have an advanced use case that's not solved by the
|
||||
* {@link SavedObjectsClient.createPointInTimeFinder} method.
|
||||
*
|
||||
* @param id - the ID of the PIT to close
|
||||
* @param options {@link SavedObjectsClosePointInTimeOptions} - options for the close PIT operation
|
||||
* @returns the {@link SavedObjectsClosePointInTimeResponse}
|
||||
*/
|
||||
closePointInTime(
|
||||
id: string,
|
||||
|
@ -338,6 +365,10 @@ export interface SavedObjectsClientContract {
|
|||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @param findOptions {@link SavedObjectsCreatePointInTimeFinderOptions} - options for the create PIT finder operation
|
||||
* @param dependencies {@link SavedObjectsCreatePointInTimeFinderDependencies} - dependencies for the create PIT fimder operation
|
||||
* @returns the created PIT finder
|
||||
*/
|
||||
createPointInTimeFinder<T = unknown, A = unknown>(
|
||||
findOptions: SavedObjectsCreatePointInTimeFinderOptions,
|
||||
|
@ -347,8 +378,9 @@ export interface SavedObjectsClientContract {
|
|||
/**
|
||||
* Gets all references and transitive references of the listed objects. Ignores any object that is not a multi-namespace type.
|
||||
*
|
||||
* @param objects
|
||||
* @param options
|
||||
* @param objects - array of objects to collect references for (contains ID and type)
|
||||
* @param options {@link SavedObjectsCollectMultiNamespaceReferencesOptions} - options for the collect multi namespace references operation
|
||||
* @returns the {@link SavedObjectsCollectMultiNamespaceReferencesResponse}
|
||||
*/
|
||||
collectMultiNamespaceReferences(
|
||||
objects: SavedObjectsCollectMultiNamespaceReferencesObject[],
|
||||
|
@ -358,10 +390,11 @@ export interface SavedObjectsClientContract {
|
|||
/**
|
||||
* Updates one or more objects to add and/or remove them from specified spaces.
|
||||
*
|
||||
* @param objects
|
||||
* @param spacesToAdd
|
||||
* @param spacesToRemove
|
||||
* @param options
|
||||
* @param objects - array of objects to update (contains ID, type, and optional internal-only parameters)
|
||||
* @param spacesToAdd - array of spaces each object should be included in
|
||||
* @param spacesToRemove - array of spaces each object should not be included in
|
||||
* @param options {@link SavedObjectsUpdateObjectsSpacesOptions} - options for the update spaces operation
|
||||
* @returns the {@link SavedObjectsUpdateObjectsSpacesResponse}
|
||||
*/
|
||||
updateObjectsSpaces(
|
||||
objects: SavedObjectsUpdateObjectsSpacesObject[],
|
||||
|
|
|
@ -49,6 +49,19 @@ import type {
|
|||
SavedObjectsBulkDeleteResponse,
|
||||
} from './apis';
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export interface SavedObjectsFindInternalOptions {
|
||||
/** This is used for calls internal to the SO domain that need to use a PIT finder but want to prevent extensions from functioning.
|
||||
* We use the SOR's PointInTimeFinder internally when searching for aliases and shared origins for saved objects, but we
|
||||
* need to disable the extensions for that to function correctly.
|
||||
* Before, when we had SOC wrappers, the SOR's PointInTimeFinder did not have any of the wrapper functionality applied.
|
||||
* This disableExtensions internal option preserves that behavior.
|
||||
*/
|
||||
disableExtensions?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The savedObjects repository contract.
|
||||
*
|
||||
|
@ -58,15 +71,15 @@ export interface ISavedObjectsRepository {
|
|||
/**
|
||||
* Persists an object
|
||||
*
|
||||
* @param {string} type
|
||||
* @param {object} attributes
|
||||
* @param {object} [options={}]
|
||||
* @param {string} type - the type of object to create
|
||||
* @param {object} attributes - the attributes for the object to be created
|
||||
* @param {object} [options={}] {@link SavedObjectsCreateOptions} - options for the create operation
|
||||
* @property {string} [options.id] - force id on creation, not recommended
|
||||
* @property {boolean} [options.overwrite=false]
|
||||
* @property {object} [options.migrationVersion=undefined]
|
||||
* @property {string} [options.namespace]
|
||||
* @property {array} [options.references=[]] - [{ name, type, id }]
|
||||
* @returns {promise} - { id, type, version, attributes }
|
||||
* @returns {promise} the created saved object { id, type, version, attributes }
|
||||
*/
|
||||
create<T = unknown>(
|
||||
type: string,
|
||||
|
@ -77,11 +90,11 @@ export interface ISavedObjectsRepository {
|
|||
/**
|
||||
* Creates multiple documents at once
|
||||
*
|
||||
* @param {array} objects - [{ type, id, attributes, references, migrationVersion }]
|
||||
* @param {object} [options={}]
|
||||
* @param {array} objects - array of objects to create [{ type, attributes, ... }]
|
||||
* @param {object} [options={}] {@link SavedObjectsCreateOptions} - options for the bulk create operation
|
||||
* @property {boolean} [options.overwrite=false] - overwrites existing documents
|
||||
* @property {string} [options.namespace]
|
||||
* @returns {promise} - {saved_objects: [[{ id, type, version, references, attributes, error: { message } }]}
|
||||
* @returns {promise} - {saved_objects: [[{ id, type, version, references, attributes, error: { message } }]}
|
||||
*/
|
||||
bulkCreate<T = unknown>(
|
||||
objects: Array<SavedObjectsBulkCreateObject<T>>,
|
||||
|
@ -91,6 +104,10 @@ export interface ISavedObjectsRepository {
|
|||
/**
|
||||
* Check what conflicts will result when creating a given array of saved objects. This includes "unresolvable conflicts", which are
|
||||
* multi-namespace objects that exist in a different namespace; such conflicts cannot be resolved/overwritten.
|
||||
*
|
||||
* @param {array} objects - array of objects to check for conflicts [{ id, type }]
|
||||
* @param {object} options {@link SavedObjectsBaseOptions} - options for the check conflict operation
|
||||
* @returns {promise} - {errors: [{ id, type, error: { message } }]}
|
||||
*/
|
||||
checkConflicts(
|
||||
objects: SavedObjectsCheckConflictsObject[],
|
||||
|
@ -100,18 +117,17 @@ export interface ISavedObjectsRepository {
|
|||
/**
|
||||
* Deletes an object
|
||||
*
|
||||
* @param {string} type
|
||||
* @param {string} id
|
||||
* @param {object} [options={}]
|
||||
* @param {string} type - the type of the object to delete
|
||||
* @param {string} id - the id of the object to delete
|
||||
* @param {object} [options={}] {@link SavedObjectsDeleteOptions} - options for the delete operation
|
||||
* @property {string} [options.namespace]
|
||||
* @returns {promise}
|
||||
*/
|
||||
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={}]
|
||||
* @param {array} objects - an array of objects to delete (contains id and type)
|
||||
* @param {object} [options={}] {@link SavedObjectsBulkDeleteOptions} - options for the bulk delete operation
|
||||
* @returns {promise} - { statuses: [{ id, type, success, error: { message } }] }
|
||||
*/
|
||||
bulkDelete(
|
||||
|
@ -122,7 +138,8 @@ export interface ISavedObjectsRepository {
|
|||
/**
|
||||
* Deletes all objects from the provided namespace.
|
||||
*
|
||||
* @param {string} namespace
|
||||
* @param {string} namespace - the namespace in which to delete all objects
|
||||
* @param {object} options {@link SavedObjectsDeleteByNamespaceOptions} - options for the delete by namespace operation
|
||||
* @returns {promise} - { took, timed_out, total, deleted, batches, version_conflicts, noops, retries, failures }
|
||||
*/
|
||||
deleteByNamespace(
|
||||
|
@ -131,7 +148,9 @@ export interface ISavedObjectsRepository {
|
|||
): Promise<any>;
|
||||
|
||||
/**
|
||||
* @param {object} [options={}]
|
||||
* Find saved objects by query
|
||||
*
|
||||
* @param {object} [options={}] {@link SavedObjectsFindOptions} - options for the find operation
|
||||
* @property {(string|Array<string>)} [options.type]
|
||||
* @property {string} [options.search]
|
||||
* @property {string} [options.defaultSearchOperator]
|
||||
|
@ -147,17 +166,19 @@ export interface ISavedObjectsRepository {
|
|||
* @property {object} [options.hasReference] - { type, id }
|
||||
* @property {string} [options.pit]
|
||||
* @property {string} [options.preference]
|
||||
* @param {object} internalOptions {@link SavedObjectsFindInternalOptions} - internal-only options for the find operation
|
||||
* @returns {promise} - { saved_objects: [{ id, type, version, attributes }], total, per_page, page }
|
||||
*/
|
||||
find<T = unknown, A = unknown>(
|
||||
options: SavedObjectsFindOptions
|
||||
options: SavedObjectsFindOptions,
|
||||
internalOptions?: SavedObjectsFindInternalOptions
|
||||
): Promise<SavedObjectsFindResponse<T, A>>;
|
||||
|
||||
/**
|
||||
* Returns an array of objects by id
|
||||
*
|
||||
* @param {array} objects - an array of objects containing id, type and optionally fields
|
||||
* @param {object} [options={}]
|
||||
* @param {object} [options={}] {@link SavedObjectsBaseOptions} - options for the bulk get operation
|
||||
* @property {string} [options.namespace]
|
||||
* @returns {promise} - { saved_objects: [{ id, type, version, attributes }] }
|
||||
* @example
|
||||
|
@ -176,7 +197,7 @@ export interface ISavedObjectsRepository {
|
|||
* Resolves an array of objects by id, using any legacy URL aliases if they exist
|
||||
*
|
||||
* @param {array} objects - an array of objects containing id, type
|
||||
* @param {object} [options={}]
|
||||
* @param {object} [options={}] {@link SavedObjectsBaseOptions} - options for the bulk resolve operation
|
||||
* @property {string} [options.namespace]
|
||||
* @returns {promise} - { resolved_objects: [{ saved_object, outcome }] }
|
||||
* @example
|
||||
|
@ -194,9 +215,9 @@ export interface ISavedObjectsRepository {
|
|||
/**
|
||||
* Gets a single object
|
||||
*
|
||||
* @param {string} type
|
||||
* @param {string} id
|
||||
* @param {object} [options={}]
|
||||
* @param {string} type - the type of the object to get
|
||||
* @param {string} id - the ID of the object to get
|
||||
* @param {object} [options={}] {@link SavedObjectsBaseOptions} - options for the get operation
|
||||
* @property {string} [options.namespace]
|
||||
* @returns {promise} - { id, type, version, attributes }
|
||||
*/
|
||||
|
@ -209,9 +230,9 @@ export interface ISavedObjectsRepository {
|
|||
/**
|
||||
* Resolves a single object, using any legacy URL alias if it exists
|
||||
*
|
||||
* @param {string} type
|
||||
* @param {string} id
|
||||
* @param {object} [options={}]
|
||||
* @param {string} type - the type of the object to resolve
|
||||
* @param {string} id - the id of the object to resolve
|
||||
* @param {object} [options={}] {@link SavedObjectsBaseOptions} - options for the resolve operation
|
||||
* @property {string} [options.namespace]
|
||||
* @returns {promise} - { saved_object, outcome }
|
||||
*/
|
||||
|
@ -224,13 +245,14 @@ export interface ISavedObjectsRepository {
|
|||
/**
|
||||
* Updates an object
|
||||
*
|
||||
* @param {string} type
|
||||
* @param {string} id
|
||||
* @param {object} [options={}]
|
||||
* @param {string} type - the type of the object to update
|
||||
* @param {string} id - the ID of the object to update
|
||||
* @param {object} attributes - attributes to update
|
||||
* @param {object} [options={}] {@link SavedObjectsUpdateOptions} - options for the update operation
|
||||
* @property {string} options.version - ensures version matches that of persisted object
|
||||
* @property {string} [options.namespace]
|
||||
* @property {array} [options.references] - [{ name, type, id }]
|
||||
* @returns {promise}
|
||||
* @returns {promise} - updated saved object
|
||||
*/
|
||||
update<T = unknown>(
|
||||
type: string,
|
||||
|
@ -243,7 +265,9 @@ export interface ISavedObjectsRepository {
|
|||
* Gets all references and transitive references of the given objects. Ignores any object and/or reference that is not a multi-namespace
|
||||
* type.
|
||||
*
|
||||
* @param objects The objects to get the references for.
|
||||
* @param {array} objects - The objects to get the references for (contains type and ID)
|
||||
* @param {object} options {@link SavedObjectsCollectMultiNamespaceReferencesOptions} - the options for the operation
|
||||
* @returns {promise} - {@link SavedObjectsCollectMultiNamespaceReferencesResponse} { objects: [{ type, id, spaces, inboundReferences, ... }] }
|
||||
*/
|
||||
collectMultiNamespaceReferences(
|
||||
objects: SavedObjectsCollectMultiNamespaceReferencesObject[],
|
||||
|
@ -253,10 +277,11 @@ export interface ISavedObjectsRepository {
|
|||
/**
|
||||
* Updates one or more objects to add and/or remove them from specified spaces.
|
||||
*
|
||||
* @param objects
|
||||
* @param spacesToAdd
|
||||
* @param spacesToRemove
|
||||
* @param options
|
||||
* @param {array} objects - array of objects to update (contains type, ID, and optional parameters)
|
||||
* @param {array} spacesToAdd - array of spaces in which the objects should be added
|
||||
* @param {array} spacesToRemove - array of spaces from which the objects should be removed
|
||||
* @param {object} options {@link SavedObjectsUpdateObjectsSpacesOptions} - options for the operation
|
||||
* @returns {promise} - { objects: [{ id, type, spaces, error: { message } }] }
|
||||
*/
|
||||
updateObjectsSpaces(
|
||||
objects: SavedObjectsUpdateObjectsSpacesObject[],
|
||||
|
@ -268,7 +293,8 @@ export interface ISavedObjectsRepository {
|
|||
/**
|
||||
* Updates multiple objects in bulk
|
||||
*
|
||||
* @param {array} objects - [{ type, id, attributes, options: { version, namespace } references }]
|
||||
* @param {array} objects - array of objects to update (contains type, id, attributes, options: { version, namespace } references)
|
||||
* @param {object} options {@link SavedObjectsBulkUpdateOptions} - options for the bulk update operation
|
||||
* @property {string} options.version - ensures version matches that of persisted object
|
||||
* @property {string} [options.namespace]
|
||||
* @returns {promise} - {saved_objects: [[{ id, type, version, references, attributes, error: { message } }]}
|
||||
|
@ -284,6 +310,11 @@ export interface ISavedObjectsRepository {
|
|||
* @remarks Will throw a conflict error if the `update_by_query` operation returns any failure. In that case
|
||||
* some references might have been removed, and some were not. It is the caller's responsibility
|
||||
* to handle and fix this situation if it was to happen.
|
||||
*
|
||||
* @param {string} type - the type of the object to remove references to
|
||||
* @param {string} id - the ID of the object to remove references to
|
||||
* @param {object} options {@link SavedObjectsRemoveReferencesToOptions} - options for the remove references operation
|
||||
* @returns {promise} - { number - the number of objects that have been updated by this operation }
|
||||
*/
|
||||
removeReferencesTo(
|
||||
type: string,
|
||||
|
@ -338,11 +369,11 @@ export interface ISavedObjectsRepository {
|
|||
* )
|
||||
* ```
|
||||
*
|
||||
* @param type - The type of saved object whose fields should be incremented
|
||||
* @param id - The id of the document whose fields should be incremented
|
||||
* @param counterFields - An array of field names to increment or an array of {@link SavedObjectsIncrementCounterField}
|
||||
* @param options - {@link SavedObjectsIncrementCounterOptions}
|
||||
* @returns The saved object after the specified fields were incremented
|
||||
* @param {string} type - The type of saved object whose fields should be incremented
|
||||
* @param {string} id - The id of the document whose fields should be incremented
|
||||
* @param {array} counterFields - An array of field names to increment or an array of {@link SavedObjectsIncrementCounterField}
|
||||
* @param {object} options {@link SavedObjectsIncrementCounterOptions}
|
||||
* @returns {promise} - The saved object after the specified fields were incremented
|
||||
*/
|
||||
incrementCounter<T = unknown>(
|
||||
type: string,
|
||||
|
@ -381,15 +412,17 @@ export interface ISavedObjectsRepository {
|
|||
* await savedObjectsClient.closePointInTime(page2.pit_id);
|
||||
* ```
|
||||
*
|
||||
* @param {string|Array<string>} type
|
||||
* @param {object} [options] - {@link SavedObjectsOpenPointInTimeOptions}
|
||||
* @param {string|Array<string>} type - the type or types for the PIT
|
||||
* @param {object} [options] {@link SavedObjectsOpenPointInTimeOptions} - options for the open PIT operation
|
||||
* @property {string} [options.keepAlive]
|
||||
* @property {string} [options.preference]
|
||||
* @returns {promise} - { id: string }
|
||||
* @param {object} internalOptions {@link SavedObjectsFindInternalOptions} - internal options for the open PIT operation
|
||||
* @returns {promise} - { id - the ID for the PIT }
|
||||
*/
|
||||
openPointInTimeForType(
|
||||
type: string | string[],
|
||||
options?: SavedObjectsOpenPointInTimeOptions
|
||||
options?: SavedObjectsOpenPointInTimeOptions,
|
||||
internalOptions?: SavedObjectsFindInternalOptions
|
||||
): Promise<SavedObjectsOpenPointInTimeResponse>;
|
||||
|
||||
/**
|
||||
|
@ -429,13 +462,15 @@ export interface ISavedObjectsRepository {
|
|||
* await repository.closePointInTime(response.pit_id);
|
||||
* ```
|
||||
*
|
||||
* @param {string} id
|
||||
* @param {object} [options] - {@link SavedObjectsClosePointInTimeOptions}
|
||||
* @returns {promise} - {@link SavedObjectsClosePointInTimeResponse}
|
||||
* @param {string} id - ID of the saved object
|
||||
* @param {object} [options] {@link SavedObjectsClosePointInTimeOptions} - options for the close PIT operation
|
||||
* @param {object} internalOptions {@link SavedObjectsFindInternalOptions} - internal options for the close PIT operation
|
||||
* @returns {promise} - { succeeded, num_freed - number of contexts closed }
|
||||
*/
|
||||
closePointInTime(
|
||||
id: string,
|
||||
options?: SavedObjectsClosePointInTimeOptions
|
||||
options?: SavedObjectsClosePointInTimeOptions,
|
||||
internalOptions?: SavedObjectsFindInternalOptions
|
||||
): Promise<SavedObjectsClosePointInTimeResponse>;
|
||||
|
||||
/**
|
||||
|
@ -464,6 +499,10 @@ export interface ISavedObjectsRepository {
|
|||
* PIT will automatically be closed for you once you reach the last page
|
||||
* of results, or if the underlying call to `find` fails for any reason.
|
||||
*
|
||||
* @param {object} findOptions - {@link SavedObjectsCreatePointInTimeFinderOptions} - the options for creating the point-in-time finder
|
||||
* @param {object} dependencies - {@link SavedObjectsCreatePointInTimeFinderDependencies} - the dependencies for creating the point-in-time finder
|
||||
* @returns - the point-in-time finder {@link ISavedObjectsPointInTimeFinder}
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const findOptions: SavedObjectsCreatePointInTimeFinderOptions = {
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import { toPath } from 'lodash';
|
||||
import type { SavedObjectsFieldMapping } from '@kbn/core-saved-objects-server';
|
||||
import { IndexMapping } from '../types';
|
||||
import type { IndexMapping } from '../types';
|
||||
|
||||
function getPropertyMappingFromObjectMapping(
|
||||
mapping: IndexMapping | SavedObjectsFieldMapping,
|
||||
|
|
|
@ -10,7 +10,7 @@ import type {
|
|||
SavedObjectsFieldMapping,
|
||||
SavedObjectsMappingProperties,
|
||||
} from '@kbn/core-saved-objects-server';
|
||||
import { IndexMapping } from '../types';
|
||||
import type { IndexMapping } from '../types';
|
||||
import { getRootProperties } from './get_root_properties';
|
||||
|
||||
/**
|
||||
|
|
|
@ -64,6 +64,7 @@ export interface SavedObjectReference {
|
|||
* @public
|
||||
*/
|
||||
export interface SavedObjectsMigrationVersion {
|
||||
/** The plugin name and version string */
|
||||
[pluginName: string]: string;
|
||||
}
|
||||
|
||||
|
@ -78,6 +79,7 @@ export interface SavedObject<T = unknown> {
|
|||
created_at?: string;
|
||||
/** Timestamp of the last time this document had been updated. */
|
||||
updated_at?: string;
|
||||
/** Error associated with this object, populated if an operation failed for this object. */
|
||||
error?: SavedObjectError;
|
||||
/** The data for a Saved Object is stored as an object in the `attributes` property. **/
|
||||
attributes: T;
|
||||
|
|
|
@ -13,7 +13,7 @@ import type {
|
|||
SavedObjectsExportTransformContext,
|
||||
} from '@kbn/core-saved-objects-server';
|
||||
import { SavedObjectsExportError } from './errors';
|
||||
import { getObjKey, SavedObjectComparator } from './utils';
|
||||
import { getObjKey, type SavedObjectComparator } from './utils';
|
||||
|
||||
interface ApplyExportTransformsOptions {
|
||||
objects: SavedObject[];
|
||||
|
|
|
@ -16,7 +16,7 @@ import { applyExportTransformsMock } from './collect_exported_objects.test.mocks
|
|||
import { savedObjectsClientMock } from '@kbn/core-saved-objects-api-server-mocks';
|
||||
import { loggerMock } from '@kbn/logging-mocks';
|
||||
import { SavedObjectTypeRegistry } from '@kbn/core-saved-objects-base-server-internal';
|
||||
import { collectExportedObjects, ExclusionReason } from './collect_exported_objects';
|
||||
import { collectExportedObjects, type ExclusionReason } from './collect_exported_objects';
|
||||
|
||||
const createObject = (parts: Partial<SavedObject>): SavedObject => ({
|
||||
id: 'id',
|
||||
|
|
|
@ -11,7 +11,7 @@ import type { SavedObject } from '@kbn/core-saved-objects-common';
|
|||
import { SavedObjectTypeRegistry } from '@kbn/core-saved-objects-base-server-internal';
|
||||
import { SavedObjectsExporter } from './saved_objects_exporter';
|
||||
import { savedObjectsClientMock } from '@kbn/core-saved-objects-api-server-mocks';
|
||||
import { loggerMock, MockedLogger } from '@kbn/logging-mocks';
|
||||
import { loggerMock, type MockedLogger } from '@kbn/logging-mocks';
|
||||
import { Readable } from 'stream';
|
||||
import { createPromiseFromStreams, createConcatStream } from '@kbn/utils';
|
||||
|
||||
|
@ -127,6 +127,7 @@ describe('getSortedObjectsForExport()', () => {
|
|||
"search",
|
||||
],
|
||||
},
|
||||
undefined,
|
||||
],
|
||||
],
|
||||
"results": Array [
|
||||
|
@ -242,7 +243,8 @@ describe('getSortedObjectsForExport()', () => {
|
|||
sortField: 'updated_at',
|
||||
sortOrder: 'desc',
|
||||
type: ['index-pattern'],
|
||||
})
|
||||
}),
|
||||
undefined // PointInTimeFinder adds `internalOptions`, which is undefined in this case
|
||||
);
|
||||
});
|
||||
});
|
||||
|
@ -480,6 +482,7 @@ describe('getSortedObjectsForExport()', () => {
|
|||
"search",
|
||||
],
|
||||
},
|
||||
undefined,
|
||||
],
|
||||
],
|
||||
"results": Array [
|
||||
|
@ -639,6 +642,7 @@ describe('getSortedObjectsForExport()', () => {
|
|||
"search",
|
||||
],
|
||||
},
|
||||
undefined,
|
||||
],
|
||||
],
|
||||
"results": Array [
|
||||
|
@ -735,6 +739,7 @@ describe('getSortedObjectsForExport()', () => {
|
|||
"search",
|
||||
],
|
||||
},
|
||||
undefined,
|
||||
],
|
||||
],
|
||||
"results": Array [
|
||||
|
@ -836,6 +841,7 @@ describe('getSortedObjectsForExport()', () => {
|
|||
"search",
|
||||
],
|
||||
},
|
||||
undefined,
|
||||
],
|
||||
],
|
||||
"results": Array [
|
||||
|
|
|
@ -25,7 +25,11 @@ import type {
|
|||
import { sortObjects } from './sort_objects';
|
||||
import { SavedObjectsExportError } from './errors';
|
||||
import { collectExportedObjects } from './collect_exported_objects';
|
||||
import { byIdAscComparator, getPreservedOrderComparator, SavedObjectComparator } from './utils';
|
||||
import {
|
||||
byIdAscComparator,
|
||||
getPreservedOrderComparator,
|
||||
type SavedObjectComparator,
|
||||
} from './utils';
|
||||
|
||||
/**
|
||||
* @internal
|
||||
|
|
|
@ -32,7 +32,10 @@ import type {
|
|||
} from '@kbn/core-saved-objects-server';
|
||||
import { typeRegistryMock } from '@kbn/core-saved-objects-base-server-mocks';
|
||||
import { savedObjectsClientMock } from '@kbn/core-saved-objects-api-server-mocks';
|
||||
import { importSavedObjectsFromStream, ImportSavedObjectsOptions } from './import_saved_objects';
|
||||
import {
|
||||
importSavedObjectsFromStream,
|
||||
type ImportSavedObjectsOptions,
|
||||
} from './import_saved_objects';
|
||||
import type { ImportStateMap } from './lib';
|
||||
|
||||
describe('#importSavedObjectsFromStream', () => {
|
||||
|
|
|
@ -13,7 +13,7 @@ import type {
|
|||
SavedObjectsClientContract,
|
||||
} from '@kbn/core-saved-objects-api-server';
|
||||
import type { ISavedObjectTypeRegistry } from '@kbn/core-saved-objects-server';
|
||||
import { checkReferenceOrigins, CheckReferenceOriginsParams } from './check_reference_origins';
|
||||
import { checkReferenceOrigins, type CheckReferenceOriginsParams } from './check_reference_origins';
|
||||
import { savedObjectsClientMock } from '@kbn/core-saved-objects-api-server-mocks';
|
||||
import { typeRegistryMock } from '@kbn/core-saved-objects-base-server-mocks';
|
||||
import type { ImportStateMap } from './types';
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
import { savedObjectsClientMock } from '@kbn/core-saved-objects-api-server-mocks';
|
||||
import type { SavedObject, SavedObjectsImportFailure } from '@kbn/core-saved-objects-common';
|
||||
import { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server';
|
||||
import type { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server';
|
||||
import { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-utils-server';
|
||||
import { createSavedObjects } from './create_saved_objects';
|
||||
import { extractErrors } from './extract_errors';
|
||||
|
|
|
@ -40,7 +40,7 @@ import { typeRegistryMock } from '@kbn/core-saved-objects-base-server-mocks';
|
|||
import { savedObjectsClientMock } from '@kbn/core-saved-objects-api-server-mocks';
|
||||
import {
|
||||
resolveSavedObjectsImportErrors,
|
||||
ResolveSavedObjectsImportErrorsOptions,
|
||||
type ResolveSavedObjectsImportErrorsOptions,
|
||||
} from './resolve_import_errors';
|
||||
|
||||
describe('#importSavedObjectsFromStream', () => {
|
||||
|
|
|
@ -32,7 +32,7 @@ import {
|
|||
checkConflicts,
|
||||
executeImportHooks,
|
||||
checkOriginConflicts,
|
||||
ImportStateMap,
|
||||
type ImportStateMap,
|
||||
} from './lib';
|
||||
|
||||
/**
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
export { KibanaMigrator, buildActiveMappings, mergeTypes } from './src';
|
||||
export { DocumentMigrator, KibanaMigrator, buildActiveMappings, mergeTypes } from './src';
|
||||
export type { KibanaMigratorOptions } from './src';
|
||||
export { getAggregatedTypesDocuments } from './src/actions/check_for_unknown_docs';
|
||||
export { addExcludedTypesToBoolQuery } from './src/model/helpers';
|
||||
|
|
|
@ -14,7 +14,7 @@ import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server';
|
|||
import type { SavedObjectsRawDoc } from '@kbn/core-saved-objects-server';
|
||||
import {
|
||||
catchRetryableEsClientErrors,
|
||||
RetryableEsClientError,
|
||||
type RetryableEsClientError,
|
||||
} from './catch_retryable_es_client_errors';
|
||||
import { isWriteBlockException, isIndexNotFoundException } from './es_errors';
|
||||
import { WAIT_FOR_ALL_SHARDS_TO_BE_ACTIVE } from './constants';
|
||||
|
|
|
@ -13,7 +13,7 @@ import { errors as EsErrors } from '@elastic/elasticsearch';
|
|||
import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server';
|
||||
import {
|
||||
catchRetryableEsClientErrors,
|
||||
RetryableEsClientError,
|
||||
type RetryableEsClientError,
|
||||
} from './catch_retryable_es_client_errors';
|
||||
import type { IndexNotFound, AcknowledgeResponse } from '.';
|
||||
import { type IndexNotGreenTimeout, waitForIndexStatus } from './wait_for_index_status';
|
||||
|
@ -24,7 +24,7 @@ import {
|
|||
WAIT_FOR_ALL_SHARDS_TO_BE_ACTIVE,
|
||||
} from './constants';
|
||||
import { isClusterShardLimitExceeded } from './es_errors';
|
||||
import { ClusterShardLimitExceeded } from './create_index';
|
||||
import type { ClusterShardLimitExceeded } from './create_index';
|
||||
|
||||
export type CloneIndexResponse = AcknowledgeResponse;
|
||||
|
||||
|
|
|
@ -11,7 +11,7 @@ import * as TaskEither from 'fp-ts/lib/TaskEither';
|
|||
import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server';
|
||||
import {
|
||||
catchRetryableEsClientErrors,
|
||||
RetryableEsClientError,
|
||||
type RetryableEsClientError,
|
||||
} from './catch_retryable_es_client_errors';
|
||||
|
||||
/** @internal */
|
||||
|
|
|
@ -12,10 +12,10 @@ import { pipe } from 'fp-ts/lib/pipeable';
|
|||
import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
|
||||
import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server';
|
||||
import type { IndexMapping } from '@kbn/core-saved-objects-base-server-internal';
|
||||
import { AcknowledgeResponse } from '.';
|
||||
import type { AcknowledgeResponse } from '.';
|
||||
import {
|
||||
catchRetryableEsClientErrors,
|
||||
RetryableEsClientError,
|
||||
type RetryableEsClientError,
|
||||
} from './catch_retryable_es_client_errors';
|
||||
import {
|
||||
DEFAULT_TIMEOUT,
|
||||
|
|
|
@ -12,7 +12,7 @@ import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server';
|
|||
import type { IndexMapping } from '@kbn/core-saved-objects-base-server-internal';
|
||||
import {
|
||||
catchRetryableEsClientErrors,
|
||||
RetryableEsClientError,
|
||||
type RetryableEsClientError,
|
||||
} from './catch_retryable_es_client_errors';
|
||||
|
||||
export type FetchIndexResponse = Record<
|
||||
|
|
|
@ -6,8 +6,8 @@
|
|||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { RetryableEsClientError } from './catch_retryable_es_client_errors';
|
||||
import { DocumentsTransformFailed } from '../core/migrate_raw_docs';
|
||||
import type { RetryableEsClientError } from './catch_retryable_es_client_errors';
|
||||
import type { DocumentsTransformFailed } from '../core/migrate_raw_docs';
|
||||
|
||||
export {
|
||||
BATCH_SIZE,
|
||||
|
|
|
@ -12,10 +12,10 @@ import { pipe } from 'fp-ts/lib/pipeable';
|
|||
import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server';
|
||||
import {
|
||||
catchRetryableEsClientErrors,
|
||||
RetryableEsClientError,
|
||||
type RetryableEsClientError,
|
||||
} from './catch_retryable_es_client_errors';
|
||||
|
||||
import { FetchIndexResponse, fetchIndices } from './fetch_indices';
|
||||
import { type FetchIndexResponse, fetchIndices } from './fetch_indices';
|
||||
|
||||
const routingAllocationEnable = 'cluster.routing.allocation.enable';
|
||||
export interface ClusterRoutingAllocationEnabled {
|
||||
|
|
|
@ -11,7 +11,7 @@ import * as TaskEither from 'fp-ts/lib/TaskEither';
|
|||
import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server';
|
||||
import {
|
||||
catchRetryableEsClientErrors,
|
||||
RetryableEsClientError,
|
||||
type RetryableEsClientError,
|
||||
} from './catch_retryable_es_client_errors';
|
||||
|
||||
/** @internal */
|
||||
|
|
|
@ -11,7 +11,7 @@ import * as TaskEither from 'fp-ts/lib/TaskEither';
|
|||
import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server';
|
||||
import {
|
||||
catchRetryableEsClientErrors,
|
||||
RetryableEsClientError,
|
||||
type RetryableEsClientError,
|
||||
} from './catch_retryable_es_client_errors';
|
||||
import { BATCH_SIZE } from './constants';
|
||||
|
||||
|
|
|
@ -13,7 +13,7 @@ import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server';
|
|||
import type { SavedObjectsRawDoc } from '@kbn/core-saved-objects-server';
|
||||
import {
|
||||
catchRetryableEsClientErrors,
|
||||
RetryableEsClientError,
|
||||
type RetryableEsClientError,
|
||||
} from './catch_retryable_es_client_errors';
|
||||
import { DEFAULT_PIT_KEEP_ALIVE } from './open_pit';
|
||||
|
||||
|
|
|
@ -11,7 +11,7 @@ import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server';
|
|||
|
||||
import {
|
||||
catchRetryableEsClientErrors,
|
||||
RetryableEsClientError,
|
||||
type RetryableEsClientError,
|
||||
} from './catch_retryable_es_client_errors';
|
||||
|
||||
/** @internal */
|
||||
|
|
|
@ -11,7 +11,7 @@ import * as TaskEither from 'fp-ts/lib/TaskEither';
|
|||
import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server';
|
||||
import {
|
||||
catchRetryableEsClientErrors,
|
||||
RetryableEsClientError,
|
||||
type RetryableEsClientError,
|
||||
} from './catch_retryable_es_client_errors';
|
||||
import { DEFAULT_TIMEOUT } from './constants';
|
||||
|
||||
|
|
|
@ -13,7 +13,7 @@ import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server';
|
|||
import type { SavedObjectsRawDoc, SavedObjectsRawDocSource } from '@kbn/core-saved-objects-server';
|
||||
import {
|
||||
catchRetryableEsClientErrors,
|
||||
RetryableEsClientError,
|
||||
type RetryableEsClientError,
|
||||
} from './catch_retryable_es_client_errors';
|
||||
|
||||
/** @internal */
|
||||
|
|
|
@ -12,7 +12,7 @@ import { errors as EsErrors } from '@elastic/elasticsearch';
|
|||
import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server';
|
||||
import {
|
||||
catchRetryableEsClientErrors,
|
||||
RetryableEsClientError,
|
||||
type RetryableEsClientError,
|
||||
} from './catch_retryable_es_client_errors';
|
||||
import { DEFAULT_TIMEOUT, IndexNotFound } from '.';
|
||||
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
import * as TaskEither from 'fp-ts/lib/TaskEither';
|
||||
import type { SavedObjectsRawDoc } from '@kbn/core-saved-objects-server';
|
||||
import type { TransformRawDocs } from '../types';
|
||||
import { DocumentsTransformFailed, DocumentsTransformSuccess } from '../core/migrate_raw_docs';
|
||||
import type { DocumentsTransformFailed, DocumentsTransformSuccess } from '../core/migrate_raw_docs';
|
||||
|
||||
/** @internal */
|
||||
export interface TransformDocsParams {
|
||||
|
|
|
@ -12,7 +12,7 @@ import { errors as EsErrors } from '@elastic/elasticsearch';
|
|||
import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server';
|
||||
import {
|
||||
catchRetryableEsClientErrors,
|
||||
RetryableEsClientError,
|
||||
type RetryableEsClientError,
|
||||
} from './catch_retryable_es_client_errors';
|
||||
import { DEFAULT_TIMEOUT, IndexNotFound } from '.';
|
||||
|
||||
|
|
|
@ -13,7 +13,7 @@ import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server';
|
|||
import type { IndexMapping } from '@kbn/core-saved-objects-base-server-internal';
|
||||
import {
|
||||
catchRetryableEsClientErrors,
|
||||
RetryableEsClientError,
|
||||
type RetryableEsClientError,
|
||||
} from './catch_retryable_es_client_errors';
|
||||
import { pickupUpdatedMappings } from './pickup_updated_mappings';
|
||||
import { DEFAULT_TIMEOUT } from './constants';
|
||||
|
|
|
@ -11,7 +11,7 @@ import * as TaskEither from 'fp-ts/lib/TaskEither';
|
|||
import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server';
|
||||
import {
|
||||
catchRetryableEsClientErrors,
|
||||
RetryableEsClientError,
|
||||
type RetryableEsClientError,
|
||||
} from './catch_retryable_es_client_errors';
|
||||
import { DEFAULT_TIMEOUT } from './constants';
|
||||
|
||||
|
|
|
@ -9,9 +9,9 @@
|
|||
import * as TaskEither from 'fp-ts/lib/TaskEither';
|
||||
import * as Option from 'fp-ts/lib/Option';
|
||||
import { flow } from 'fp-ts/lib/function';
|
||||
import { RetryableEsClientError } from './catch_retryable_es_client_errors';
|
||||
import type { RetryableEsClientError } from './catch_retryable_es_client_errors';
|
||||
import type { IndexNotFound, TargetIndexHadWriteBlock } from '.';
|
||||
import { waitForTask, WaitForTaskCompletionTimeout } from './wait_for_task';
|
||||
import { waitForTask, type WaitForTaskCompletionTimeout } from './wait_for_task';
|
||||
import { isWriteBlockException, isIncompatibleMappingException } from './es_errors';
|
||||
|
||||
export interface IncompatibleMappingException {
|
||||
|
|
|
@ -13,7 +13,7 @@ import { errors as EsErrors } from '@elastic/elasticsearch';
|
|||
import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server';
|
||||
import {
|
||||
catchRetryableEsClientErrors,
|
||||
RetryableEsClientError,
|
||||
type RetryableEsClientError,
|
||||
} from './catch_retryable_es_client_errors';
|
||||
|
||||
/** @internal */
|
||||
|
|
|
@ -9,3 +9,4 @@
|
|||
export { KibanaMigrator, mergeTypes } from './kibana_migrator';
|
||||
export type { KibanaMigratorOptions } from './kibana_migrator';
|
||||
export { buildActiveMappings } from './core';
|
||||
export { DocumentMigrator } from './core';
|
||||
|
|
|
@ -13,7 +13,7 @@ import { loggingSystemMock } from '@kbn/core-logging-server-mocks';
|
|||
import { elasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks';
|
||||
import type { SavedObjectsType } from '@kbn/core-saved-objects-server';
|
||||
import { SavedObjectTypeRegistry } from '@kbn/core-saved-objects-base-server-internal';
|
||||
import { KibanaMigratorOptions, KibanaMigrator } from './kibana_migrator';
|
||||
import { type KibanaMigratorOptions, KibanaMigrator } from './kibana_migrator';
|
||||
import { DocumentMigrator } from './core/document_migrator';
|
||||
import { ByteSizeValue } from '@kbn/config-schema';
|
||||
import { docLinksServiceMock } from '@kbn/core-doc-links-server-mocks';
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue