From 40f8de754fb80658c10755f830876af37bf976e8 Mon Sep 17 00:00:00 2001 From: "Christiane (Tina) Heiligers" Date: Tue, 14 Nov 2023 12:35:41 -0700 Subject: [PATCH 01/16] get docs for all valid types in preflight mget call --- .../src/lib/apis/bulk_update.ts | 92 +++++++++++++++---- .../src/apis/bulk_update.ts | 5 +- 2 files changed, 76 insertions(+), 21 deletions(-) diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.ts index b9c0f10a9021f..1234a5ab0ea05 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.ts @@ -14,6 +14,7 @@ import { DecoratedError, AuthorizeUpdateObject, SavedObjectsRawDoc, + SavedObjectsRawDocSource, } from '@kbn/core-saved-objects-server'; import { ALL_NAMESPACES_STRING, SavedObjectsUtils } from '@kbn/core-saved-objects-utils-server'; import { encodeVersion } from '@kbn/core-saved-objects-base-server-internal'; @@ -47,7 +48,11 @@ export const performBulkUpdate = async ( { objects, options }: PerformUpdateParams, { registry, helpers, allowedTypes, client, serializer, extensions = {} }: ApiExecutionContext ): Promise> => { - const { common: commonHelper, encryption: encryptionHelper } = helpers; + const { + common: commonHelper, + encryption: encryptionHelper, + migration: migrationHelper, + } = helpers; const { securityExtension } = extensions; const namespace = commonHelper.getCurrentNamespace(options.namespace); @@ -64,10 +69,19 @@ export const performBulkUpdate = async ( documentToSave: DocumentToSave; objectNamespace?: string; esRequestIndex?: number; + migrationVersionCompatibility?: 'raw' | 'compatible'; } >; const expectedBulkGetResults = objects.map((object) => { - const { type, id, attributes, references, version, namespace: objectNamespace } = object; + const { + type, + id, + attributes, + references, + version, + namespace: objectNamespace, + migrationVersionCompatibility, + } = object; let error: DecoratedError | undefined; if (!allowedTypes.includes(type)) { error = SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); @@ -91,7 +105,7 @@ export const performBulkUpdate = async ( ...(Array.isArray(references) && { references }), }; - const requiresNamespacesCheck = registry.isMultiNamespace(object.type); + // const requiresNamespacesCheck = registry.isMultiNamespace(object.type); return right({ type, @@ -99,7 +113,8 @@ export const performBulkUpdate = async ( version, documentToSave, objectNamespace, - ...(requiresNamespacesCheck && { esRequestIndex: bulkGetRequestIndexCounter++ }), + esRequestIndex: bulkGetRequestIndexCounter++, + migrationVersionCompatibility, }); }); @@ -117,20 +132,26 @@ export const performBulkUpdate = async ( // `objectNamespace` is a namespace string, while `namespace` is a namespace ID. // The object namespace string, if defined, will supersede the operation's namespace ID. const namespaceString = SavedObjectsUtils.namespaceIdToString(namespace); + const getNamespaceId = (objectNamespace?: string) => objectNamespace !== undefined ? SavedObjectsUtils.namespaceStringToId(objectNamespace) : namespace; + const getNamespaceString = (objectNamespace?: string) => objectNamespace ?? namespaceString; + const bulkGetDocs = validObjects .filter(({ value }) => value.esRequestIndex !== undefined) .map(({ value: { type, id, objectNamespace } }) => ({ _id: serializer.generateRawId(getNamespaceId(objectNamespace), type, id), _index: commonHelper.getIndexForType(type), - _source: ['type', 'namespaces'], + _source: true, })); const bulkGetResponse = bulkGetDocs.length - ? await client.mget({ body: { docs: bulkGetDocs } }, { ignore: [404], meta: true }) + ? await client.mget( + { body: { docs: bulkGetDocs } }, + { ignore: [404], meta: true } + ) : undefined; // fail fast if we can't verify a 404 response is from Elasticsearch if ( @@ -144,15 +165,27 @@ export const performBulkUpdate = async ( } const authObjects: AuthorizeUpdateObject[] = validObjects.map((element) => { + let result; const { type, id, objectNamespace, esRequestIndex: index } = element.value; + const preflightResult = index !== undefined ? bulkGetResponse?.body.docs[index] : undefined; - return { - type, - id, - objectNamespace, - // @ts-expect-error MultiGetHit._source is optional - existingNamespaces: preflightResult?._source?.namespaces ?? [], - }; + if (registry.isMultiNamespace(type)) { + result = { + type, + id, + objectNamespace, // the namespace as defined per object in params.objects + // @ts-expect-error MultiGetHit._source is optional + existingNamespaces: preflightResult?._source?.namespaces ?? [], // we only have _source.namespaces for multi-namespace objects. + }; + } else { + result = { + type, + id, + objectNamespace, + existingNamespaces: [], // we only have _source.namespaces for multi-namespace objects. + }; + } + return result; }); const authorizationResult = await securityExtension?.authorizeBulkUpdate({ @@ -162,31 +195,43 @@ export const performBulkUpdate = async ( let bulkUpdateRequestIndexCounter = 0; const bulkUpdateParams: object[] = []; + type ExpectedBulkUpdateResult = Either< { type: string; id: string; error: Payload }, { type: string; id: string; - namespaces: string[]; + namespaces?: string[]; documentToSave: DocumentToSave; esRequestIndex: number; } >; + const expectedBulkUpdateResults = await Promise.all( expectedBulkGetResults.map>(async (expectedBulkGetResult) => { if (isLeft(expectedBulkGetResult)) { return expectedBulkGetResult; } - const { esRequestIndex, id, type, version, documentToSave, objectNamespace } = - expectedBulkGetResult.value; + const { + esRequestIndex, + id, + type, + version, + documentToSave, + objectNamespace, + migrationVersionCompatibility, + } = expectedBulkGetResult.value; let namespaces; let versionProperties; - if (esRequestIndex !== undefined) { - const indexFound = bulkGetResponse?.statusCode !== 404; - const actualResult = indexFound ? bulkGetResponse?.body.docs[esRequestIndex] : undefined; - const docFound = indexFound && isMgetDoc(actualResult) && actualResult.found; + const indexFound = bulkGetResponse?.statusCode !== 404; + + // if (esRequestIndex !== undefined) { + const actualResult = + indexFound && esRequestIndex ? bulkGetResponse?.body.docs[esRequestIndex] : undefined; + const docFound = indexFound && isMgetDoc(actualResult) && actualResult.found; + if (registry.isMultiNamespace(type)) { if ( !docFound || !rawDocExistsInNamespace( @@ -209,6 +254,13 @@ export const performBulkUpdate = async ( versionProperties = getExpectedVersionProperties(version); } else { if (registry.isSingleNamespace(type)) { + if (!docFound) { + return left({ + id, + type, + error: errorContent(SavedObjectsErrorHelpers.createGenericNotFoundError(type, id)), + }); + } // if `objectNamespace` is undefined, fall back to `options.namespace` namespaces = [getNamespaceString(objectNamespace)]; } diff --git a/packages/core/saved-objects/core-saved-objects-api-server/src/apis/bulk_update.ts b/packages/core/saved-objects/core-saved-objects-api-server/src/apis/bulk_update.ts index 6d10aee397b2f..b7c77b7ac3ab8 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server/src/apis/bulk_update.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server/src/apis/bulk_update.ts @@ -15,7 +15,10 @@ import type { SavedObjectsUpdateOptions, SavedObjectsUpdateResponse } from './up * @public */ export interface SavedObjectsBulkUpdateObject - extends Pick, 'version' | 'references'> { + extends Pick< + SavedObjectsUpdateOptions, + 'version' | 'references' | 'migrationVersionCompatibility' + > { /** The ID of this Saved Object, guaranteed to be unique for all objects of the same `type` */ id: string; /** The type of this Saved Object. Each plugin can define it's own custom Saved Object types. */ From 8f4e957616c049a1af28cbe48eb3a58c99e23bf4 Mon Sep 17 00:00:00 2001 From: "Christiane (Tina) Heiligers" Date: Tue, 14 Nov 2023 13:18:07 -0700 Subject: [PATCH 02/16] migrates existing docs, updates them and migrates back --- .../src/lib/apis/bulk_update.ts | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.ts index 1234a5ab0ea05..c8b8cbcaac85a 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.ts @@ -15,6 +15,7 @@ import { AuthorizeUpdateObject, SavedObjectsRawDoc, SavedObjectsRawDocSource, + SavedObjectSanitizedDoc, } from '@kbn/core-saved-objects-server'; import { ALL_NAMESPACES_STRING, SavedObjectsUtils } from '@kbn/core-saved-objects-utils-server'; import { encodeVersion } from '@kbn/core-saved-objects-base-server-internal'; @@ -36,6 +37,8 @@ import { isLeft, isRight, rawDocExistsInNamespace, + getSavedObjectFromSource, + mergeForUpdate, } from './utils'; import { ApiExecutionContext } from './types'; @@ -273,7 +276,54 @@ export const performBulkUpdate = async ( namespaces, esRequestIndex: bulkUpdateRequestIndexCounter++, documentToSave: expectedBulkGetResult.value.documentToSave, + rawMigratedUpdatedDoc: {} as SavedObjectsRawDoc, + migrationVersionCompatibility, }; + let migrated: SavedObject; + const typeDefinition = registry.getType(type)!; + if (docFound) { + const document = getSavedObjectFromSource( + registry, + type, + id, + actualResult as SavedObjectsRawDoc + ); + try { + migrated = migrationHelper.migrateStorageDocument(document) as SavedObject; + } catch (migrateStorageDocError) { + throw SavedObjectsErrorHelpers.decorateGeneralError( + migrateStorageDocError, + 'Failed to migrate document to the latest version.' + ); + } + } + const updatedAttributes = mergeForUpdate({ + targetAttributes: { + ...migrated!.attributes, + }, + updatedAttributes: await encryptionHelper.optionallyEncryptAttributes( + type, + id, + namespace, + documentToSave[type] + ), + typeMappings: typeDefinition.mappings, + }); + const migratedUpdatedSavedObjectDoc = migrationHelper.migrateInputDocument({ + ...migrated!, + id, + type, + namespace, + namespaces, + attributes: updatedAttributes, + updated_at: time, + ...(Array.isArray(documentToSave.references) && { references: documentToSave.references }), + }); + + const updatedMigratedDocumentToSave = serializer.savedObjectToRaw( + migratedUpdatedSavedObjectDoc as SavedObjectSanitizedDoc + ); + expectedResult.rawMigratedUpdatedDoc = updatedMigratedDocumentToSave; bulkUpdateParams.push( { From 02aea44e0f1c5bbe08df032c98a3786895ca00d9 Mon Sep 17 00:00:00 2001 From: "Christiane (Tina) Heiligers" Date: Tue, 14 Nov 2023 15:18:34 -0700 Subject: [PATCH 03/16] finishes first pass at making bulkUpdate BWC --- .../src/lib/apis/bulk_update.ts | 35 ++++++++----------- 1 file changed, 15 insertions(+), 20 deletions(-) diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.ts index c8b8cbcaac85a..8e6a4ecadb539 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.ts @@ -207,6 +207,7 @@ export const performBulkUpdate = async ( namespaces?: string[]; documentToSave: DocumentToSave; esRequestIndex: number; + rawMigratedUpdatedDoc: SavedObjectsRawDoc; } >; @@ -281,12 +282,14 @@ export const performBulkUpdate = async ( }; let migrated: SavedObject; const typeDefinition = registry.getType(type)!; + if (docFound) { const document = getSavedObjectFromSource( registry, type, id, - actualResult as SavedObjectsRawDoc + actualResult as SavedObjectsRawDoc, + { migrationVersionCompatibility } ); try { migrated = migrationHelper.migrateStorageDocument(document) as SavedObject; @@ -297,6 +300,7 @@ export const performBulkUpdate = async ( ); } } + const updatedAttributes = mergeForUpdate({ targetAttributes: { ...migrated!.attributes, @@ -309,6 +313,7 @@ export const performBulkUpdate = async ( ), typeMappings: typeDefinition.mappings, }); + const migratedUpdatedSavedObjectDoc = migrationHelper.migrateInputDocument({ ...migrated!, id, @@ -327,23 +332,13 @@ export const performBulkUpdate = async ( bulkUpdateParams.push( { - update: { + index: { _id: serializer.generateRawId(getNamespaceId(objectNamespace), type, id), _index: commonHelper.getIndexForType(type), ...versionProperties, }, }, - { - doc: { - ...documentToSave, - [type]: await encryptionHelper.optionallyEncryptAttributes( - type, - id, - objectNamespace || namespace, - documentToSave[type] - ), - }, - } + updatedMigratedDocumentToSave._source ); return right(expectedResult); @@ -359,14 +354,15 @@ export const performBulkUpdate = async ( require_alias: true, }) : undefined; - + // @TINA MARKER: refactor to use rawMigratedUpdatedDoc (the whole doc) rather than documentToSave (partial SO fields to update) const result = { saved_objects: expectedBulkUpdateResults.map((expectedResult) => { if (isLeft(expectedResult)) { return expectedResult.value as any; } - const { type, id, namespaces, documentToSave, esRequestIndex } = expectedResult.value; + const { type, id, namespaces, documentToSave, esRequestIndex, rawMigratedUpdatedDoc } = + expectedResult.value; const response = bulkUpdateResponse?.items[esRequestIndex] ?? {}; const rawResponse = Object.values(response)[0] as any; @@ -375,14 +371,13 @@ export const performBulkUpdate = async ( return { type, id, error }; } - // When a bulk update operation is completed, any fields specified in `_sourceIncludes` will be found in the "get" value of the - // returned object. We need to retrieve the `originId` if it exists so we can return it to the consumer. - const { _seq_no: seqNo, _primary_term: primaryTerm, get } = rawResponse; + const { _seq_no: seqNo, _primary_term: primaryTerm } = rawResponse; // eslint-disable-next-line @typescript-eslint/naming-convention - const { [type]: attributes, references, updated_at } = documentToSave; + const { [type]: attributes, references, updated_at } = documentToSave; // use the original request params - const { originId } = get._source; + const { originId } = rawMigratedUpdatedDoc._source; + // @TINA TODO: ensure we return the correct response without changing the signature return { id, type, From 86642a276ab0c9f4ac464b3b9e56695db36129a9 Mon Sep 17 00:00:00 2001 From: "Christiane (Tina) Heiligers" Date: Wed, 15 Nov 2023 16:09:11 -0700 Subject: [PATCH 04/16] Updates and refactors bulkUpdate client calls unit tests --- .../src/lib/apis/bulk_update.test.ts | 113 ++++++++++++------ .../src/lib/apis/bulk_update.ts | 52 ++++---- .../test_helpers/repository.test.common.ts | 39 ++++-- 3 files changed, 137 insertions(+), 67 deletions(-) diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.test.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.test.ts index d24c11f190696..2ad02a15ed26b 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.test.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.test.ts @@ -22,7 +22,10 @@ import type { SavedObjectsBulkUpdateObject, SavedObjectsBulkUpdateOptions, } from '@kbn/core-saved-objects-api-server'; -import { type SavedObjectReference } from '@kbn/core-saved-objects-server'; +import { + SavedObjectUnsanitizedDoc, + type SavedObjectReference, +} from '@kbn/core-saved-objects-server'; import { ALL_NAMESPACES_STRING } from '@kbn/core-saved-objects-utils-server'; import { SavedObjectsRepository } from '../repository'; import { loggerMock } from '@kbn/logging-mocks'; @@ -120,8 +123,28 @@ describe('SavedObjectsRepository', () => { const references = [{ name: 'ref_0', type: 'test', id: '1' }]; const originId = 'some-origin-id'; const namespace = 'foo-namespace'; + const mockMigrationVersion = { foo: '2.3.4' }; + const mockMigrateDocumentForUpdate = (doc: SavedObjectUnsanitizedDoc) => { + const response = { + ...doc, + attributes: { + ...doc.attributes, + ...(doc.attributes?.title && { title: `${doc.attributes.title}!!` }), + }, + migrationVersion: mockMigrationVersion, + managed: doc.managed ?? false, + references: doc.references || [ + { + name: 'ref_0', + type: 'test', + id: '1', + }, + ], + }; + return response; + }; - // bulk create calls have two objects for each source -- the action, and the source + // bulk index calls have two objects for each source -- the action, and the source const expectClientCallArgsAction = ( objects: TypeIdTuple[], { @@ -153,14 +176,26 @@ describe('SavedObjectsRepository', () => { ); }; - const expectObjArgs = ({ type, attributes }: { type: string; attributes: unknown }) => [ - expect.any(Object), + const expectObjArgs = ( { - doc: expect.objectContaining({ - [type]: attributes, - ...mockTimestampFields, - }), + type, + attributes, + references, + }: { + type: string; + attributes: unknown; + references?: SavedObjectReference[]; }, + overrides: Record = {} + ) => [ + expect.any(Object), + expect.objectContaining({ + [type]: attributes, + references, + type, + ...overrides, + ...mockTimestampFields, + }), ]; describe('client calls', () => { @@ -169,13 +204,14 @@ describe('SavedObjectsRepository', () => { expect(client.bulk).toHaveBeenCalled(); }); - it(`should use the ES mget action before bulk action for any types that are multi-namespace`, async () => { + it(`should use the ES mget action before bulk action for any types that are valid`, async () => { const objects = [obj1, { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }]; await bulkUpdateSuccess(client, repository, registry, objects); expect(client.bulk).toHaveBeenCalled(); expect(client.mget).toHaveBeenCalled(); const docs = [ + expect.objectContaining({ _id: `${obj1.type}:${obj1.id}` }), expect.objectContaining({ _id: `${MULTI_NAMESPACE_ISOLATED_TYPE}:${obj2.id}` }), ]; expect(client.mget).toHaveBeenCalledWith( @@ -186,21 +222,14 @@ describe('SavedObjectsRepository', () => { it(`formats the ES request`, async () => { await bulkUpdateSuccess(client, repository, registry, [obj1, obj2]); - const body = [...expectObjArgs(obj1), ...expectObjArgs(obj2)]; - expect(client.bulk).toHaveBeenCalledWith( - expect.objectContaining({ body }), - expect.anything() - ); + // expect client.bulk call args should include the whole doc + expectClientCallArgsAction([obj1, obj2], { method: 'index' }); }); it(`formats the ES request for any types that are multi-namespace`, async () => { const _obj2 = { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }; await bulkUpdateSuccess(client, repository, registry, [obj1, _obj2]); - const body = [...expectObjArgs(obj1), ...expectObjArgs(_obj2)]; - expect(client.bulk).toHaveBeenCalledWith( - expect.objectContaining({ body }), - expect.anything() - ); + expectClientCallArgsAction([obj1, _obj2], { method: 'index' }); }); it(`doesnt call Elasticsearch if there are no valid objects to update`, async () => { @@ -211,8 +240,10 @@ describe('SavedObjectsRepository', () => { it(`defaults to no references`, async () => { await bulkUpdateSuccess(client, repository, registry, [obj1, obj2]); - const expected = { doc: expect.not.objectContaining({ references: expect.anything() }) }; - const body = [expect.any(Object), expected, expect.any(Object), expected]; + const body = [ + ...expectObjArgs({ ...obj1, references: [] }), + ...expectObjArgs({ ...obj2, references: [] }), + ]; expect(client.bulk).toHaveBeenCalledWith( expect.objectContaining({ body }), expect.anything() @@ -223,13 +254,16 @@ describe('SavedObjectsRepository', () => { const test = async (references: SavedObjectReference[]) => { const objects = [obj1, obj2].map((obj) => ({ ...obj, references })); await bulkUpdateSuccess(client, repository, registry, objects); - const expected = { doc: expect.objectContaining({ references }) }; - const body = [expect.any(Object), expected, expect.any(Object), expected]; + const body = [ + ...expectObjArgs({ ...obj1, references }), + ...expectObjArgs({ ...obj2, references }), + ]; expect(client.bulk).toHaveBeenCalledWith( expect.objectContaining({ body }), expect.anything() ); client.bulk.mockClear(); + client.mget.mockClear(); }; await test(references); await test([{ type: 'type', id: 'id', name: 'some ref' }]); @@ -238,15 +272,18 @@ describe('SavedObjectsRepository', () => { it(`doesn't accept custom references if not an array`, async () => { const test = async (references: unknown) => { - const objects = [obj1, obj2]; // .map((obj) => ({ ...obj })); + const objects = [obj1, obj2]; await bulkUpdateSuccess(client, repository, registry, objects); - const expected = { doc: expect.not.objectContaining({ references: expect.anything() }) }; - const body = [expect.any(Object), expected, expect.any(Object), expected]; + const body = [ + ...expectObjArgs({ ...obj1, references: expect.not.arrayContaining([references]) }), + ...expectObjArgs({ ...obj2, references: expect.not.arrayContaining([references]) }), + ]; expect(client.bulk).toHaveBeenCalledWith( expect.objectContaining({ body }), expect.anything() ); client.bulk.mockClear(); + client.mget.mockClear(); }; await test('string'); await test(123); @@ -265,7 +302,7 @@ describe('SavedObjectsRepository', () => { it(`defaults to no version for types that are not multi-namespace`, async () => { const objects = [obj1, { ...obj2, type: NAMESPACE_AGNOSTIC_TYPE }]; await bulkUpdateSuccess(client, repository, registry, objects); - expectClientCallArgsAction(objects, { method: 'update' }); + expectClientCallArgsAction(objects, { method: 'index' }); }); it(`accepts version`, async () => { @@ -277,13 +314,13 @@ describe('SavedObjectsRepository', () => { ]; await bulkUpdateSuccess(client, repository, registry, objects); const overrides = { if_seq_no: 100, if_primary_term: 200 }; - expectClientCallArgsAction(objects, { method: 'update', overrides }); + expectClientCallArgsAction(objects, { method: 'index', overrides }); }); it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => { const getId = (type: string, id: string) => `${namespace}:${type}:${id}`; // test that the raw document ID equals this (e.g., has a namespace prefix) await bulkUpdateSuccess(client, repository, registry, [obj1, obj2], { namespace }); - expectClientCallArgsAction([obj1, obj2], { method: 'update', getId }); + expectClientCallArgsAction([obj1, obj2], { method: 'index', getId }); jest.clearAllMocks(); // test again with object namespace string that supersedes the operation's namespace ID @@ -291,13 +328,13 @@ describe('SavedObjectsRepository', () => { { ...obj1, namespace }, { ...obj2, namespace }, ]); - expectClientCallArgsAction([obj1, obj2], { method: 'update', getId }); + expectClientCallArgsAction([obj1, obj2], { method: 'index', getId }); }); it(`doesn't prepend namespace to the id when providing no namespace for single-namespace type`, async () => { const getId = (type: string, id: string) => `${type}:${id}`; // test that the raw document ID equals this (e.g., does not have a namespace prefix) await bulkUpdateSuccess(client, repository, registry, [obj1, obj2]); - expectClientCallArgsAction([obj1, obj2], { method: 'update', getId }); + expectClientCallArgsAction([obj1, obj2], { method: 'index', getId }); jest.clearAllMocks(); // test again with object namespace string that supersedes the operation's namespace ID @@ -311,7 +348,7 @@ describe('SavedObjectsRepository', () => { ], { namespace } ); - expectClientCallArgsAction([obj1, obj2], { method: 'update', getId }); + expectClientCallArgsAction([obj1, obj2], { method: 'index', getId }); }); it(`normalizes options.namespace from 'default' to undefined`, async () => { @@ -319,7 +356,7 @@ describe('SavedObjectsRepository', () => { await bulkUpdateSuccess(client, repository, registry, [obj1, obj2], { namespace: 'default', }); - expectClientCallArgsAction([obj1, obj2], { method: 'update', getId }); + expectClientCallArgsAction([obj1, obj2], { method: 'index', getId }); }); it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => { @@ -328,18 +365,20 @@ describe('SavedObjectsRepository', () => { const _obj2 = { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }; await bulkUpdateSuccess(client, repository, registry, [_obj1], { namespace }); - expectClientCallArgsAction([_obj1], { method: 'update', getId }); + expectClientCallArgsAction([_obj1], { method: 'index', getId }); client.bulk.mockClear(); + client.mget.mockClear(); await bulkUpdateSuccess(client, repository, registry, [_obj2], { namespace }); - expectClientCallArgsAction([_obj2], { method: 'update', getId }); + expectClientCallArgsAction([_obj2], { method: 'index', getId }); jest.clearAllMocks(); // test again with object namespace string that supersedes the operation's namespace ID await bulkUpdateSuccess(client, repository, registry, [{ ..._obj1, namespace }]); - expectClientCallArgsAction([_obj1], { method: 'update', getId }); + expectClientCallArgsAction([_obj1], { method: 'index', getId }); client.bulk.mockClear(); + client.mget.mockClear(); await bulkUpdateSuccess(client, repository, registry, [{ ..._obj2, namespace }]); - expectClientCallArgsAction([_obj2], { method: 'update', getId }); + expectClientCallArgsAction([_obj2], { method: 'index', getId }); }); }); diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.ts index 8e6a4ecadb539..f6ad9df9a8470 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.ts @@ -75,6 +75,10 @@ export const performBulkUpdate = async ( migrationVersionCompatibility?: 'raw' | 'compatible'; } >; + // get all docs from ES -> once we have them, we're pretty much in a similar flow as + // bulkCreate when requestId !== undefined && overwrite === true + + // maps to expectedResults in bulkCreate const expectedBulkGetResults = objects.map((object) => { const { type, @@ -120,7 +124,7 @@ export const performBulkUpdate = async ( migrationVersionCompatibility, }); }); - + // maps to validObjects in bulkCreate 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. @@ -142,14 +146,18 @@ export const performBulkUpdate = async ( : namespace; const getNamespaceString = (objectNamespace?: string) => objectNamespace ?? namespaceString; - + // bulkGetDocs maps to preflightCheckObjects in bulkCreate + // bulkCreate uses bulkGetObjectsAndAliases to mget them + // here we're only interested in the objects themselves const bulkGetDocs = validObjects - .filter(({ value }) => value.esRequestIndex !== undefined) + .filter(({ value }) => value.esRequestIndex !== undefined) // line 136 in bulk_create .map(({ value: { type, id, objectNamespace } }) => ({ _id: serializer.generateRawId(getNamespaceId(objectNamespace), type, id), _index: commonHelper.getIndexForType(type), - _source: true, + // _source: true, + _source: ['type', 'namespaces'], })); + // bulkGetResponse maps to preflightCheckResponse in bulkCreate const bulkGetResponse = bulkGetDocs.length ? await client.mget( { body: { docs: bulkGetDocs } }, @@ -166,7 +174,7 @@ export const performBulkUpdate = async ( ) { throw SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError(); } - + // we need to still check auth for bulkUpdate a doc, regardless of using a different esClient API const authObjects: AuthorizeUpdateObject[] = validObjects.map((element) => { let result; const { type, id, objectNamespace, esRequestIndex: index } = element.value; @@ -227,15 +235,17 @@ export const performBulkUpdate = async ( migrationVersionCompatibility, } = expectedBulkGetResult.value; - let namespaces; + let namespaces: string[] | undefined; let versionProperties; const indexFound = bulkGetResponse?.statusCode !== 404; - - // if (esRequestIndex !== undefined) { const actualResult = - indexFound && esRequestIndex ? bulkGetResponse?.body.docs[esRequestIndex] : undefined; - const docFound = indexFound && isMgetDoc(actualResult) && actualResult.found; + indexFound && esRequestIndex !== undefined + ? bulkGetResponse?.body.docs[esRequestIndex] + : undefined; + const docFound = + indexFound && esRequestIndex !== undefined && isMgetDoc(actualResult) && actualResult.found; if (registry.isMultiNamespace(type)) { + // esRequestIndex will be undefined for invalid types. we assign an esReqeustIndex to all docs, regardless of namespace type and can't use it as an indicator for a multinamespace object type. if ( !docFound || !rawDocExistsInNamespace( @@ -257,14 +267,14 @@ export const performBulkUpdate = async ( ]; versionProperties = getExpectedVersionProperties(version); } else { + if (!docFound) { + return left({ + id, + type, + error: errorContent(SavedObjectsErrorHelpers.createGenericNotFoundError(type, id)), + }); + } if (registry.isSingleNamespace(type)) { - if (!docFound) { - return left({ - id, - type, - error: errorContent(SavedObjectsErrorHelpers.createGenericNotFoundError(type, id)), - }); - } // if `objectNamespace` is undefined, fall back to `options.namespace` namespaces = [getNamespaceString(objectNamespace)]; } @@ -284,6 +294,7 @@ export const performBulkUpdate = async ( const typeDefinition = registry.getType(type)!; if (docFound) { + // actualResult could be undefined const document = getSavedObjectFromSource( registry, type, @@ -291,6 +302,7 @@ export const performBulkUpdate = async ( actualResult as SavedObjectsRawDoc, { migrationVersionCompatibility } ); + try { migrated = migrationHelper.migrateStorageDocument(document) as SavedObject; } catch (migrateStorageDocError) { @@ -350,11 +362,11 @@ export const performBulkUpdate = async ( ? await client.bulk({ refresh, body: bulkUpdateParams, - _source_includes: ['originId'], + _source_includes: ['originId'], // originId can only be defined for multi-namespace object types require_alias: true, }) : undefined; - // @TINA MARKER: refactor to use rawMigratedUpdatedDoc (the whole doc) rather than documentToSave (partial SO fields to update) + const result = { saved_objects: expectedBulkUpdateResults.map((expectedResult) => { if (isLeft(expectedResult)) { @@ -374,7 +386,7 @@ export const performBulkUpdate = async ( const { _seq_no: seqNo, _primary_term: primaryTerm } = rawResponse; // eslint-disable-next-line @typescript-eslint/naming-convention - const { [type]: attributes, references, updated_at } = documentToSave; // use the original request params + const { [type]: attributes, references, updated_at } = documentToSave; // use the original request params ?? probably need to return the actual updated doc that exists in es now. const { originId } = rawMigratedUpdatedDoc._source; // @TINA TODO: ensure we return the correct response without changing the signature diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/test_helpers/repository.test.common.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/test_helpers/repository.test.common.ts index 29c00e9d41ac1..07f7245597a39 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/test_helpers/repository.test.common.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/test_helpers/repository.test.common.ts @@ -652,7 +652,7 @@ export const getMockBulkUpdateResponse = ( ) => ({ items: objects.map(({ type, id }) => ({ - update: { + index: { _id: `${ registry.isSingleNamespace(type) && options?.namespace ? `${options?.namespace}:` : '' }${type}:${id}`, @@ -678,19 +678,38 @@ export const bulkUpdateSuccess = async ( 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); + // console.log( + // 'bulkUpdateSuccess: the objects for mocking bulkUpdateSuccess are:', + // JSON.stringify(objects) + // ); + let mockedMgetResponse; + const validObjects = objects.filter(({ type }) => registry.getType(type) !== undefined); + // console.log('bulkUpdateSuccess: the valid objects are:', JSON.stringify(validObjects)); + const multiNamespaceObjects = validObjects.filter(({ type }) => registry.isMultiNamespace(type)); + + if (validObjects?.length) { + if (multiNamespaceObjects.length > 0) { + mockedMgetResponse = getMockMgetResponse( + registry, + validObjects, + multiNamespaceSpace ?? options?.namespace + ); + } else { + // console.log('bulkUpdateSuccess: no multinamespace object types'); + mockedMgetResponse = getMockMgetResponse(registry, validObjects); + } + // console.log( + // 'bulkUpdateSuccess: mocking mget response only once with:', + // JSON.stringify(mockedMgetResponse) + // ); + client.mget.mockResponseOnce(mockedMgetResponse); } const response = getMockBulkUpdateResponse(registry, objects, options, originId); + // console.log('bulkUpdateSuccess: mocked response for bulkUpdate', JSON.stringify(response)); client.bulk.mockResponseOnce(response); const result = await repository.bulkUpdate(objects, options); - expect(client.mget).toHaveBeenCalledTimes(multiNamespaceObjects?.length ? 1 : 0); + expect(client.mget).toHaveBeenCalledTimes(validObjects?.length ? 1 : 0); + // console.log('bulkUpdateSuccess: result', JSON.stringify(result)); return result; }; From 6b61851d170f3eb02bcb5042dc038ea5853c53db Mon Sep 17 00:00:00 2001 From: "Christiane (Tina) Heiligers" Date: Fri, 17 Nov 2023 16:36:48 -0700 Subject: [PATCH 05/16] Updates errors unit tests --- .../src/lib/apis/bulk_create.test.ts | 1048 +++++++++++++++++ .../src/lib/apis/bulk_update.test.ts | 83 +- .../src/lib/apis/bulk_update.ts | 7 +- .../src/lib/repository.test.ts | 916 -------------- 4 files changed, 1106 insertions(+), 948 deletions(-) create mode 100644 packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_create.test.ts diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_create.test.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_create.test.ts new file mode 100644 index 0000000000000..659249edcc26b --- /dev/null +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_create.test.ts @@ -0,0 +1,1048 @@ +/* + * 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. + */ + +/* eslint-disable @typescript-eslint/no-shadow */ + +import { + pointInTimeFinderMock, + mockGetBulkOperationError, + mockGetCurrentTime, + mockPreflightCheckForCreate, + mockGetSearchDsl, +} from '../repository.test.mock'; + +import type { Payload } from '@hapi/boom'; + +import type { SavedObjectsBulkCreateObject } from '@kbn/core-saved-objects-api-server'; +import { + type SavedObjectsRawDoc, + type SavedObjectUnsanitizedDoc, + type SavedObjectReference, +} from '@kbn/core-saved-objects-server'; +import { ALL_NAMESPACES_STRING } from '@kbn/core-saved-objects-utils-server'; +import { SavedObjectsRepository } from '../repository'; +import { loggerMock } from '@kbn/logging-mocks'; +import { SavedObjectsSerializer } from '@kbn/core-saved-objects-base-server-internal'; +import { kibanaMigratorMock } from '../../mocks'; +import { elasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks'; + +import { + CUSTOM_INDEX_TYPE, + NAMESPACE_AGNOSTIC_TYPE, + MULTI_NAMESPACE_TYPE, + MULTI_NAMESPACE_ISOLATED_TYPE, + HIDDEN_TYPE, + mockVersionProps, + mockTimestampFields, + mockTimestamp, + mappings, + mockVersion, + createRegistry, + createDocumentMigrator, + createSpySerializer, + bulkCreateSuccess, + getMockBulkCreateResponse, + expectErrorResult, + expectErrorInvalidType, + expectErrorConflict, + expectError, + createBadRequestErrorPayload, + expectCreateResult, + mockTimestampFieldsWithCreated, +} from '../../test_helpers/repository.test.common'; + +// 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. + +interface ExpectedErrorResult { + type: string; + id: string; + error: Record; +} + +describe('SavedObjectsRepository', () => { + let client: ReturnType; + let repository: SavedObjectsRepository; + let migrator: ReturnType; + let logger: ReturnType; + let serializer: jest.Mocked; + + const registry = createRegistry(); + const documentMigrator = createDocumentMigrator(registry); + + const expectSuccess = ({ type, id }: { type: string; id: string }) => { + // @ts-expect-error TS is not aware of the extension + return expect.toBeDocumentWithoutError(type, id); + }; + + const expectMigrationArgs = (args: unknown, contains = true, n = 1) => { + const obj = contains ? expect.objectContaining(args) : expect.not.objectContaining(args); + expect(migrator.migrateDocument).toHaveBeenNthCalledWith( + n, + obj, + expect.objectContaining({ + allowDowngrade: expect.any(Boolean), + }) + ); + }; + + 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); + + 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 + repository = new SavedObjectsRepository({ + index: '.kibana-test', + mappings, + client, + migrator, + typeRegistry: registry, + serializer, + allowedTypes, + logger, + }); + + mockGetCurrentTime.mockReturnValue(mockTimestamp); + mockGetSearchDsl.mockClear(); + }); + + // Setup migration mock for creating an object + const mockMigrationVersion = { foo: '2.3.4' }; + const mockMigrateDocument = (doc: SavedObjectUnsanitizedDoc) => ({ + ...doc, + attributes: { + ...doc.attributes, + ...(doc.attributes?.title && { title: `${doc.attributes.title}!!` }), + }, + migrationVersion: mockMigrationVersion, + managed: doc.managed ?? false, + references: [{ name: 'search_0', type: 'search', id: '123' }], + }); + + 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' }], + managed: false, + }; + const obj2 = { + type: 'index-pattern', + id: 'logstash-*', + attributes: { title: 'Test Two' }, + references: [{ name: 'ref_0', type: 'test', id: '2' }], + managed: false, + }; + const namespace = 'foo-namespace'; + + // bulk create calls have two objects for each source -- the action, and the source + const expectClientCallArgsAction = ( + objects: Array<{ type: string; id?: string; if_primary_term?: string; if_seq_no?: string }>, + { + method, + _index = expect.any(String), + getId = () => expect.any(String), + }: { method: string; _index?: string; getId?: (type: string, id?: string) => string } + ) => { + const body = []; + for (const { type, id, if_primary_term: ifPrimaryTerm, if_seq_no: ifSeqNo } of objects) { + body.push({ + [method]: { + _index, + _id: getId(type, id), + ...(ifPrimaryTerm && ifSeqNo + ? { if_primary_term: expect.any(Number), if_seq_no: expect.any(Number) } + : {}), + }, + }); + body.push(expect.any(Object)); + } + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + }; + + const expectObjArgs = ( + { + type, + attributes, + references, + }: { type: string; attributes: unknown; references?: SavedObjectReference[] }, + overrides: Record = {} + ) => [ + expect.any(Object), + expect.objectContaining({ + [type]: attributes, + references, + type, + ...overrides, + ...mockTimestampFields, + }), + ]; + describe('client calls', () => { + it(`should use the ES bulk action by default`, async () => { + await bulkCreateSuccess(client, repository, [obj1, obj2]); + expect(client.bulk).toHaveBeenCalledTimes(1); + }); + + it(`should use the preflightCheckForCreate action before bulk action for any types that are multi-namespace, when id is defined`, async () => { + const objects = [obj1, { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }]; + await bulkCreateSuccess(client, repository, objects); + expect(client.bulk).toHaveBeenCalledTimes(1); + expect(mockPreflightCheckForCreate).toHaveBeenCalledTimes(1); + expect(mockPreflightCheckForCreate).toHaveBeenCalledWith( + expect.objectContaining({ + objects: [ + { + type: MULTI_NAMESPACE_ISOLATED_TYPE, + id: obj2.id, + overwrite: false, + namespaces: ['default'], + }, + ], + }) + ); + }); + + it(`should use the ES create method if ID is undefined and overwrite=true`, async () => { + const objects = [obj1, obj2].map((obj) => ({ ...obj, id: undefined })); + await bulkCreateSuccess(client, repository, objects, { overwrite: true }); + expectClientCallArgsAction(objects, { method: 'create' }); + }); + + it(`should use the ES create method if ID is undefined and overwrite=false`, async () => { + const objects = [obj1, obj2].map((obj) => ({ ...obj, id: undefined })); + await bulkCreateSuccess(client, repository, objects); + expectClientCallArgsAction(objects, { method: 'create' }); + }); + + it(`should use the ES index method if ID is defined and overwrite=true`, async () => { + await bulkCreateSuccess(client, repository, [obj1, obj2], { overwrite: true }); + expectClientCallArgsAction([obj1, obj2], { method: 'index' }); + }); + + it(`should use the ES index method with version if ID and version are defined and overwrite=true`, async () => { + await bulkCreateSuccess( + client, + repository, + [ + { + ...obj1, + version: mockVersion, + }, + obj2, + ], + { overwrite: true } + ); + + const obj1WithSeq = { + ...obj1, + managed: obj1.managed, + if_seq_no: mockVersionProps._seq_no, + if_primary_term: mockVersionProps._primary_term, + }; + + expectClientCallArgsAction([obj1WithSeq, obj2], { method: 'index' }); + }); + + it(`should use the ES create method if ID is defined and overwrite=false`, async () => { + await bulkCreateSuccess(client, repository, [obj1, obj2]); + expectClientCallArgsAction([obj1, obj2], { method: 'create' }); + }); + + it(`should use the ES index method if ID is defined, overwrite=true and managed=true in a document`, async () => { + await bulkCreateSuccess(client, repository, [obj1, obj2], { + overwrite: true, + managed: true, + }); + expectClientCallArgsAction([obj1, obj2], { method: 'index' }); + }); + + it(`should use the ES create method if ID is defined, overwrite=false and managed=true in a document`, async () => { + await bulkCreateSuccess(client, repository, [obj1, obj2], { managed: true }); + expectClientCallArgsAction([obj1, obj2], { method: 'create' }); + }); + + it(`formats the ES request`, async () => { + await bulkCreateSuccess(client, repository, [obj1, obj2]); + const body = [...expectObjArgs(obj1), ...expectObjArgs(obj2)]; + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + }); + // this test only ensures that the client accepts the managed field in a document + it(`formats the ES request with managed=true in a document`, async () => { + const obj1WithManagedTrue = { ...obj1, managed: true }; + const obj2WithManagedTrue = { ...obj2, managed: true }; + await bulkCreateSuccess(client, repository, [obj1WithManagedTrue, obj2WithManagedTrue]); + const body = [...expectObjArgs(obj1WithManagedTrue), ...expectObjArgs(obj2WithManagedTrue)]; + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + }); + + describe('originId', () => { + it(`returns error if originId is set for non-multi-namespace type`, async () => { + const result = await repository.bulkCreate([ + { ...obj1, originId: 'some-originId' }, + { ...obj2, type: NAMESPACE_AGNOSTIC_TYPE, originId: 'some-originId' }, + ]); + expect(result.saved_objects).toEqual([ + expect.objectContaining({ id: obj1.id, type: obj1.type, error: expect.anything() }), + expect.objectContaining({ + id: obj2.id, + type: NAMESPACE_AGNOSTIC_TYPE, + error: expect.anything(), + }), + ]); + expect(client.bulk).not.toHaveBeenCalled(); + }); + + it(`defaults to no originId`, async () => { + const objects = [ + { ...obj1, type: MULTI_NAMESPACE_TYPE }, + { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }, + ]; + + await bulkCreateSuccess(client, repository, objects); + const expected = expect.not.objectContaining({ originId: expect.anything() }); + const body = [expect.any(Object), expected, expect.any(Object), expected]; + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + }); + + describe('with existing originId', () => { + beforeEach(() => { + mockPreflightCheckForCreate.mockImplementation(({ objects }) => { + const existingDocument = { + _source: { originId: 'existing-originId' }, + } as SavedObjectsRawDoc; + return Promise.resolve( + objects.map(({ type, id }) => ({ type, id, existingDocument })) + ); + }); + }); + + it(`accepts custom originId for multi-namespace type`, async () => { + // The preflight result has `existing-originId`, but that is discarded + const objects = [ + { ...obj1, type: MULTI_NAMESPACE_TYPE, originId: 'some-originId' }, + { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE, originId: 'some-originId' }, + ]; + await bulkCreateSuccess(client, repository, objects); + const expected = expect.objectContaining({ originId: 'some-originId' }); + const body = [expect.any(Object), expected, expect.any(Object), expected]; + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + }); + + it(`accepts undefined originId`, async () => { + // The preflight result has `existing-originId`, but that is discarded + const objects = [ + { ...obj1, type: MULTI_NAMESPACE_TYPE, originId: undefined }, + { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE, originId: undefined }, + ]; + await bulkCreateSuccess(client, repository, objects); + const expected = expect.not.objectContaining({ originId: expect.anything() }); + const body = [expect.any(Object), expected, expect.any(Object), expected]; + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + }); + + it(`preserves existing originId if originId option is not set`, async () => { + const objects = [ + { ...obj1, type: MULTI_NAMESPACE_TYPE }, + { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }, + ]; + await bulkCreateSuccess(client, repository, objects); + const expected = expect.objectContaining({ originId: 'existing-originId' }); + const body = [expect.any(Object), expected, expect.any(Object), expected]; + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + }); + }); + }); + + it(`adds namespace to request body for any types that are single-namespace`, async () => { + await bulkCreateSuccess(client, repository, [obj1, obj2], { namespace }); + const expected = expect.objectContaining({ namespace }); + const body = [expect.any(Object), expected, expect.any(Object), expected]; + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + }); + + // this only ensures we don't override any other options + it(`adds managed=false to request body if declared for any types that are single-namespace`, async () => { + await bulkCreateSuccess(client, repository, [obj1, obj2], { namespace, managed: false }); + const expected = expect.objectContaining({ namespace, managed: false }); + const body = [expect.any(Object), expected, expect.any(Object), expected]; + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + }); + // this only ensures we don't override any other options + it(`adds managed=true to request body if declared for any types that are single-namespace`, async () => { + await bulkCreateSuccess(client, repository, [obj1, obj2], { namespace, managed: true }); + const expected = expect.objectContaining({ namespace, managed: true }); + const body = [expect.any(Object), expected, expect.any(Object), expected]; + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + }); + + it(`normalizes options.namespace from 'default' to undefined`, async () => { + await bulkCreateSuccess(client, repository, [obj1, obj2], { namespace: 'default' }); + const expected = expect.not.objectContaining({ namespace: 'default' }); + const body = [expect.any(Object), expected, expect.any(Object), expected]; + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + }); + + it(`doesn't add namespace to request body for any types that are not single-namespace`, async () => { + const objects = [ + { ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }, + { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }, + ]; + await bulkCreateSuccess(client, repository, objects, { namespace }); + const expected = expect.not.objectContaining({ namespace: expect.anything() }); + const body = [expect.any(Object), expected, expect.any(Object), expected]; + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + }); + + it(`adds namespaces to request body for any types that are multi-namespace`, async () => { + const test = async (namespace?: string) => { + const objects = [obj1, obj2].map((x) => ({ ...x, type: MULTI_NAMESPACE_ISOLATED_TYPE })); + const [o1, o2] = objects; + mockPreflightCheckForCreate.mockResolvedValueOnce([ + { type: o1.type, id: o1.id! }, // first object does not have an existing document to overwrite + { + type: o2.type, + id: o2.id!, + existingDocument: { _id: o2.id!, _source: { namespaces: ['*'], type: o2.type } }, // second object does have an existing document to overwrite + }, + ]); + await bulkCreateSuccess(client, repository, objects, { namespace, overwrite: true }); + const expected1 = expect.objectContaining({ namespaces: [namespace ?? 'default'] }); + const expected2 = expect.objectContaining({ namespaces: ['*'] }); + const body = [expect.any(Object), expected1, expect.any(Object), expected2]; + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + client.bulk.mockClear(); + mockPreflightCheckForCreate.mockReset(); + }; + await test(undefined); + await test(namespace); + }); + + it(`adds initialNamespaces instead of namespace`, async () => { + const test = async (namespace?: string) => { + const ns2 = 'bar-namespace'; + const ns3 = 'baz-namespace'; + const objects = [ + { ...obj1, type: 'dashboard', initialNamespaces: [ns2] }, + { ...obj1, type: MULTI_NAMESPACE_ISOLATED_TYPE, initialNamespaces: [ns2] }, + { ...obj1, type: MULTI_NAMESPACE_TYPE, initialNamespaces: [ns2, ns3] }, + ]; + const [o1, o2, o3] = objects; + mockPreflightCheckForCreate.mockResolvedValueOnce([ + // first object does not get passed in to preflightCheckForCreate at all + { type: o2.type, id: o2.id! }, // second object does not have an existing document to overwrite + { + type: o3.type, + id: o3.id!, + existingDocument: { + _id: o3.id!, + _source: { type: o3.type, namespaces: [namespace ?? 'default', 'something-else'] }, // third object does have an existing document to overwrite + }, + }, + ]); + await bulkCreateSuccess(client, repository, objects, { namespace, overwrite: true }); + const body = [ + { index: expect.objectContaining({ _id: `${ns2}:dashboard:${o1.id}` }) }, + expect.objectContaining({ namespace: ns2 }), + { + index: expect.objectContaining({ + _id: `${MULTI_NAMESPACE_ISOLATED_TYPE}:${o2.id}`, + }), + }, + expect.objectContaining({ namespaces: [ns2] }), + { index: expect.objectContaining({ _id: `${MULTI_NAMESPACE_TYPE}:${o3.id}` }) }, + expect.objectContaining({ namespaces: [ns2, ns3] }), + ]; + expect(mockPreflightCheckForCreate).toHaveBeenCalledWith( + expect.objectContaining({ + objects: [ + // assert that the initialNamespaces fields were passed into preflightCheckForCreate instead of the current namespace + { type: o2.type, id: o2.id, overwrite: true, namespaces: o2.initialNamespaces }, + { type: o3.type, id: o3.id, overwrite: true, namespaces: o3.initialNamespaces }, + ], + }) + ); + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + client.bulk.mockClear(); + mockPreflightCheckForCreate.mockReset(); + }; + await test(undefined); + await test(namespace); + }); + + it(`normalizes initialNamespaces from 'default' to undefined`, async () => { + const test = async (namespace?: string) => { + const objects = [{ ...obj1, type: 'dashboard', initialNamespaces: ['default'] }]; + await bulkCreateSuccess(client, repository, objects, { namespace, overwrite: true }); + const body = [ + { index: expect.objectContaining({ _id: `dashboard:${obj1.id}` }) }, + expect.not.objectContaining({ namespace: 'default' }), + ]; + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + client.bulk.mockClear(); + }; + await test(undefined); + await test(namespace); + }); + + it(`doesn't add namespaces to request body for any types that are not multi-namespace`, async () => { + const test = async (namespace?: string) => { + const objects = [obj1, { ...obj2, type: NAMESPACE_AGNOSTIC_TYPE }]; + await bulkCreateSuccess(client, repository, objects, { namespace, overwrite: true }); + const expected = expect.not.objectContaining({ namespaces: expect.anything() }); + const body = [expect.any(Object), expected, expect.any(Object), expected]; + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + client.bulk.mockClear(); + }; + await test(undefined); + await test(namespace); + }); + + it(`defaults to a refresh setting of wait_for`, async () => { + await bulkCreateSuccess(client, repository, [obj1, obj2]); + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ refresh: 'wait_for' }), + expect.anything() + ); + }); + + it(`should use default index`, async () => { + await bulkCreateSuccess(client, repository, [obj1, obj2]); + expectClientCallArgsAction([obj1, obj2], { + method: 'create', + _index: '.kibana-test_8.0.0-testing', + }); + }); + + it(`should use custom index`, async () => { + await bulkCreateSuccess( + client, + repository, + [obj1, obj2].map((x) => ({ ...x, type: CUSTOM_INDEX_TYPE })) + ); + expectClientCallArgsAction([obj1, obj2], { + method: 'create', + _index: 'custom_8.0.0-testing', + }); + }); + + it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => { + const getId = (type: string, id: string = '') => `${namespace}:${type}:${id}`; // test that the raw document ID equals this (e.g., has a namespace prefix) + await bulkCreateSuccess(client, repository, [obj1, obj2], { namespace }); + expectClientCallArgsAction([obj1, obj2], { method: 'create', getId }); + }); + + it(`doesn't prepend namespace to the id when providing no namespace for single-namespace type`, async () => { + const getId = (type: string, id: string = '') => `${type}:${id}`; // test that the raw document ID equals this (e.g., does not have a namespace prefix) + await bulkCreateSuccess(client, repository, [obj1, obj2]); + expectClientCallArgsAction([obj1, obj2], { method: 'create', getId }); + }); + + it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => { + const getId = (type: string, id: string = '') => `${type}:${id}`; // test that the raw document ID equals this (e.g., does not have a namespace prefix) + const objects = [ + { ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }, + { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }, + ]; + await bulkCreateSuccess(client, repository, objects, { namespace }); + expectClientCallArgsAction(objects, { method: 'create', getId }); + }); + }); + + describe('errors', () => { + afterEach(() => { + mockGetBulkOperationError.mockReset(); + }); + + const obj3 = { + type: 'dashboard', + id: 'three', + attributes: { title: 'Test Three' }, + references: [{ name: 'ref_0', type: 'test', id: '2' }], + }; + + const bulkCreateError = async ( + obj: SavedObjectsBulkCreateObject, + isBulkError: boolean | undefined, + expectedErrorResult: ExpectedErrorResult + ) => { + let response; + if (isBulkError) { + // mock the bulk error for only the second object + mockGetBulkOperationError.mockReturnValueOnce(undefined); + mockGetBulkOperationError.mockReturnValueOnce(expectedErrorResult.error as Payload); + response = getMockBulkCreateResponse([obj1, obj, obj2]); + } else { + response = getMockBulkCreateResponse([obj1, obj2]); + } + client.bulk.mockResponseOnce(response); + + const objects = [obj1, obj, obj2]; + const result = await repository.bulkCreate(objects); + expect(client.bulk).toHaveBeenCalled(); + const objCall = isBulkError ? expectObjArgs(obj) : []; + const body = [...expectObjArgs(obj1), ...objCall, ...expectObjArgs(obj2)]; + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + expect(result).toEqual({ + saved_objects: [expectSuccess(obj1), expectedErrorResult, expectSuccess(obj2)], + }); + }; + + it(`throws when options.namespace is '*'`, async () => { + await expect( + repository.bulkCreate([obj3], { namespace: ALL_NAMESPACES_STRING }) + ).rejects.toThrowError(createBadRequestErrorPayload('"options.namespace" cannot be "*"')); + }); + + it(`returns error when initialNamespaces is used with a space-agnostic object`, async () => { + const obj = { ...obj3, type: NAMESPACE_AGNOSTIC_TYPE, initialNamespaces: [] }; + await bulkCreateError( + obj, + undefined, + expectErrorResult( + obj, + createBadRequestErrorPayload( + '"initialNamespaces" cannot be used on space-agnostic types' + ) + ) + ); + }); + + it(`returns error when initialNamespaces is empty`, async () => { + const obj = { ...obj3, type: MULTI_NAMESPACE_TYPE, initialNamespaces: [] }; + await bulkCreateError( + obj, + undefined, + expectErrorResult( + obj, + createBadRequestErrorPayload('"initialNamespaces" must be a non-empty array of strings') + ) + ); + }); + + it(`returns error when initialNamespaces is used with a space-isolated object and does not specify a single space`, async () => { + const doTest = async (objType: string, initialNamespaces: string[]) => { + const obj = { ...obj3, type: objType, initialNamespaces }; + await bulkCreateError( + obj, + undefined, + expectErrorResult( + obj, + createBadRequestErrorPayload( + '"initialNamespaces" can only specify a single space when used with space-isolated types' + ) + ) + ); + }; + await doTest('dashboard', ['spacex', 'spacey']); + await doTest('dashboard', ['*']); + await doTest(MULTI_NAMESPACE_ISOLATED_TYPE, ['spacex', 'spacey']); + await doTest(MULTI_NAMESPACE_ISOLATED_TYPE, ['*']); + }); + + it.only(`returns error when type is invalid`, async () => { + const obj = { ...obj3, type: 'unknownType' }; + await bulkCreateError(obj, undefined, expectErrorInvalidType(obj)); + }); + + it(`returns error when type is hidden`, async () => { + const obj = { ...obj3, type: HIDDEN_TYPE }; + await bulkCreateError(obj, undefined, expectErrorInvalidType(obj)); + }); + + it(`returns error when there is a conflict from preflightCheckForCreate`, async () => { + const objects = [ + // only the second, third, and fourth objects are passed to preflightCheckForCreate and result in errors + obj1, + { ...obj1, type: MULTI_NAMESPACE_TYPE }, + { ...obj2, type: MULTI_NAMESPACE_TYPE }, + { ...obj3, type: MULTI_NAMESPACE_TYPE }, + obj2, + ]; + const [o1, o2, o3, o4, o5] = objects; + mockPreflightCheckForCreate.mockResolvedValueOnce([ + // first and last objects do not get passed in to preflightCheckForCreate at all + { type: o2.type, id: o2.id!, error: { type: 'conflict' } }, + { + type: o3.type, + id: o3.id!, + error: { type: 'unresolvableConflict', metadata: { isNotOverwritable: true } }, + }, + { + type: o4.type, + id: o4.id!, + error: { type: 'aliasConflict', metadata: { spacesWithConflictingAliases: ['foo'] } }, + }, + ]); + const bulkResponse = getMockBulkCreateResponse([o1, o5]); + client.bulk.mockResponseOnce(bulkResponse); + + const options = { overwrite: true }; + const result = await repository.bulkCreate(objects, options); + expect(mockPreflightCheckForCreate).toHaveBeenCalled(); + expect(mockPreflightCheckForCreate).toHaveBeenCalledWith( + expect.objectContaining({ + objects: [ + { type: o2.type, id: o2.id, overwrite: true, namespaces: ['default'] }, + { type: o3.type, id: o3.id, overwrite: true, namespaces: ['default'] }, + { type: o4.type, id: o4.id, overwrite: true, namespaces: ['default'] }, + ], + }) + ); + expect(client.bulk).toHaveBeenCalled(); + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body: [...expectObjArgs(o1), ...expectObjArgs(o5)] }), + expect.anything() + ); + expect(result).toEqual({ + saved_objects: [ + expectSuccess(o1), + expectErrorConflict(o2), + expectErrorConflict(o3, { metadata: { isNotOverwritable: true } }), + expectErrorConflict(o4, { metadata: { spacesWithConflictingAliases: ['foo'] } }), + expectSuccess(o5), + ], + }); + }); + + it(`returns bulk error`, async () => { + const expectedErrorResult = { + type: obj3.type, + id: obj3.id, + error: { error: 'Oh no, a bulk error!' }, + }; + await bulkCreateError(obj3, true, expectedErrorResult); + }); + + it(`returns errors for any bulk objects with invalid schemas`, async () => { + const response = getMockBulkCreateResponse([obj3]); + client.bulk.mockResponseOnce(response); + + const result = await repository.bulkCreate([ + obj3, + // @ts-expect-error - Title should be a string and is intentionally malformed for testing + { ...obj3, id: 'three-again', attributes: { title: 123 } }, + ]); + expect(client.bulk).toHaveBeenCalledTimes(1); // only called once for the valid object + expect(result.saved_objects).toEqual([ + expect.objectContaining(obj3), + expect.objectContaining({ + error: new Error( + '[attributes.title]: expected value of type [string] but got [number]: Bad Request' + ), + id: 'three-again', + type: 'dashboard', + }), + ]); + }); + }); + + describe('migration', () => { + it(`migrates the docs and serializes the migrated docs`, async () => { + migrator.migrateDocument.mockImplementation(mockMigrateDocument); + const modifiedObj1 = { ...obj1, coreMigrationVersion: '8.0.0' }; + await bulkCreateSuccess(client, repository, [modifiedObj1, obj2]); + const docs = [modifiedObj1, obj2].map((x) => ({ ...x, ...mockTimestampFieldsWithCreated })); + expectMigrationArgs(docs[0], true, 1); + expectMigrationArgs(docs[1], true, 2); + + const migratedDocs = docs.map((x) => migrator.migrateDocument(x)); + expect(serializer.savedObjectToRaw).toHaveBeenNthCalledWith(1, migratedDocs[0]); + expect(serializer.savedObjectToRaw).toHaveBeenNthCalledWith(2, migratedDocs[1]); + }); + + it(`adds namespace to body when providing namespace for single-namespace type`, async () => { + await bulkCreateSuccess(client, repository, [obj1, obj2], { namespace }); + expectMigrationArgs({ namespace }, true, 1); + expectMigrationArgs({ namespace }, true, 2); + }); + + it(`doesn't add namespace to body when providing no namespace for single-namespace type`, async () => { + await bulkCreateSuccess(client, repository, [obj1, obj2]); + expectMigrationArgs({ namespace: expect.anything() }, false, 1); + expectMigrationArgs({ namespace: expect.anything() }, false, 2); + }); + + it(`doesn't add namespace to body when not using single-namespace type`, async () => { + const objects = [ + { ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }, + { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }, + ]; + await bulkCreateSuccess(client, repository, objects, { namespace }); + expectMigrationArgs({ namespace: expect.anything() }, false, 1); + expectMigrationArgs({ namespace: expect.anything() }, false, 2); + }); + + it(`adds namespaces to body when providing namespace for multi-namespace type`, async () => { + const objects = [obj1, obj2].map((obj) => ({ + ...obj, + type: MULTI_NAMESPACE_ISOLATED_TYPE, + })); + await bulkCreateSuccess(client, repository, objects, { namespace }); + expectMigrationArgs({ namespaces: [namespace] }, true, 1); + expectMigrationArgs({ namespaces: [namespace] }, true, 2); + }); + + it(`adds default namespaces to body when providing no namespace for multi-namespace type`, async () => { + const objects = [obj1, obj2].map((obj) => ({ + ...obj, + type: MULTI_NAMESPACE_ISOLATED_TYPE, + })); + await bulkCreateSuccess(client, repository, objects); + expectMigrationArgs({ namespaces: ['default'] }, true, 1); + expectMigrationArgs({ namespaces: ['default'] }, true, 2); + }); + + it(`doesn't add namespaces to body when not using multi-namespace type`, async () => { + const objects = [obj1, { ...obj2, type: NAMESPACE_AGNOSTIC_TYPE }]; + await bulkCreateSuccess(client, repository, objects); + expectMigrationArgs({ namespaces: expect.anything() }, false, 1); + expectMigrationArgs({ namespaces: expect.anything() }, false, 2); + }); + }); + + describe('returns', () => { + it(`formats the ES response`, async () => { + const result = await bulkCreateSuccess(client, repository, [obj1, obj2]); + expect(result).toEqual({ + saved_objects: [obj1, obj2].map((x) => expectCreateResult(x)), + }); + }); + + it.todo(`should return objects in the same order regardless of type`); + + it(`handles a mix of successful creates and errors`, async () => { + const obj = { + type: 'unknownType', + id: 'three', + attributes: {}, + }; + const objects = [obj1, obj, obj2]; + const response = getMockBulkCreateResponse([obj1, obj2]); + client.bulk.mockResponseOnce(response); + const result = await repository.bulkCreate(objects); + expect(client.bulk).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + saved_objects: [expectCreateResult(obj1), expectError(obj), expectCreateResult(obj2)], + }); + }); + + it(`a deserialized saved object`, async () => { + // Test for fix to https://github.com/elastic/kibana/issues/65088 where + // we returned raw ID's when an object without an id was created. + const namespace = 'myspace'; + // FIXME: this test is based on a gigantic hack to have the bulk operation return the source + // of the document when it actually does not, forcing to cast to any as BulkResponse + // does not contains _source + const response = getMockBulkCreateResponse([obj1, obj2], namespace) as any; + client.bulk.mockResponseOnce(response); + + // Bulk create one object with id unspecified, and one with id specified + const result = await repository.bulkCreate([{ ...obj1, id: undefined }, obj2], { + namespace, + }); + + // Assert that both raw docs from the ES response are deserialized + expect(serializer.rawToSavedObject).toHaveBeenNthCalledWith( + 1, + { + ...response.items[0].create, + _source: { + ...response.items[0].create._source, + namespaces: response.items[0].create._source.namespaces, + coreMigrationVersion: expect.any(String), + typeMigrationVersion: '1.1.1', + }, + _id: expect.stringMatching( + /^myspace:config:[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$/ + ), + }, + expect.any(Object) + ); + expect(serializer.rawToSavedObject).toHaveBeenNthCalledWith( + 2, + { + ...response.items[1].create, + _source: { + ...response.items[1].create._source, + namespaces: response.items[1].create._source.namespaces, + coreMigrationVersion: expect.any(String), + typeMigrationVersion: '1.1.1', + }, + }, + expect.any(Object) + ); + + // Assert that ID's are deserialized to remove the type and namespace + expect(result.saved_objects[0].id).toEqual( + expect.stringMatching(/^[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$/) + ); + expect(result.saved_objects[1].id).toEqual(obj2.id); + + // Assert that managed is not changed + expect(result.saved_objects[0].managed).toBeFalsy(); + expect(result.saved_objects[1].managed).toEqual(obj2.managed); + }); + + it(`sets managed=false if not already set`, async () => { + const obj1WithoutManaged = { + type: 'config', + id: '6.0.0-alpha1', + attributes: { title: 'Test One' }, + references: [{ name: 'ref_0', type: 'test', id: '1' }], + }; + const obj2WithoutManaged = { + type: 'index-pattern', + id: 'logstash-*', + attributes: { title: 'Test Two' }, + references: [{ name: 'ref_0', type: 'test', id: '2' }], + }; + const result = await bulkCreateSuccess(client, repository, [ + obj1WithoutManaged, + obj2WithoutManaged, + ]); + expect(result).toEqual({ + saved_objects: [obj1, obj2].map((x) => expectCreateResult(x)), + }); + }); + + it(`sets managed=false only on documents without managed already set`, async () => { + const objWithoutManaged = { + type: 'config', + id: '6.0.0-alpha1', + attributes: { title: 'Test One' }, + references: [{ name: 'ref_0', type: 'test', id: '1' }], + }; + const result = await bulkCreateSuccess(client, repository, [objWithoutManaged, obj2]); + expect(result).toEqual({ + saved_objects: [obj1, obj2].map((x) => expectCreateResult(x)), + }); + }); + + it(`sets managed=true if provided as an override`, async () => { + const obj1WithoutManaged = { + type: 'config', + id: '6.0.0-alpha1', + attributes: { title: 'Test One' }, + references: [{ name: 'ref_0', type: 'test', id: '1' }], + }; + const obj2WithoutManaged = { + type: 'index-pattern', + id: 'logstash-*', + attributes: { title: 'Test Two' }, + references: [{ name: 'ref_0', type: 'test', id: '2' }], + }; + const result = await bulkCreateSuccess( + client, + repository, + [obj1WithoutManaged, obj2WithoutManaged], + { managed: true } + ); + expect(result).toEqual({ + saved_objects: [ + { ...obj1WithoutManaged, managed: true }, + { ...obj2WithoutManaged, managed: true }, + ].map((x) => expectCreateResult(x)), + }); + }); + + it(`sets managed=false if provided as an override`, async () => { + const obj1WithoutManaged = { + type: 'config', + id: '6.0.0-alpha1', + attributes: { title: 'Test One' }, + references: [{ name: 'ref_0', type: 'test', id: '1' }], + }; + const obj2WithoutManaged = { + type: 'index-pattern', + id: 'logstash-*', + attributes: { title: 'Test Two' }, + references: [{ name: 'ref_0', type: 'test', id: '2' }], + }; + const result = await bulkCreateSuccess( + client, + repository, + [obj1WithoutManaged, obj2WithoutManaged], + { managed: false } + ); + expect(result).toEqual({ + saved_objects: [obj1, obj2].map((x) => expectCreateResult(x)), + }); + }); + }); + }); +}); diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.test.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.test.ts index 2ad02a15ed26b..a4c87fb1b1eac 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.test.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.test.ts @@ -398,51 +398,72 @@ describe('SavedObjectsRepository', () => { isBulkError: boolean, expectedErrorResult: ExpectedErrorResult ) => { - const objects = [obj1, obj, obj2]; - const mockResponse = getMockBulkUpdateResponse(registry, objects); + const objects = [obj1, obj2, obj]; + + const mockedMgetResponse = getMockMgetResponse(registry, [obj1, obj2, obj]); + client.bulk.mockClear(); + client.mget.mockClear(); + client.mget.mockResponseOnce(mockedMgetResponse); + + const mockBulkIndexResponse = getMockBulkUpdateResponse(registry, objects); if (isBulkError) { - // mock the bulk error for only the second object + // mock the bulk error for only the third object + mockGetBulkOperationError.mockReturnValueOnce(undefined); mockGetBulkOperationError.mockReturnValueOnce(undefined); mockGetBulkOperationError.mockReturnValueOnce(expectedErrorResult.error as Payload); } - client.bulk.mockResponseOnce(mockResponse); + client.bulk.mockResponseOnce(mockBulkIndexResponse); const result = await repository.bulkUpdate(objects); + + expect(client.mget).toHaveBeenCalled(); expect(client.bulk).toHaveBeenCalled(); - const objCall = isBulkError ? expectObjArgs(obj) : []; - const body = [...expectObjArgs(obj1), ...objCall, ...expectObjArgs(obj2)]; - expect(client.bulk).toHaveBeenCalledWith( - expect.objectContaining({ body }), - expect.anything() - ); + + const expectClientCallObjects = isBulkError ? [obj1, obj2, obj] : [obj1, obj2]; + expectClientCallArgsAction(expectClientCallObjects, { method: 'index' }); + expect(result).toEqual({ - saved_objects: [expectSuccess(obj1), expectedErrorResult, expectSuccess(obj2)], + saved_objects: [expectSuccess(obj1), expectSuccess(obj2), expectedErrorResult], }); }; const bulkUpdateMultiError = async ( - [obj1, _obj, obj2]: SavedObjectsBulkUpdateObject[], + [obj1, obj2, _obj]: SavedObjectsBulkUpdateObject[], options: SavedObjectsBulkUpdateOptions | undefined, mgetResponse: estypes.MgetResponse, mgetOptions?: { statusCode?: number } ) => { + client.bulk.mockClear(); + client.mget.mockClear(); + // we only need to mock the response once. A 404 status code will apply to the response for all client.mget.mockResponseOnce(mgetResponse, { statusCode: mgetOptions?.statusCode }); - const bulkResponse = getMockBulkUpdateResponse(registry, [obj1, obj2], { namespace }); - client.bulk.mockResponseOnce(bulkResponse); + const mockBulkIndexResponse = getMockBulkUpdateResponse(registry, [obj1, obj2], { + namespace, + }); + client.bulk.mockResponseOnce(mockBulkIndexResponse); - const result = await repository.bulkUpdate([obj1, _obj, obj2], options); - expect(client.bulk).toHaveBeenCalled(); - expect(client.mget).toHaveBeenCalled(); - const body = [...expectObjArgs(obj1), ...expectObjArgs(obj2)]; - expect(client.bulk).toHaveBeenCalledWith( - expect.objectContaining({ body }), - expect.anything() - ); + const result = await repository.bulkUpdate([obj1, obj2, _obj], options); - expect(result).toEqual({ - saved_objects: [expectSuccess(obj1), expectErrorNotFound(_obj), expectSuccess(obj2)], - }); + expect(client.mget).toHaveBeenCalled(); + // @TINA TODO: celan this up with a small parameterized function + if (mgetOptions?.statusCode === 404) { + expect(client.bulk).not.toHaveBeenCalled(); + expect(result).toEqual({ + saved_objects: [ + expectErrorNotFound(obj1), + expectErrorNotFound(obj2), + expectErrorNotFound(_obj), + ], + }); + } else { + expect(client.bulk).toHaveBeenCalled(); + expectClientCallArgsAction([obj1, obj2], { method: 'index' }); + + expect(result).toEqual({ + saved_objects: [expectSuccess(obj1), expectSuccess(obj2), expectErrorNotFound(_obj)], + }); + } }; it(`throws when options.namespace is '*'`, async () => { @@ -472,22 +493,22 @@ describe('SavedObjectsRepository', () => { it(`returns error when ES is unable to find the document (mget)`, async () => { const _obj = { ...obj, type: MULTI_NAMESPACE_ISOLATED_TYPE, found: false }; - const mgetResponse = getMockMgetResponse(registry, [_obj]); - await bulkUpdateMultiError([obj1, _obj, obj2], undefined, mgetResponse); + const mgetResponse = getMockMgetResponse(registry, [obj1, obj2, _obj]); + await bulkUpdateMultiError([obj1, obj2, _obj], undefined, mgetResponse); }); it(`returns error when ES is unable to find the index (mget)`, async () => { const _obj = { ...obj, type: MULTI_NAMESPACE_ISOLATED_TYPE }; - const mgetResponse = getMockMgetResponse(registry, [_obj]); - await bulkUpdateMultiError([obj1, _obj, obj2], { namespace }, mgetResponse, { + const mgetResponse = getMockMgetResponse(registry, [obj1, obj2, _obj]); + await bulkUpdateMultiError([obj1, obj2, _obj], { namespace }, mgetResponse, { statusCode: 404, }); }); it(`returns error when there is a conflict with an existing multi-namespace saved object (mget)`, async () => { const _obj = { ...obj, type: MULTI_NAMESPACE_ISOLATED_TYPE }; - const mgetResponse = getMockMgetResponse(registry, [_obj], 'bar-namespace'); - await bulkUpdateMultiError([obj1, _obj, obj2], { namespace }, mgetResponse); + const mgetResponse = getMockMgetResponse(registry, [obj1, obj2, _obj], 'bar-namespace'); + await bulkUpdateMultiError([obj1, obj2, _obj], { namespace }, mgetResponse); }); it(`returns bulk error`, async () => { diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.ts index f6ad9df9a8470..7e11191b40d8a 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.ts @@ -58,6 +58,8 @@ export const performBulkUpdate = async ( } = helpers; const { securityExtension } = extensions; + // console.log('the objects we are testing in unit test for errors:', JSON.stringify(objects)); + const namespace = commonHelper.getCurrentNamespace(options.namespace); const time = getCurrentTime(); @@ -90,6 +92,9 @@ export const performBulkUpdate = async ( migrationVersionCompatibility, } = object; let error: DecoratedError | undefined; + // console.log( + // `expectedBulkGetResults should return error as result for object that has "*" as namespace', type: ${type}, objectNamespace: ${objectNamespace}` + // ); if (!allowedTypes.includes(type)) { error = SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } else { @@ -179,7 +184,7 @@ export const performBulkUpdate = async ( let result; const { type, id, objectNamespace, esRequestIndex: index } = element.value; - const preflightResult = index !== undefined ? bulkGetResponse?.body.docs[index] : undefined; + const preflightResult = index !== undefined ? bulkGetResponse?.body?.docs[index] : undefined; if (registry.isMultiNamespace(type)) { result = { type, diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.test.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.test.ts index 1084ad3e58966..3547d653e3de4 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.test.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.test.ts @@ -32,7 +32,6 @@ import type { SavedObjectsIncrementCounterOptions, SavedObjectsCreatePointInTimeFinderDependencies, SavedObjectsCreatePointInTimeFinderOptions, - SavedObjectsBulkCreateObject, SavedObjectsBulkGetObject, SavedObjectsCreateOptions, SavedObjectsDeleteOptions, @@ -86,13 +85,10 @@ import { getMockMgetResponse, type TypeIdTuple, createSpySerializer, - bulkCreateSuccess, - getMockBulkCreateResponse, bulkGet, expectErrorResult, expectErrorInvalidType, expectErrorNotFound, - expectErrorConflict, expectError, generateIndexPatternSearchResults, findSuccess, @@ -105,7 +101,6 @@ import { createUnsupportedTypeErrorPayload, createConflictErrorPayload, createGenericNotFoundErrorPayload, - expectCreateResult, mockTimestampFieldsWithCreated, getMockEsBulkDeleteResponse, bulkDeleteSuccess, @@ -193,917 +188,6 @@ describe('SavedObjectsRepository', () => { references: [{ name: 'search_0', type: 'search', id: '123' }], }); - 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' }], - managed: false, - }; - const obj2 = { - type: 'index-pattern', - id: 'logstash-*', - attributes: { title: 'Test Two' }, - references: [{ name: 'ref_0', type: 'test', id: '2' }], - managed: false, - }; - const namespace = 'foo-namespace'; - - // bulk create calls have two objects for each source -- the action, and the source - const expectClientCallArgsAction = ( - objects: Array<{ type: string; id?: string; if_primary_term?: string; if_seq_no?: string }>, - { - method, - _index = expect.any(String), - getId = () => expect.any(String), - }: { method: string; _index?: string; getId?: (type: string, id?: string) => string } - ) => { - const body = []; - for (const { type, id, if_primary_term: ifPrimaryTerm, if_seq_no: ifSeqNo } of objects) { - body.push({ - [method]: { - _index, - _id: getId(type, id), - ...(ifPrimaryTerm && ifSeqNo - ? { if_primary_term: expect.any(Number), if_seq_no: expect.any(Number) } - : {}), - }, - }); - body.push(expect.any(Object)); - } - expect(client.bulk).toHaveBeenCalledWith( - expect.objectContaining({ body }), - expect.anything() - ); - }; - - const expectObjArgs = ( - { - type, - attributes, - references, - }: { type: string; attributes: unknown; references?: SavedObjectReference[] }, - overrides: Record = {} - ) => [ - expect.any(Object), - expect.objectContaining({ - [type]: attributes, - references, - type, - ...overrides, - ...mockTimestampFields, - }), - ]; - describe('client calls', () => { - it(`should use the ES bulk action by default`, async () => { - await bulkCreateSuccess(client, repository, [obj1, obj2]); - expect(client.bulk).toHaveBeenCalledTimes(1); - }); - - it(`should use the preflightCheckForCreate action before bulk action for any types that are multi-namespace, when id is defined`, async () => { - const objects = [obj1, { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }]; - await bulkCreateSuccess(client, repository, objects); - expect(client.bulk).toHaveBeenCalledTimes(1); - expect(mockPreflightCheckForCreate).toHaveBeenCalledTimes(1); - expect(mockPreflightCheckForCreate).toHaveBeenCalledWith( - expect.objectContaining({ - objects: [ - { - type: MULTI_NAMESPACE_ISOLATED_TYPE, - id: obj2.id, - overwrite: false, - namespaces: ['default'], - }, - ], - }) - ); - }); - - it(`should use the ES create method if ID is undefined and overwrite=true`, async () => { - const objects = [obj1, obj2].map((obj) => ({ ...obj, id: undefined })); - await bulkCreateSuccess(client, repository, objects, { overwrite: true }); - expectClientCallArgsAction(objects, { method: 'create' }); - }); - - it(`should use the ES create method if ID is undefined and overwrite=false`, async () => { - const objects = [obj1, obj2].map((obj) => ({ ...obj, id: undefined })); - await bulkCreateSuccess(client, repository, objects); - expectClientCallArgsAction(objects, { method: 'create' }); - }); - - it(`should use the ES index method if ID is defined and overwrite=true`, async () => { - await bulkCreateSuccess(client, repository, [obj1, obj2], { overwrite: true }); - expectClientCallArgsAction([obj1, obj2], { method: 'index' }); - }); - - it(`should use the ES index method with version if ID and version are defined and overwrite=true`, async () => { - await bulkCreateSuccess( - client, - repository, - [ - { - ...obj1, - version: mockVersion, - }, - obj2, - ], - { overwrite: true } - ); - - const obj1WithSeq = { - ...obj1, - managed: obj1.managed, - if_seq_no: mockVersionProps._seq_no, - if_primary_term: mockVersionProps._primary_term, - }; - - expectClientCallArgsAction([obj1WithSeq, obj2], { method: 'index' }); - }); - - it(`should use the ES create method if ID is defined and overwrite=false`, async () => { - await bulkCreateSuccess(client, repository, [obj1, obj2]); - expectClientCallArgsAction([obj1, obj2], { method: 'create' }); - }); - - it(`should use the ES index method if ID is defined, overwrite=true and managed=true in a document`, async () => { - await bulkCreateSuccess(client, repository, [obj1, obj2], { - overwrite: true, - managed: true, - }); - expectClientCallArgsAction([obj1, obj2], { method: 'index' }); - }); - - it(`should use the ES create method if ID is defined, overwrite=false and managed=true in a document`, async () => { - await bulkCreateSuccess(client, repository, [obj1, obj2], { managed: true }); - expectClientCallArgsAction([obj1, obj2], { method: 'create' }); - }); - - it(`formats the ES request`, async () => { - await bulkCreateSuccess(client, repository, [obj1, obj2]); - const body = [...expectObjArgs(obj1), ...expectObjArgs(obj2)]; - expect(client.bulk).toHaveBeenCalledWith( - expect.objectContaining({ body }), - expect.anything() - ); - }); - // this test only ensures that the client accepts the managed field in a document - it(`formats the ES request with managed=true in a document`, async () => { - const obj1WithManagedTrue = { ...obj1, managed: true }; - const obj2WithManagedTrue = { ...obj2, managed: true }; - await bulkCreateSuccess(client, repository, [obj1WithManagedTrue, obj2WithManagedTrue]); - const body = [...expectObjArgs(obj1WithManagedTrue), ...expectObjArgs(obj2WithManagedTrue)]; - expect(client.bulk).toHaveBeenCalledWith( - expect.objectContaining({ body }), - expect.anything() - ); - }); - - describe('originId', () => { - it(`returns error if originId is set for non-multi-namespace type`, async () => { - const result = await repository.bulkCreate([ - { ...obj1, originId: 'some-originId' }, - { ...obj2, type: NAMESPACE_AGNOSTIC_TYPE, originId: 'some-originId' }, - ]); - expect(result.saved_objects).toEqual([ - expect.objectContaining({ id: obj1.id, type: obj1.type, error: expect.anything() }), - expect.objectContaining({ - id: obj2.id, - type: NAMESPACE_AGNOSTIC_TYPE, - error: expect.anything(), - }), - ]); - expect(client.bulk).not.toHaveBeenCalled(); - }); - - it(`defaults to no originId`, async () => { - const objects = [ - { ...obj1, type: MULTI_NAMESPACE_TYPE }, - { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }, - ]; - - await bulkCreateSuccess(client, repository, objects); - const expected = expect.not.objectContaining({ originId: expect.anything() }); - const body = [expect.any(Object), expected, expect.any(Object), expected]; - expect(client.bulk).toHaveBeenCalledWith( - expect.objectContaining({ body }), - expect.anything() - ); - }); - - describe('with existing originId', () => { - beforeEach(() => { - mockPreflightCheckForCreate.mockImplementation(({ objects }) => { - const existingDocument = { - _source: { originId: 'existing-originId' }, - } as SavedObjectsRawDoc; - return Promise.resolve( - objects.map(({ type, id }) => ({ type, id, existingDocument })) - ); - }); - }); - - it(`accepts custom originId for multi-namespace type`, async () => { - // The preflight result has `existing-originId`, but that is discarded - const objects = [ - { ...obj1, type: MULTI_NAMESPACE_TYPE, originId: 'some-originId' }, - { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE, originId: 'some-originId' }, - ]; - await bulkCreateSuccess(client, repository, objects); - const expected = expect.objectContaining({ originId: 'some-originId' }); - const body = [expect.any(Object), expected, expect.any(Object), expected]; - expect(client.bulk).toHaveBeenCalledWith( - expect.objectContaining({ body }), - expect.anything() - ); - }); - - it(`accepts undefined originId`, async () => { - // The preflight result has `existing-originId`, but that is discarded - const objects = [ - { ...obj1, type: MULTI_NAMESPACE_TYPE, originId: undefined }, - { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE, originId: undefined }, - ]; - await bulkCreateSuccess(client, repository, objects); - const expected = expect.not.objectContaining({ originId: expect.anything() }); - const body = [expect.any(Object), expected, expect.any(Object), expected]; - expect(client.bulk).toHaveBeenCalledWith( - expect.objectContaining({ body }), - expect.anything() - ); - }); - - it(`preserves existing originId if originId option is not set`, async () => { - const objects = [ - { ...obj1, type: MULTI_NAMESPACE_TYPE }, - { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }, - ]; - await bulkCreateSuccess(client, repository, objects); - const expected = expect.objectContaining({ originId: 'existing-originId' }); - const body = [expect.any(Object), expected, expect.any(Object), expected]; - expect(client.bulk).toHaveBeenCalledWith( - expect.objectContaining({ body }), - expect.anything() - ); - }); - }); - }); - - it(`adds namespace to request body for any types that are single-namespace`, async () => { - await bulkCreateSuccess(client, repository, [obj1, obj2], { namespace }); - const expected = expect.objectContaining({ namespace }); - const body = [expect.any(Object), expected, expect.any(Object), expected]; - expect(client.bulk).toHaveBeenCalledWith( - expect.objectContaining({ body }), - expect.anything() - ); - }); - - // this only ensures we don't override any other options - it(`adds managed=false to request body if declared for any types that are single-namespace`, async () => { - await bulkCreateSuccess(client, repository, [obj1, obj2], { namespace, managed: false }); - const expected = expect.objectContaining({ namespace, managed: false }); - const body = [expect.any(Object), expected, expect.any(Object), expected]; - expect(client.bulk).toHaveBeenCalledWith( - expect.objectContaining({ body }), - expect.anything() - ); - }); - // this only ensures we don't override any other options - it(`adds managed=true to request body if declared for any types that are single-namespace`, async () => { - await bulkCreateSuccess(client, repository, [obj1, obj2], { namespace, managed: true }); - const expected = expect.objectContaining({ namespace, managed: true }); - const body = [expect.any(Object), expected, expect.any(Object), expected]; - expect(client.bulk).toHaveBeenCalledWith( - expect.objectContaining({ body }), - expect.anything() - ); - }); - - it(`normalizes options.namespace from 'default' to undefined`, async () => { - await bulkCreateSuccess(client, repository, [obj1, obj2], { namespace: 'default' }); - const expected = expect.not.objectContaining({ namespace: 'default' }); - const body = [expect.any(Object), expected, expect.any(Object), expected]; - expect(client.bulk).toHaveBeenCalledWith( - expect.objectContaining({ body }), - expect.anything() - ); - }); - - it(`doesn't add namespace to request body for any types that are not single-namespace`, async () => { - const objects = [ - { ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }, - { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }, - ]; - await bulkCreateSuccess(client, repository, objects, { namespace }); - const expected = expect.not.objectContaining({ namespace: expect.anything() }); - const body = [expect.any(Object), expected, expect.any(Object), expected]; - expect(client.bulk).toHaveBeenCalledWith( - expect.objectContaining({ body }), - expect.anything() - ); - }); - - it(`adds namespaces to request body for any types that are multi-namespace`, async () => { - const test = async (namespace?: string) => { - const objects = [obj1, obj2].map((x) => ({ ...x, type: MULTI_NAMESPACE_ISOLATED_TYPE })); - const [o1, o2] = objects; - mockPreflightCheckForCreate.mockResolvedValueOnce([ - { type: o1.type, id: o1.id! }, // first object does not have an existing document to overwrite - { - type: o2.type, - id: o2.id!, - existingDocument: { _id: o2.id!, _source: { namespaces: ['*'], type: o2.type } }, // second object does have an existing document to overwrite - }, - ]); - await bulkCreateSuccess(client, repository, objects, { namespace, overwrite: true }); - const expected1 = expect.objectContaining({ namespaces: [namespace ?? 'default'] }); - const expected2 = expect.objectContaining({ namespaces: ['*'] }); - const body = [expect.any(Object), expected1, expect.any(Object), expected2]; - expect(client.bulk).toHaveBeenCalledWith( - expect.objectContaining({ body }), - expect.anything() - ); - client.bulk.mockClear(); - mockPreflightCheckForCreate.mockReset(); - }; - await test(undefined); - await test(namespace); - }); - - it(`adds initialNamespaces instead of namespace`, async () => { - const test = async (namespace?: string) => { - const ns2 = 'bar-namespace'; - const ns3 = 'baz-namespace'; - const objects = [ - { ...obj1, type: 'dashboard', initialNamespaces: [ns2] }, - { ...obj1, type: MULTI_NAMESPACE_ISOLATED_TYPE, initialNamespaces: [ns2] }, - { ...obj1, type: MULTI_NAMESPACE_TYPE, initialNamespaces: [ns2, ns3] }, - ]; - const [o1, o2, o3] = objects; - mockPreflightCheckForCreate.mockResolvedValueOnce([ - // first object does not get passed in to preflightCheckForCreate at all - { type: o2.type, id: o2.id! }, // second object does not have an existing document to overwrite - { - type: o3.type, - id: o3.id!, - existingDocument: { - _id: o3.id!, - _source: { type: o3.type, namespaces: [namespace ?? 'default', 'something-else'] }, // third object does have an existing document to overwrite - }, - }, - ]); - await bulkCreateSuccess(client, repository, objects, { namespace, overwrite: true }); - const body = [ - { index: expect.objectContaining({ _id: `${ns2}:dashboard:${o1.id}` }) }, - expect.objectContaining({ namespace: ns2 }), - { - index: expect.objectContaining({ - _id: `${MULTI_NAMESPACE_ISOLATED_TYPE}:${o2.id}`, - }), - }, - expect.objectContaining({ namespaces: [ns2] }), - { index: expect.objectContaining({ _id: `${MULTI_NAMESPACE_TYPE}:${o3.id}` }) }, - expect.objectContaining({ namespaces: [ns2, ns3] }), - ]; - expect(mockPreflightCheckForCreate).toHaveBeenCalledWith( - expect.objectContaining({ - objects: [ - // assert that the initialNamespaces fields were passed into preflightCheckForCreate instead of the current namespace - { type: o2.type, id: o2.id, overwrite: true, namespaces: o2.initialNamespaces }, - { type: o3.type, id: o3.id, overwrite: true, namespaces: o3.initialNamespaces }, - ], - }) - ); - expect(client.bulk).toHaveBeenCalledWith( - expect.objectContaining({ body }), - expect.anything() - ); - client.bulk.mockClear(); - mockPreflightCheckForCreate.mockReset(); - }; - await test(undefined); - await test(namespace); - }); - - it(`normalizes initialNamespaces from 'default' to undefined`, async () => { - const test = async (namespace?: string) => { - const objects = [{ ...obj1, type: 'dashboard', initialNamespaces: ['default'] }]; - await bulkCreateSuccess(client, repository, objects, { namespace, overwrite: true }); - const body = [ - { index: expect.objectContaining({ _id: `dashboard:${obj1.id}` }) }, - expect.not.objectContaining({ namespace: 'default' }), - ]; - expect(client.bulk).toHaveBeenCalledWith( - expect.objectContaining({ body }), - expect.anything() - ); - client.bulk.mockClear(); - }; - await test(undefined); - await test(namespace); - }); - - it(`doesn't add namespaces to request body for any types that are not multi-namespace`, async () => { - const test = async (namespace?: string) => { - const objects = [obj1, { ...obj2, type: NAMESPACE_AGNOSTIC_TYPE }]; - await bulkCreateSuccess(client, repository, objects, { namespace, overwrite: true }); - const expected = expect.not.objectContaining({ namespaces: expect.anything() }); - const body = [expect.any(Object), expected, expect.any(Object), expected]; - expect(client.bulk).toHaveBeenCalledWith( - expect.objectContaining({ body }), - expect.anything() - ); - client.bulk.mockClear(); - }; - await test(undefined); - await test(namespace); - }); - - it(`defaults to a refresh setting of wait_for`, async () => { - await bulkCreateSuccess(client, repository, [obj1, obj2]); - expect(client.bulk).toHaveBeenCalledWith( - expect.objectContaining({ refresh: 'wait_for' }), - expect.anything() - ); - }); - - it(`should use default index`, async () => { - await bulkCreateSuccess(client, repository, [obj1, obj2]); - expectClientCallArgsAction([obj1, obj2], { - method: 'create', - _index: '.kibana-test_8.0.0-testing', - }); - }); - - it(`should use custom index`, async () => { - await bulkCreateSuccess( - client, - repository, - [obj1, obj2].map((x) => ({ ...x, type: CUSTOM_INDEX_TYPE })) - ); - expectClientCallArgsAction([obj1, obj2], { - method: 'create', - _index: 'custom_8.0.0-testing', - }); - }); - - it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => { - const getId = (type: string, id: string = '') => `${namespace}:${type}:${id}`; // test that the raw document ID equals this (e.g., has a namespace prefix) - await bulkCreateSuccess(client, repository, [obj1, obj2], { namespace }); - expectClientCallArgsAction([obj1, obj2], { method: 'create', getId }); - }); - - it(`doesn't prepend namespace to the id when providing no namespace for single-namespace type`, async () => { - const getId = (type: string, id: string = '') => `${type}:${id}`; // test that the raw document ID equals this (e.g., does not have a namespace prefix) - await bulkCreateSuccess(client, repository, [obj1, obj2]); - expectClientCallArgsAction([obj1, obj2], { method: 'create', getId }); - }); - - it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => { - const getId = (type: string, id: string = '') => `${type}:${id}`; // test that the raw document ID equals this (e.g., does not have a namespace prefix) - const objects = [ - { ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }, - { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }, - ]; - await bulkCreateSuccess(client, repository, objects, { namespace }); - expectClientCallArgsAction(objects, { method: 'create', getId }); - }); - }); - - describe('errors', () => { - afterEach(() => { - mockGetBulkOperationError.mockReset(); - }); - - const obj3 = { - type: 'dashboard', - id: 'three', - attributes: { title: 'Test Three' }, - references: [{ name: 'ref_0', type: 'test', id: '2' }], - }; - - const bulkCreateError = async ( - obj: SavedObjectsBulkCreateObject, - isBulkError: boolean | undefined, - expectedErrorResult: ExpectedErrorResult - ) => { - let response; - if (isBulkError) { - // mock the bulk error for only the second object - mockGetBulkOperationError.mockReturnValueOnce(undefined); - mockGetBulkOperationError.mockReturnValueOnce(expectedErrorResult.error as Payload); - response = getMockBulkCreateResponse([obj1, obj, obj2]); - } else { - response = getMockBulkCreateResponse([obj1, obj2]); - } - client.bulk.mockResponseOnce(response); - - const objects = [obj1, obj, obj2]; - const result = await repository.bulkCreate(objects); - expect(client.bulk).toHaveBeenCalled(); - const objCall = isBulkError ? expectObjArgs(obj) : []; - const body = [...expectObjArgs(obj1), ...objCall, ...expectObjArgs(obj2)]; - expect(client.bulk).toHaveBeenCalledWith( - expect.objectContaining({ body }), - expect.anything() - ); - expect(result).toEqual({ - saved_objects: [expectSuccess(obj1), expectedErrorResult, expectSuccess(obj2)], - }); - }; - - it(`throws when options.namespace is '*'`, async () => { - await expect( - repository.bulkCreate([obj3], { namespace: ALL_NAMESPACES_STRING }) - ).rejects.toThrowError(createBadRequestErrorPayload('"options.namespace" cannot be "*"')); - }); - - it(`returns error when initialNamespaces is used with a space-agnostic object`, async () => { - const obj = { ...obj3, type: NAMESPACE_AGNOSTIC_TYPE, initialNamespaces: [] }; - await bulkCreateError( - obj, - undefined, - expectErrorResult( - obj, - createBadRequestErrorPayload( - '"initialNamespaces" cannot be used on space-agnostic types' - ) - ) - ); - }); - - it(`returns error when initialNamespaces is empty`, async () => { - const obj = { ...obj3, type: MULTI_NAMESPACE_TYPE, initialNamespaces: [] }; - await bulkCreateError( - obj, - undefined, - expectErrorResult( - obj, - createBadRequestErrorPayload('"initialNamespaces" must be a non-empty array of strings') - ) - ); - }); - - it(`returns error when initialNamespaces is used with a space-isolated object and does not specify a single space`, async () => { - const doTest = async (objType: string, initialNamespaces: string[]) => { - const obj = { ...obj3, type: objType, initialNamespaces }; - await bulkCreateError( - obj, - undefined, - expectErrorResult( - obj, - createBadRequestErrorPayload( - '"initialNamespaces" can only specify a single space when used with space-isolated types' - ) - ) - ); - }; - await doTest('dashboard', ['spacex', 'spacey']); - await doTest('dashboard', ['*']); - await doTest(MULTI_NAMESPACE_ISOLATED_TYPE, ['spacex', 'spacey']); - await doTest(MULTI_NAMESPACE_ISOLATED_TYPE, ['*']); - }); - - it(`returns error when type is invalid`, async () => { - const obj = { ...obj3, type: 'unknownType' }; - await bulkCreateError(obj, undefined, expectErrorInvalidType(obj)); - }); - - it(`returns error when type is hidden`, async () => { - const obj = { ...obj3, type: HIDDEN_TYPE }; - await bulkCreateError(obj, undefined, expectErrorInvalidType(obj)); - }); - - it(`returns error when there is a conflict from preflightCheckForCreate`, async () => { - const objects = [ - // only the second, third, and fourth objects are passed to preflightCheckForCreate and result in errors - obj1, - { ...obj1, type: MULTI_NAMESPACE_TYPE }, - { ...obj2, type: MULTI_NAMESPACE_TYPE }, - { ...obj3, type: MULTI_NAMESPACE_TYPE }, - obj2, - ]; - const [o1, o2, o3, o4, o5] = objects; - mockPreflightCheckForCreate.mockResolvedValueOnce([ - // first and last objects do not get passed in to preflightCheckForCreate at all - { type: o2.type, id: o2.id!, error: { type: 'conflict' } }, - { - type: o3.type, - id: o3.id!, - error: { type: 'unresolvableConflict', metadata: { isNotOverwritable: true } }, - }, - { - type: o4.type, - id: o4.id!, - error: { type: 'aliasConflict', metadata: { spacesWithConflictingAliases: ['foo'] } }, - }, - ]); - const bulkResponse = getMockBulkCreateResponse([o1, o5]); - client.bulk.mockResponseOnce(bulkResponse); - - const options = { overwrite: true }; - const result = await repository.bulkCreate(objects, options); - expect(mockPreflightCheckForCreate).toHaveBeenCalled(); - expect(mockPreflightCheckForCreate).toHaveBeenCalledWith( - expect.objectContaining({ - objects: [ - { type: o2.type, id: o2.id, overwrite: true, namespaces: ['default'] }, - { type: o3.type, id: o3.id, overwrite: true, namespaces: ['default'] }, - { type: o4.type, id: o4.id, overwrite: true, namespaces: ['default'] }, - ], - }) - ); - expect(client.bulk).toHaveBeenCalled(); - expect(client.bulk).toHaveBeenCalledWith( - expect.objectContaining({ body: [...expectObjArgs(o1), ...expectObjArgs(o5)] }), - expect.anything() - ); - expect(result).toEqual({ - saved_objects: [ - expectSuccess(o1), - expectErrorConflict(o2), - expectErrorConflict(o3, { metadata: { isNotOverwritable: true } }), - expectErrorConflict(o4, { metadata: { spacesWithConflictingAliases: ['foo'] } }), - expectSuccess(o5), - ], - }); - }); - - it(`returns bulk error`, async () => { - const expectedErrorResult = { - type: obj3.type, - id: obj3.id, - error: { error: 'Oh no, a bulk error!' }, - }; - await bulkCreateError(obj3, true, expectedErrorResult); - }); - - it(`returns errors for any bulk objects with invalid schemas`, async () => { - const response = getMockBulkCreateResponse([obj3]); - client.bulk.mockResponseOnce(response); - - const result = await repository.bulkCreate([ - obj3, - // @ts-expect-error - Title should be a string and is intentionally malformed for testing - { ...obj3, id: 'three-again', attributes: { title: 123 } }, - ]); - expect(client.bulk).toHaveBeenCalledTimes(1); // only called once for the valid object - expect(result.saved_objects).toEqual([ - expect.objectContaining(obj3), - expect.objectContaining({ - error: new Error( - '[attributes.title]: expected value of type [string] but got [number]: Bad Request' - ), - id: 'three-again', - type: 'dashboard', - }), - ]); - }); - }); - - describe('migration', () => { - it(`migrates the docs and serializes the migrated docs`, async () => { - migrator.migrateDocument.mockImplementation(mockMigrateDocument); - const modifiedObj1 = { ...obj1, coreMigrationVersion: '8.0.0' }; - await bulkCreateSuccess(client, repository, [modifiedObj1, obj2]); - const docs = [modifiedObj1, obj2].map((x) => ({ ...x, ...mockTimestampFieldsWithCreated })); - expectMigrationArgs(docs[0], true, 1); - expectMigrationArgs(docs[1], true, 2); - - const migratedDocs = docs.map((x) => migrator.migrateDocument(x)); - expect(serializer.savedObjectToRaw).toHaveBeenNthCalledWith(1, migratedDocs[0]); - expect(serializer.savedObjectToRaw).toHaveBeenNthCalledWith(2, migratedDocs[1]); - }); - - it(`adds namespace to body when providing namespace for single-namespace type`, async () => { - await bulkCreateSuccess(client, repository, [obj1, obj2], { namespace }); - expectMigrationArgs({ namespace }, true, 1); - expectMigrationArgs({ namespace }, true, 2); - }); - - it(`doesn't add namespace to body when providing no namespace for single-namespace type`, async () => { - await bulkCreateSuccess(client, repository, [obj1, obj2]); - expectMigrationArgs({ namespace: expect.anything() }, false, 1); - expectMigrationArgs({ namespace: expect.anything() }, false, 2); - }); - - it(`doesn't add namespace to body when not using single-namespace type`, async () => { - const objects = [ - { ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }, - { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }, - ]; - await bulkCreateSuccess(client, repository, objects, { namespace }); - expectMigrationArgs({ namespace: expect.anything() }, false, 1); - expectMigrationArgs({ namespace: expect.anything() }, false, 2); - }); - - it(`adds namespaces to body when providing namespace for multi-namespace type`, async () => { - const objects = [obj1, obj2].map((obj) => ({ - ...obj, - type: MULTI_NAMESPACE_ISOLATED_TYPE, - })); - await bulkCreateSuccess(client, repository, objects, { namespace }); - expectMigrationArgs({ namespaces: [namespace] }, true, 1); - expectMigrationArgs({ namespaces: [namespace] }, true, 2); - }); - - it(`adds default namespaces to body when providing no namespace for multi-namespace type`, async () => { - const objects = [obj1, obj2].map((obj) => ({ - ...obj, - type: MULTI_NAMESPACE_ISOLATED_TYPE, - })); - await bulkCreateSuccess(client, repository, objects); - expectMigrationArgs({ namespaces: ['default'] }, true, 1); - expectMigrationArgs({ namespaces: ['default'] }, true, 2); - }); - - it(`doesn't add namespaces to body when not using multi-namespace type`, async () => { - const objects = [obj1, { ...obj2, type: NAMESPACE_AGNOSTIC_TYPE }]; - await bulkCreateSuccess(client, repository, objects); - expectMigrationArgs({ namespaces: expect.anything() }, false, 1); - expectMigrationArgs({ namespaces: expect.anything() }, false, 2); - }); - }); - - describe('returns', () => { - it(`formats the ES response`, async () => { - const result = await bulkCreateSuccess(client, repository, [obj1, obj2]); - expect(result).toEqual({ - saved_objects: [obj1, obj2].map((x) => expectCreateResult(x)), - }); - }); - - it.todo(`should return objects in the same order regardless of type`); - - it(`handles a mix of successful creates and errors`, async () => { - const obj = { - type: 'unknownType', - id: 'three', - attributes: {}, - }; - const objects = [obj1, obj, obj2]; - const response = getMockBulkCreateResponse([obj1, obj2]); - client.bulk.mockResponseOnce(response); - const result = await repository.bulkCreate(objects); - expect(client.bulk).toHaveBeenCalledTimes(1); - expect(result).toEqual({ - saved_objects: [expectCreateResult(obj1), expectError(obj), expectCreateResult(obj2)], - }); - }); - - it(`a deserialized saved object`, async () => { - // Test for fix to https://github.com/elastic/kibana/issues/65088 where - // we returned raw ID's when an object without an id was created. - const namespace = 'myspace'; - // FIXME: this test is based on a gigantic hack to have the bulk operation return the source - // of the document when it actually does not, forcing to cast to any as BulkResponse - // does not contains _source - const response = getMockBulkCreateResponse([obj1, obj2], namespace) as any; - client.bulk.mockResponseOnce(response); - - // Bulk create one object with id unspecified, and one with id specified - const result = await repository.bulkCreate([{ ...obj1, id: undefined }, obj2], { - namespace, - }); - - // Assert that both raw docs from the ES response are deserialized - expect(serializer.rawToSavedObject).toHaveBeenNthCalledWith( - 1, - { - ...response.items[0].create, - _source: { - ...response.items[0].create._source, - namespaces: response.items[0].create._source.namespaces, - coreMigrationVersion: expect.any(String), - typeMigrationVersion: '1.1.1', - }, - _id: expect.stringMatching( - /^myspace:config:[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$/ - ), - }, - expect.any(Object) - ); - expect(serializer.rawToSavedObject).toHaveBeenNthCalledWith( - 2, - { - ...response.items[1].create, - _source: { - ...response.items[1].create._source, - namespaces: response.items[1].create._source.namespaces, - coreMigrationVersion: expect.any(String), - typeMigrationVersion: '1.1.1', - }, - }, - expect.any(Object) - ); - - // Assert that ID's are deserialized to remove the type and namespace - expect(result.saved_objects[0].id).toEqual( - expect.stringMatching(/^[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$/) - ); - expect(result.saved_objects[1].id).toEqual(obj2.id); - - // Assert that managed is not changed - expect(result.saved_objects[0].managed).toBeFalsy(); - expect(result.saved_objects[1].managed).toEqual(obj2.managed); - }); - - it(`sets managed=false if not already set`, async () => { - const obj1WithoutManaged = { - type: 'config', - id: '6.0.0-alpha1', - attributes: { title: 'Test One' }, - references: [{ name: 'ref_0', type: 'test', id: '1' }], - }; - const obj2WithoutManaged = { - type: 'index-pattern', - id: 'logstash-*', - attributes: { title: 'Test Two' }, - references: [{ name: 'ref_0', type: 'test', id: '2' }], - }; - const result = await bulkCreateSuccess(client, repository, [ - obj1WithoutManaged, - obj2WithoutManaged, - ]); - expect(result).toEqual({ - saved_objects: [obj1, obj2].map((x) => expectCreateResult(x)), - }); - }); - - it(`sets managed=false only on documents without managed already set`, async () => { - const objWithoutManaged = { - type: 'config', - id: '6.0.0-alpha1', - attributes: { title: 'Test One' }, - references: [{ name: 'ref_0', type: 'test', id: '1' }], - }; - const result = await bulkCreateSuccess(client, repository, [objWithoutManaged, obj2]); - expect(result).toEqual({ - saved_objects: [obj1, obj2].map((x) => expectCreateResult(x)), - }); - }); - - it(`sets managed=true if provided as an override`, async () => { - const obj1WithoutManaged = { - type: 'config', - id: '6.0.0-alpha1', - attributes: { title: 'Test One' }, - references: [{ name: 'ref_0', type: 'test', id: '1' }], - }; - const obj2WithoutManaged = { - type: 'index-pattern', - id: 'logstash-*', - attributes: { title: 'Test Two' }, - references: [{ name: 'ref_0', type: 'test', id: '2' }], - }; - const result = await bulkCreateSuccess( - client, - repository, - [obj1WithoutManaged, obj2WithoutManaged], - { managed: true } - ); - expect(result).toEqual({ - saved_objects: [ - { ...obj1WithoutManaged, managed: true }, - { ...obj2WithoutManaged, managed: true }, - ].map((x) => expectCreateResult(x)), - }); - }); - - it(`sets managed=false if provided as an override`, async () => { - const obj1WithoutManaged = { - type: 'config', - id: '6.0.0-alpha1', - attributes: { title: 'Test One' }, - references: [{ name: 'ref_0', type: 'test', id: '1' }], - }; - const obj2WithoutManaged = { - type: 'index-pattern', - id: 'logstash-*', - attributes: { title: 'Test Two' }, - references: [{ name: 'ref_0', type: 'test', id: '2' }], - }; - const result = await bulkCreateSuccess( - client, - repository, - [obj1WithoutManaged, obj2WithoutManaged], - { managed: false } - ); - expect(result).toEqual({ - saved_objects: [obj1, obj2].map((x) => expectCreateResult(x)), - }); - }); - }); - }); - describe('#bulkGet', () => { const obj1: SavedObject = { type: 'config', From 237b0666189296e85cdd6d10d7ddad4fff48d3c5 Mon Sep 17 00:00:00 2001 From: "Christiane (Tina) Heiligers" Date: Fri, 17 Nov 2023 16:46:54 -0700 Subject: [PATCH 06/16] reverts splitting out bulk_create unit tests --- .../src/lib/apis/bulk_create.test.ts | 1048 ----------------- .../src/lib/repository.test.ts | 916 ++++++++++++++ 2 files changed, 916 insertions(+), 1048 deletions(-) delete mode 100644 packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_create.test.ts diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_create.test.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_create.test.ts deleted file mode 100644 index 659249edcc26b..0000000000000 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_create.test.ts +++ /dev/null @@ -1,1048 +0,0 @@ -/* - * 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. - */ - -/* eslint-disable @typescript-eslint/no-shadow */ - -import { - pointInTimeFinderMock, - mockGetBulkOperationError, - mockGetCurrentTime, - mockPreflightCheckForCreate, - mockGetSearchDsl, -} from '../repository.test.mock'; - -import type { Payload } from '@hapi/boom'; - -import type { SavedObjectsBulkCreateObject } from '@kbn/core-saved-objects-api-server'; -import { - type SavedObjectsRawDoc, - type SavedObjectUnsanitizedDoc, - type SavedObjectReference, -} from '@kbn/core-saved-objects-server'; -import { ALL_NAMESPACES_STRING } from '@kbn/core-saved-objects-utils-server'; -import { SavedObjectsRepository } from '../repository'; -import { loggerMock } from '@kbn/logging-mocks'; -import { SavedObjectsSerializer } from '@kbn/core-saved-objects-base-server-internal'; -import { kibanaMigratorMock } from '../../mocks'; -import { elasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks'; - -import { - CUSTOM_INDEX_TYPE, - NAMESPACE_AGNOSTIC_TYPE, - MULTI_NAMESPACE_TYPE, - MULTI_NAMESPACE_ISOLATED_TYPE, - HIDDEN_TYPE, - mockVersionProps, - mockTimestampFields, - mockTimestamp, - mappings, - mockVersion, - createRegistry, - createDocumentMigrator, - createSpySerializer, - bulkCreateSuccess, - getMockBulkCreateResponse, - expectErrorResult, - expectErrorInvalidType, - expectErrorConflict, - expectError, - createBadRequestErrorPayload, - expectCreateResult, - mockTimestampFieldsWithCreated, -} from '../../test_helpers/repository.test.common'; - -// 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. - -interface ExpectedErrorResult { - type: string; - id: string; - error: Record; -} - -describe('SavedObjectsRepository', () => { - let client: ReturnType; - let repository: SavedObjectsRepository; - let migrator: ReturnType; - let logger: ReturnType; - let serializer: jest.Mocked; - - const registry = createRegistry(); - const documentMigrator = createDocumentMigrator(registry); - - const expectSuccess = ({ type, id }: { type: string; id: string }) => { - // @ts-expect-error TS is not aware of the extension - return expect.toBeDocumentWithoutError(type, id); - }; - - const expectMigrationArgs = (args: unknown, contains = true, n = 1) => { - const obj = contains ? expect.objectContaining(args) : expect.not.objectContaining(args); - expect(migrator.migrateDocument).toHaveBeenNthCalledWith( - n, - obj, - expect.objectContaining({ - allowDowngrade: expect.any(Boolean), - }) - ); - }; - - 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); - - 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 - repository = new SavedObjectsRepository({ - index: '.kibana-test', - mappings, - client, - migrator, - typeRegistry: registry, - serializer, - allowedTypes, - logger, - }); - - mockGetCurrentTime.mockReturnValue(mockTimestamp); - mockGetSearchDsl.mockClear(); - }); - - // Setup migration mock for creating an object - const mockMigrationVersion = { foo: '2.3.4' }; - const mockMigrateDocument = (doc: SavedObjectUnsanitizedDoc) => ({ - ...doc, - attributes: { - ...doc.attributes, - ...(doc.attributes?.title && { title: `${doc.attributes.title}!!` }), - }, - migrationVersion: mockMigrationVersion, - managed: doc.managed ?? false, - references: [{ name: 'search_0', type: 'search', id: '123' }], - }); - - 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' }], - managed: false, - }; - const obj2 = { - type: 'index-pattern', - id: 'logstash-*', - attributes: { title: 'Test Two' }, - references: [{ name: 'ref_0', type: 'test', id: '2' }], - managed: false, - }; - const namespace = 'foo-namespace'; - - // bulk create calls have two objects for each source -- the action, and the source - const expectClientCallArgsAction = ( - objects: Array<{ type: string; id?: string; if_primary_term?: string; if_seq_no?: string }>, - { - method, - _index = expect.any(String), - getId = () => expect.any(String), - }: { method: string; _index?: string; getId?: (type: string, id?: string) => string } - ) => { - const body = []; - for (const { type, id, if_primary_term: ifPrimaryTerm, if_seq_no: ifSeqNo } of objects) { - body.push({ - [method]: { - _index, - _id: getId(type, id), - ...(ifPrimaryTerm && ifSeqNo - ? { if_primary_term: expect.any(Number), if_seq_no: expect.any(Number) } - : {}), - }, - }); - body.push(expect.any(Object)); - } - expect(client.bulk).toHaveBeenCalledWith( - expect.objectContaining({ body }), - expect.anything() - ); - }; - - const expectObjArgs = ( - { - type, - attributes, - references, - }: { type: string; attributes: unknown; references?: SavedObjectReference[] }, - overrides: Record = {} - ) => [ - expect.any(Object), - expect.objectContaining({ - [type]: attributes, - references, - type, - ...overrides, - ...mockTimestampFields, - }), - ]; - describe('client calls', () => { - it(`should use the ES bulk action by default`, async () => { - await bulkCreateSuccess(client, repository, [obj1, obj2]); - expect(client.bulk).toHaveBeenCalledTimes(1); - }); - - it(`should use the preflightCheckForCreate action before bulk action for any types that are multi-namespace, when id is defined`, async () => { - const objects = [obj1, { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }]; - await bulkCreateSuccess(client, repository, objects); - expect(client.bulk).toHaveBeenCalledTimes(1); - expect(mockPreflightCheckForCreate).toHaveBeenCalledTimes(1); - expect(mockPreflightCheckForCreate).toHaveBeenCalledWith( - expect.objectContaining({ - objects: [ - { - type: MULTI_NAMESPACE_ISOLATED_TYPE, - id: obj2.id, - overwrite: false, - namespaces: ['default'], - }, - ], - }) - ); - }); - - it(`should use the ES create method if ID is undefined and overwrite=true`, async () => { - const objects = [obj1, obj2].map((obj) => ({ ...obj, id: undefined })); - await bulkCreateSuccess(client, repository, objects, { overwrite: true }); - expectClientCallArgsAction(objects, { method: 'create' }); - }); - - it(`should use the ES create method if ID is undefined and overwrite=false`, async () => { - const objects = [obj1, obj2].map((obj) => ({ ...obj, id: undefined })); - await bulkCreateSuccess(client, repository, objects); - expectClientCallArgsAction(objects, { method: 'create' }); - }); - - it(`should use the ES index method if ID is defined and overwrite=true`, async () => { - await bulkCreateSuccess(client, repository, [obj1, obj2], { overwrite: true }); - expectClientCallArgsAction([obj1, obj2], { method: 'index' }); - }); - - it(`should use the ES index method with version if ID and version are defined and overwrite=true`, async () => { - await bulkCreateSuccess( - client, - repository, - [ - { - ...obj1, - version: mockVersion, - }, - obj2, - ], - { overwrite: true } - ); - - const obj1WithSeq = { - ...obj1, - managed: obj1.managed, - if_seq_no: mockVersionProps._seq_no, - if_primary_term: mockVersionProps._primary_term, - }; - - expectClientCallArgsAction([obj1WithSeq, obj2], { method: 'index' }); - }); - - it(`should use the ES create method if ID is defined and overwrite=false`, async () => { - await bulkCreateSuccess(client, repository, [obj1, obj2]); - expectClientCallArgsAction([obj1, obj2], { method: 'create' }); - }); - - it(`should use the ES index method if ID is defined, overwrite=true and managed=true in a document`, async () => { - await bulkCreateSuccess(client, repository, [obj1, obj2], { - overwrite: true, - managed: true, - }); - expectClientCallArgsAction([obj1, obj2], { method: 'index' }); - }); - - it(`should use the ES create method if ID is defined, overwrite=false and managed=true in a document`, async () => { - await bulkCreateSuccess(client, repository, [obj1, obj2], { managed: true }); - expectClientCallArgsAction([obj1, obj2], { method: 'create' }); - }); - - it(`formats the ES request`, async () => { - await bulkCreateSuccess(client, repository, [obj1, obj2]); - const body = [...expectObjArgs(obj1), ...expectObjArgs(obj2)]; - expect(client.bulk).toHaveBeenCalledWith( - expect.objectContaining({ body }), - expect.anything() - ); - }); - // this test only ensures that the client accepts the managed field in a document - it(`formats the ES request with managed=true in a document`, async () => { - const obj1WithManagedTrue = { ...obj1, managed: true }; - const obj2WithManagedTrue = { ...obj2, managed: true }; - await bulkCreateSuccess(client, repository, [obj1WithManagedTrue, obj2WithManagedTrue]); - const body = [...expectObjArgs(obj1WithManagedTrue), ...expectObjArgs(obj2WithManagedTrue)]; - expect(client.bulk).toHaveBeenCalledWith( - expect.objectContaining({ body }), - expect.anything() - ); - }); - - describe('originId', () => { - it(`returns error if originId is set for non-multi-namespace type`, async () => { - const result = await repository.bulkCreate([ - { ...obj1, originId: 'some-originId' }, - { ...obj2, type: NAMESPACE_AGNOSTIC_TYPE, originId: 'some-originId' }, - ]); - expect(result.saved_objects).toEqual([ - expect.objectContaining({ id: obj1.id, type: obj1.type, error: expect.anything() }), - expect.objectContaining({ - id: obj2.id, - type: NAMESPACE_AGNOSTIC_TYPE, - error: expect.anything(), - }), - ]); - expect(client.bulk).not.toHaveBeenCalled(); - }); - - it(`defaults to no originId`, async () => { - const objects = [ - { ...obj1, type: MULTI_NAMESPACE_TYPE }, - { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }, - ]; - - await bulkCreateSuccess(client, repository, objects); - const expected = expect.not.objectContaining({ originId: expect.anything() }); - const body = [expect.any(Object), expected, expect.any(Object), expected]; - expect(client.bulk).toHaveBeenCalledWith( - expect.objectContaining({ body }), - expect.anything() - ); - }); - - describe('with existing originId', () => { - beforeEach(() => { - mockPreflightCheckForCreate.mockImplementation(({ objects }) => { - const existingDocument = { - _source: { originId: 'existing-originId' }, - } as SavedObjectsRawDoc; - return Promise.resolve( - objects.map(({ type, id }) => ({ type, id, existingDocument })) - ); - }); - }); - - it(`accepts custom originId for multi-namespace type`, async () => { - // The preflight result has `existing-originId`, but that is discarded - const objects = [ - { ...obj1, type: MULTI_NAMESPACE_TYPE, originId: 'some-originId' }, - { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE, originId: 'some-originId' }, - ]; - await bulkCreateSuccess(client, repository, objects); - const expected = expect.objectContaining({ originId: 'some-originId' }); - const body = [expect.any(Object), expected, expect.any(Object), expected]; - expect(client.bulk).toHaveBeenCalledWith( - expect.objectContaining({ body }), - expect.anything() - ); - }); - - it(`accepts undefined originId`, async () => { - // The preflight result has `existing-originId`, but that is discarded - const objects = [ - { ...obj1, type: MULTI_NAMESPACE_TYPE, originId: undefined }, - { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE, originId: undefined }, - ]; - await bulkCreateSuccess(client, repository, objects); - const expected = expect.not.objectContaining({ originId: expect.anything() }); - const body = [expect.any(Object), expected, expect.any(Object), expected]; - expect(client.bulk).toHaveBeenCalledWith( - expect.objectContaining({ body }), - expect.anything() - ); - }); - - it(`preserves existing originId if originId option is not set`, async () => { - const objects = [ - { ...obj1, type: MULTI_NAMESPACE_TYPE }, - { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }, - ]; - await bulkCreateSuccess(client, repository, objects); - const expected = expect.objectContaining({ originId: 'existing-originId' }); - const body = [expect.any(Object), expected, expect.any(Object), expected]; - expect(client.bulk).toHaveBeenCalledWith( - expect.objectContaining({ body }), - expect.anything() - ); - }); - }); - }); - - it(`adds namespace to request body for any types that are single-namespace`, async () => { - await bulkCreateSuccess(client, repository, [obj1, obj2], { namespace }); - const expected = expect.objectContaining({ namespace }); - const body = [expect.any(Object), expected, expect.any(Object), expected]; - expect(client.bulk).toHaveBeenCalledWith( - expect.objectContaining({ body }), - expect.anything() - ); - }); - - // this only ensures we don't override any other options - it(`adds managed=false to request body if declared for any types that are single-namespace`, async () => { - await bulkCreateSuccess(client, repository, [obj1, obj2], { namespace, managed: false }); - const expected = expect.objectContaining({ namespace, managed: false }); - const body = [expect.any(Object), expected, expect.any(Object), expected]; - expect(client.bulk).toHaveBeenCalledWith( - expect.objectContaining({ body }), - expect.anything() - ); - }); - // this only ensures we don't override any other options - it(`adds managed=true to request body if declared for any types that are single-namespace`, async () => { - await bulkCreateSuccess(client, repository, [obj1, obj2], { namespace, managed: true }); - const expected = expect.objectContaining({ namespace, managed: true }); - const body = [expect.any(Object), expected, expect.any(Object), expected]; - expect(client.bulk).toHaveBeenCalledWith( - expect.objectContaining({ body }), - expect.anything() - ); - }); - - it(`normalizes options.namespace from 'default' to undefined`, async () => { - await bulkCreateSuccess(client, repository, [obj1, obj2], { namespace: 'default' }); - const expected = expect.not.objectContaining({ namespace: 'default' }); - const body = [expect.any(Object), expected, expect.any(Object), expected]; - expect(client.bulk).toHaveBeenCalledWith( - expect.objectContaining({ body }), - expect.anything() - ); - }); - - it(`doesn't add namespace to request body for any types that are not single-namespace`, async () => { - const objects = [ - { ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }, - { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }, - ]; - await bulkCreateSuccess(client, repository, objects, { namespace }); - const expected = expect.not.objectContaining({ namespace: expect.anything() }); - const body = [expect.any(Object), expected, expect.any(Object), expected]; - expect(client.bulk).toHaveBeenCalledWith( - expect.objectContaining({ body }), - expect.anything() - ); - }); - - it(`adds namespaces to request body for any types that are multi-namespace`, async () => { - const test = async (namespace?: string) => { - const objects = [obj1, obj2].map((x) => ({ ...x, type: MULTI_NAMESPACE_ISOLATED_TYPE })); - const [o1, o2] = objects; - mockPreflightCheckForCreate.mockResolvedValueOnce([ - { type: o1.type, id: o1.id! }, // first object does not have an existing document to overwrite - { - type: o2.type, - id: o2.id!, - existingDocument: { _id: o2.id!, _source: { namespaces: ['*'], type: o2.type } }, // second object does have an existing document to overwrite - }, - ]); - await bulkCreateSuccess(client, repository, objects, { namespace, overwrite: true }); - const expected1 = expect.objectContaining({ namespaces: [namespace ?? 'default'] }); - const expected2 = expect.objectContaining({ namespaces: ['*'] }); - const body = [expect.any(Object), expected1, expect.any(Object), expected2]; - expect(client.bulk).toHaveBeenCalledWith( - expect.objectContaining({ body }), - expect.anything() - ); - client.bulk.mockClear(); - mockPreflightCheckForCreate.mockReset(); - }; - await test(undefined); - await test(namespace); - }); - - it(`adds initialNamespaces instead of namespace`, async () => { - const test = async (namespace?: string) => { - const ns2 = 'bar-namespace'; - const ns3 = 'baz-namespace'; - const objects = [ - { ...obj1, type: 'dashboard', initialNamespaces: [ns2] }, - { ...obj1, type: MULTI_NAMESPACE_ISOLATED_TYPE, initialNamespaces: [ns2] }, - { ...obj1, type: MULTI_NAMESPACE_TYPE, initialNamespaces: [ns2, ns3] }, - ]; - const [o1, o2, o3] = objects; - mockPreflightCheckForCreate.mockResolvedValueOnce([ - // first object does not get passed in to preflightCheckForCreate at all - { type: o2.type, id: o2.id! }, // second object does not have an existing document to overwrite - { - type: o3.type, - id: o3.id!, - existingDocument: { - _id: o3.id!, - _source: { type: o3.type, namespaces: [namespace ?? 'default', 'something-else'] }, // third object does have an existing document to overwrite - }, - }, - ]); - await bulkCreateSuccess(client, repository, objects, { namespace, overwrite: true }); - const body = [ - { index: expect.objectContaining({ _id: `${ns2}:dashboard:${o1.id}` }) }, - expect.objectContaining({ namespace: ns2 }), - { - index: expect.objectContaining({ - _id: `${MULTI_NAMESPACE_ISOLATED_TYPE}:${o2.id}`, - }), - }, - expect.objectContaining({ namespaces: [ns2] }), - { index: expect.objectContaining({ _id: `${MULTI_NAMESPACE_TYPE}:${o3.id}` }) }, - expect.objectContaining({ namespaces: [ns2, ns3] }), - ]; - expect(mockPreflightCheckForCreate).toHaveBeenCalledWith( - expect.objectContaining({ - objects: [ - // assert that the initialNamespaces fields were passed into preflightCheckForCreate instead of the current namespace - { type: o2.type, id: o2.id, overwrite: true, namespaces: o2.initialNamespaces }, - { type: o3.type, id: o3.id, overwrite: true, namespaces: o3.initialNamespaces }, - ], - }) - ); - expect(client.bulk).toHaveBeenCalledWith( - expect.objectContaining({ body }), - expect.anything() - ); - client.bulk.mockClear(); - mockPreflightCheckForCreate.mockReset(); - }; - await test(undefined); - await test(namespace); - }); - - it(`normalizes initialNamespaces from 'default' to undefined`, async () => { - const test = async (namespace?: string) => { - const objects = [{ ...obj1, type: 'dashboard', initialNamespaces: ['default'] }]; - await bulkCreateSuccess(client, repository, objects, { namespace, overwrite: true }); - const body = [ - { index: expect.objectContaining({ _id: `dashboard:${obj1.id}` }) }, - expect.not.objectContaining({ namespace: 'default' }), - ]; - expect(client.bulk).toHaveBeenCalledWith( - expect.objectContaining({ body }), - expect.anything() - ); - client.bulk.mockClear(); - }; - await test(undefined); - await test(namespace); - }); - - it(`doesn't add namespaces to request body for any types that are not multi-namespace`, async () => { - const test = async (namespace?: string) => { - const objects = [obj1, { ...obj2, type: NAMESPACE_AGNOSTIC_TYPE }]; - await bulkCreateSuccess(client, repository, objects, { namespace, overwrite: true }); - const expected = expect.not.objectContaining({ namespaces: expect.anything() }); - const body = [expect.any(Object), expected, expect.any(Object), expected]; - expect(client.bulk).toHaveBeenCalledWith( - expect.objectContaining({ body }), - expect.anything() - ); - client.bulk.mockClear(); - }; - await test(undefined); - await test(namespace); - }); - - it(`defaults to a refresh setting of wait_for`, async () => { - await bulkCreateSuccess(client, repository, [obj1, obj2]); - expect(client.bulk).toHaveBeenCalledWith( - expect.objectContaining({ refresh: 'wait_for' }), - expect.anything() - ); - }); - - it(`should use default index`, async () => { - await bulkCreateSuccess(client, repository, [obj1, obj2]); - expectClientCallArgsAction([obj1, obj2], { - method: 'create', - _index: '.kibana-test_8.0.0-testing', - }); - }); - - it(`should use custom index`, async () => { - await bulkCreateSuccess( - client, - repository, - [obj1, obj2].map((x) => ({ ...x, type: CUSTOM_INDEX_TYPE })) - ); - expectClientCallArgsAction([obj1, obj2], { - method: 'create', - _index: 'custom_8.0.0-testing', - }); - }); - - it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => { - const getId = (type: string, id: string = '') => `${namespace}:${type}:${id}`; // test that the raw document ID equals this (e.g., has a namespace prefix) - await bulkCreateSuccess(client, repository, [obj1, obj2], { namespace }); - expectClientCallArgsAction([obj1, obj2], { method: 'create', getId }); - }); - - it(`doesn't prepend namespace to the id when providing no namespace for single-namespace type`, async () => { - const getId = (type: string, id: string = '') => `${type}:${id}`; // test that the raw document ID equals this (e.g., does not have a namespace prefix) - await bulkCreateSuccess(client, repository, [obj1, obj2]); - expectClientCallArgsAction([obj1, obj2], { method: 'create', getId }); - }); - - it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => { - const getId = (type: string, id: string = '') => `${type}:${id}`; // test that the raw document ID equals this (e.g., does not have a namespace prefix) - const objects = [ - { ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }, - { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }, - ]; - await bulkCreateSuccess(client, repository, objects, { namespace }); - expectClientCallArgsAction(objects, { method: 'create', getId }); - }); - }); - - describe('errors', () => { - afterEach(() => { - mockGetBulkOperationError.mockReset(); - }); - - const obj3 = { - type: 'dashboard', - id: 'three', - attributes: { title: 'Test Three' }, - references: [{ name: 'ref_0', type: 'test', id: '2' }], - }; - - const bulkCreateError = async ( - obj: SavedObjectsBulkCreateObject, - isBulkError: boolean | undefined, - expectedErrorResult: ExpectedErrorResult - ) => { - let response; - if (isBulkError) { - // mock the bulk error for only the second object - mockGetBulkOperationError.mockReturnValueOnce(undefined); - mockGetBulkOperationError.mockReturnValueOnce(expectedErrorResult.error as Payload); - response = getMockBulkCreateResponse([obj1, obj, obj2]); - } else { - response = getMockBulkCreateResponse([obj1, obj2]); - } - client.bulk.mockResponseOnce(response); - - const objects = [obj1, obj, obj2]; - const result = await repository.bulkCreate(objects); - expect(client.bulk).toHaveBeenCalled(); - const objCall = isBulkError ? expectObjArgs(obj) : []; - const body = [...expectObjArgs(obj1), ...objCall, ...expectObjArgs(obj2)]; - expect(client.bulk).toHaveBeenCalledWith( - expect.objectContaining({ body }), - expect.anything() - ); - expect(result).toEqual({ - saved_objects: [expectSuccess(obj1), expectedErrorResult, expectSuccess(obj2)], - }); - }; - - it(`throws when options.namespace is '*'`, async () => { - await expect( - repository.bulkCreate([obj3], { namespace: ALL_NAMESPACES_STRING }) - ).rejects.toThrowError(createBadRequestErrorPayload('"options.namespace" cannot be "*"')); - }); - - it(`returns error when initialNamespaces is used with a space-agnostic object`, async () => { - const obj = { ...obj3, type: NAMESPACE_AGNOSTIC_TYPE, initialNamespaces: [] }; - await bulkCreateError( - obj, - undefined, - expectErrorResult( - obj, - createBadRequestErrorPayload( - '"initialNamespaces" cannot be used on space-agnostic types' - ) - ) - ); - }); - - it(`returns error when initialNamespaces is empty`, async () => { - const obj = { ...obj3, type: MULTI_NAMESPACE_TYPE, initialNamespaces: [] }; - await bulkCreateError( - obj, - undefined, - expectErrorResult( - obj, - createBadRequestErrorPayload('"initialNamespaces" must be a non-empty array of strings') - ) - ); - }); - - it(`returns error when initialNamespaces is used with a space-isolated object and does not specify a single space`, async () => { - const doTest = async (objType: string, initialNamespaces: string[]) => { - const obj = { ...obj3, type: objType, initialNamespaces }; - await bulkCreateError( - obj, - undefined, - expectErrorResult( - obj, - createBadRequestErrorPayload( - '"initialNamespaces" can only specify a single space when used with space-isolated types' - ) - ) - ); - }; - await doTest('dashboard', ['spacex', 'spacey']); - await doTest('dashboard', ['*']); - await doTest(MULTI_NAMESPACE_ISOLATED_TYPE, ['spacex', 'spacey']); - await doTest(MULTI_NAMESPACE_ISOLATED_TYPE, ['*']); - }); - - it.only(`returns error when type is invalid`, async () => { - const obj = { ...obj3, type: 'unknownType' }; - await bulkCreateError(obj, undefined, expectErrorInvalidType(obj)); - }); - - it(`returns error when type is hidden`, async () => { - const obj = { ...obj3, type: HIDDEN_TYPE }; - await bulkCreateError(obj, undefined, expectErrorInvalidType(obj)); - }); - - it(`returns error when there is a conflict from preflightCheckForCreate`, async () => { - const objects = [ - // only the second, third, and fourth objects are passed to preflightCheckForCreate and result in errors - obj1, - { ...obj1, type: MULTI_NAMESPACE_TYPE }, - { ...obj2, type: MULTI_NAMESPACE_TYPE }, - { ...obj3, type: MULTI_NAMESPACE_TYPE }, - obj2, - ]; - const [o1, o2, o3, o4, o5] = objects; - mockPreflightCheckForCreate.mockResolvedValueOnce([ - // first and last objects do not get passed in to preflightCheckForCreate at all - { type: o2.type, id: o2.id!, error: { type: 'conflict' } }, - { - type: o3.type, - id: o3.id!, - error: { type: 'unresolvableConflict', metadata: { isNotOverwritable: true } }, - }, - { - type: o4.type, - id: o4.id!, - error: { type: 'aliasConflict', metadata: { spacesWithConflictingAliases: ['foo'] } }, - }, - ]); - const bulkResponse = getMockBulkCreateResponse([o1, o5]); - client.bulk.mockResponseOnce(bulkResponse); - - const options = { overwrite: true }; - const result = await repository.bulkCreate(objects, options); - expect(mockPreflightCheckForCreate).toHaveBeenCalled(); - expect(mockPreflightCheckForCreate).toHaveBeenCalledWith( - expect.objectContaining({ - objects: [ - { type: o2.type, id: o2.id, overwrite: true, namespaces: ['default'] }, - { type: o3.type, id: o3.id, overwrite: true, namespaces: ['default'] }, - { type: o4.type, id: o4.id, overwrite: true, namespaces: ['default'] }, - ], - }) - ); - expect(client.bulk).toHaveBeenCalled(); - expect(client.bulk).toHaveBeenCalledWith( - expect.objectContaining({ body: [...expectObjArgs(o1), ...expectObjArgs(o5)] }), - expect.anything() - ); - expect(result).toEqual({ - saved_objects: [ - expectSuccess(o1), - expectErrorConflict(o2), - expectErrorConflict(o3, { metadata: { isNotOverwritable: true } }), - expectErrorConflict(o4, { metadata: { spacesWithConflictingAliases: ['foo'] } }), - expectSuccess(o5), - ], - }); - }); - - it(`returns bulk error`, async () => { - const expectedErrorResult = { - type: obj3.type, - id: obj3.id, - error: { error: 'Oh no, a bulk error!' }, - }; - await bulkCreateError(obj3, true, expectedErrorResult); - }); - - it(`returns errors for any bulk objects with invalid schemas`, async () => { - const response = getMockBulkCreateResponse([obj3]); - client.bulk.mockResponseOnce(response); - - const result = await repository.bulkCreate([ - obj3, - // @ts-expect-error - Title should be a string and is intentionally malformed for testing - { ...obj3, id: 'three-again', attributes: { title: 123 } }, - ]); - expect(client.bulk).toHaveBeenCalledTimes(1); // only called once for the valid object - expect(result.saved_objects).toEqual([ - expect.objectContaining(obj3), - expect.objectContaining({ - error: new Error( - '[attributes.title]: expected value of type [string] but got [number]: Bad Request' - ), - id: 'three-again', - type: 'dashboard', - }), - ]); - }); - }); - - describe('migration', () => { - it(`migrates the docs and serializes the migrated docs`, async () => { - migrator.migrateDocument.mockImplementation(mockMigrateDocument); - const modifiedObj1 = { ...obj1, coreMigrationVersion: '8.0.0' }; - await bulkCreateSuccess(client, repository, [modifiedObj1, obj2]); - const docs = [modifiedObj1, obj2].map((x) => ({ ...x, ...mockTimestampFieldsWithCreated })); - expectMigrationArgs(docs[0], true, 1); - expectMigrationArgs(docs[1], true, 2); - - const migratedDocs = docs.map((x) => migrator.migrateDocument(x)); - expect(serializer.savedObjectToRaw).toHaveBeenNthCalledWith(1, migratedDocs[0]); - expect(serializer.savedObjectToRaw).toHaveBeenNthCalledWith(2, migratedDocs[1]); - }); - - it(`adds namespace to body when providing namespace for single-namespace type`, async () => { - await bulkCreateSuccess(client, repository, [obj1, obj2], { namespace }); - expectMigrationArgs({ namespace }, true, 1); - expectMigrationArgs({ namespace }, true, 2); - }); - - it(`doesn't add namespace to body when providing no namespace for single-namespace type`, async () => { - await bulkCreateSuccess(client, repository, [obj1, obj2]); - expectMigrationArgs({ namespace: expect.anything() }, false, 1); - expectMigrationArgs({ namespace: expect.anything() }, false, 2); - }); - - it(`doesn't add namespace to body when not using single-namespace type`, async () => { - const objects = [ - { ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }, - { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }, - ]; - await bulkCreateSuccess(client, repository, objects, { namespace }); - expectMigrationArgs({ namespace: expect.anything() }, false, 1); - expectMigrationArgs({ namespace: expect.anything() }, false, 2); - }); - - it(`adds namespaces to body when providing namespace for multi-namespace type`, async () => { - const objects = [obj1, obj2].map((obj) => ({ - ...obj, - type: MULTI_NAMESPACE_ISOLATED_TYPE, - })); - await bulkCreateSuccess(client, repository, objects, { namespace }); - expectMigrationArgs({ namespaces: [namespace] }, true, 1); - expectMigrationArgs({ namespaces: [namespace] }, true, 2); - }); - - it(`adds default namespaces to body when providing no namespace for multi-namespace type`, async () => { - const objects = [obj1, obj2].map((obj) => ({ - ...obj, - type: MULTI_NAMESPACE_ISOLATED_TYPE, - })); - await bulkCreateSuccess(client, repository, objects); - expectMigrationArgs({ namespaces: ['default'] }, true, 1); - expectMigrationArgs({ namespaces: ['default'] }, true, 2); - }); - - it(`doesn't add namespaces to body when not using multi-namespace type`, async () => { - const objects = [obj1, { ...obj2, type: NAMESPACE_AGNOSTIC_TYPE }]; - await bulkCreateSuccess(client, repository, objects); - expectMigrationArgs({ namespaces: expect.anything() }, false, 1); - expectMigrationArgs({ namespaces: expect.anything() }, false, 2); - }); - }); - - describe('returns', () => { - it(`formats the ES response`, async () => { - const result = await bulkCreateSuccess(client, repository, [obj1, obj2]); - expect(result).toEqual({ - saved_objects: [obj1, obj2].map((x) => expectCreateResult(x)), - }); - }); - - it.todo(`should return objects in the same order regardless of type`); - - it(`handles a mix of successful creates and errors`, async () => { - const obj = { - type: 'unknownType', - id: 'three', - attributes: {}, - }; - const objects = [obj1, obj, obj2]; - const response = getMockBulkCreateResponse([obj1, obj2]); - client.bulk.mockResponseOnce(response); - const result = await repository.bulkCreate(objects); - expect(client.bulk).toHaveBeenCalledTimes(1); - expect(result).toEqual({ - saved_objects: [expectCreateResult(obj1), expectError(obj), expectCreateResult(obj2)], - }); - }); - - it(`a deserialized saved object`, async () => { - // Test for fix to https://github.com/elastic/kibana/issues/65088 where - // we returned raw ID's when an object without an id was created. - const namespace = 'myspace'; - // FIXME: this test is based on a gigantic hack to have the bulk operation return the source - // of the document when it actually does not, forcing to cast to any as BulkResponse - // does not contains _source - const response = getMockBulkCreateResponse([obj1, obj2], namespace) as any; - client.bulk.mockResponseOnce(response); - - // Bulk create one object with id unspecified, and one with id specified - const result = await repository.bulkCreate([{ ...obj1, id: undefined }, obj2], { - namespace, - }); - - // Assert that both raw docs from the ES response are deserialized - expect(serializer.rawToSavedObject).toHaveBeenNthCalledWith( - 1, - { - ...response.items[0].create, - _source: { - ...response.items[0].create._source, - namespaces: response.items[0].create._source.namespaces, - coreMigrationVersion: expect.any(String), - typeMigrationVersion: '1.1.1', - }, - _id: expect.stringMatching( - /^myspace:config:[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$/ - ), - }, - expect.any(Object) - ); - expect(serializer.rawToSavedObject).toHaveBeenNthCalledWith( - 2, - { - ...response.items[1].create, - _source: { - ...response.items[1].create._source, - namespaces: response.items[1].create._source.namespaces, - coreMigrationVersion: expect.any(String), - typeMigrationVersion: '1.1.1', - }, - }, - expect.any(Object) - ); - - // Assert that ID's are deserialized to remove the type and namespace - expect(result.saved_objects[0].id).toEqual( - expect.stringMatching(/^[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$/) - ); - expect(result.saved_objects[1].id).toEqual(obj2.id); - - // Assert that managed is not changed - expect(result.saved_objects[0].managed).toBeFalsy(); - expect(result.saved_objects[1].managed).toEqual(obj2.managed); - }); - - it(`sets managed=false if not already set`, async () => { - const obj1WithoutManaged = { - type: 'config', - id: '6.0.0-alpha1', - attributes: { title: 'Test One' }, - references: [{ name: 'ref_0', type: 'test', id: '1' }], - }; - const obj2WithoutManaged = { - type: 'index-pattern', - id: 'logstash-*', - attributes: { title: 'Test Two' }, - references: [{ name: 'ref_0', type: 'test', id: '2' }], - }; - const result = await bulkCreateSuccess(client, repository, [ - obj1WithoutManaged, - obj2WithoutManaged, - ]); - expect(result).toEqual({ - saved_objects: [obj1, obj2].map((x) => expectCreateResult(x)), - }); - }); - - it(`sets managed=false only on documents without managed already set`, async () => { - const objWithoutManaged = { - type: 'config', - id: '6.0.0-alpha1', - attributes: { title: 'Test One' }, - references: [{ name: 'ref_0', type: 'test', id: '1' }], - }; - const result = await bulkCreateSuccess(client, repository, [objWithoutManaged, obj2]); - expect(result).toEqual({ - saved_objects: [obj1, obj2].map((x) => expectCreateResult(x)), - }); - }); - - it(`sets managed=true if provided as an override`, async () => { - const obj1WithoutManaged = { - type: 'config', - id: '6.0.0-alpha1', - attributes: { title: 'Test One' }, - references: [{ name: 'ref_0', type: 'test', id: '1' }], - }; - const obj2WithoutManaged = { - type: 'index-pattern', - id: 'logstash-*', - attributes: { title: 'Test Two' }, - references: [{ name: 'ref_0', type: 'test', id: '2' }], - }; - const result = await bulkCreateSuccess( - client, - repository, - [obj1WithoutManaged, obj2WithoutManaged], - { managed: true } - ); - expect(result).toEqual({ - saved_objects: [ - { ...obj1WithoutManaged, managed: true }, - { ...obj2WithoutManaged, managed: true }, - ].map((x) => expectCreateResult(x)), - }); - }); - - it(`sets managed=false if provided as an override`, async () => { - const obj1WithoutManaged = { - type: 'config', - id: '6.0.0-alpha1', - attributes: { title: 'Test One' }, - references: [{ name: 'ref_0', type: 'test', id: '1' }], - }; - const obj2WithoutManaged = { - type: 'index-pattern', - id: 'logstash-*', - attributes: { title: 'Test Two' }, - references: [{ name: 'ref_0', type: 'test', id: '2' }], - }; - const result = await bulkCreateSuccess( - client, - repository, - [obj1WithoutManaged, obj2WithoutManaged], - { managed: false } - ); - expect(result).toEqual({ - saved_objects: [obj1, obj2].map((x) => expectCreateResult(x)), - }); - }); - }); - }); -}); diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.test.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.test.ts index 3547d653e3de4..1084ad3e58966 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.test.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.test.ts @@ -32,6 +32,7 @@ import type { SavedObjectsIncrementCounterOptions, SavedObjectsCreatePointInTimeFinderDependencies, SavedObjectsCreatePointInTimeFinderOptions, + SavedObjectsBulkCreateObject, SavedObjectsBulkGetObject, SavedObjectsCreateOptions, SavedObjectsDeleteOptions, @@ -85,10 +86,13 @@ import { getMockMgetResponse, type TypeIdTuple, createSpySerializer, + bulkCreateSuccess, + getMockBulkCreateResponse, bulkGet, expectErrorResult, expectErrorInvalidType, expectErrorNotFound, + expectErrorConflict, expectError, generateIndexPatternSearchResults, findSuccess, @@ -101,6 +105,7 @@ import { createUnsupportedTypeErrorPayload, createConflictErrorPayload, createGenericNotFoundErrorPayload, + expectCreateResult, mockTimestampFieldsWithCreated, getMockEsBulkDeleteResponse, bulkDeleteSuccess, @@ -188,6 +193,917 @@ describe('SavedObjectsRepository', () => { references: [{ name: 'search_0', type: 'search', id: '123' }], }); + 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' }], + managed: false, + }; + const obj2 = { + type: 'index-pattern', + id: 'logstash-*', + attributes: { title: 'Test Two' }, + references: [{ name: 'ref_0', type: 'test', id: '2' }], + managed: false, + }; + const namespace = 'foo-namespace'; + + // bulk create calls have two objects for each source -- the action, and the source + const expectClientCallArgsAction = ( + objects: Array<{ type: string; id?: string; if_primary_term?: string; if_seq_no?: string }>, + { + method, + _index = expect.any(String), + getId = () => expect.any(String), + }: { method: string; _index?: string; getId?: (type: string, id?: string) => string } + ) => { + const body = []; + for (const { type, id, if_primary_term: ifPrimaryTerm, if_seq_no: ifSeqNo } of objects) { + body.push({ + [method]: { + _index, + _id: getId(type, id), + ...(ifPrimaryTerm && ifSeqNo + ? { if_primary_term: expect.any(Number), if_seq_no: expect.any(Number) } + : {}), + }, + }); + body.push(expect.any(Object)); + } + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + }; + + const expectObjArgs = ( + { + type, + attributes, + references, + }: { type: string; attributes: unknown; references?: SavedObjectReference[] }, + overrides: Record = {} + ) => [ + expect.any(Object), + expect.objectContaining({ + [type]: attributes, + references, + type, + ...overrides, + ...mockTimestampFields, + }), + ]; + describe('client calls', () => { + it(`should use the ES bulk action by default`, async () => { + await bulkCreateSuccess(client, repository, [obj1, obj2]); + expect(client.bulk).toHaveBeenCalledTimes(1); + }); + + it(`should use the preflightCheckForCreate action before bulk action for any types that are multi-namespace, when id is defined`, async () => { + const objects = [obj1, { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }]; + await bulkCreateSuccess(client, repository, objects); + expect(client.bulk).toHaveBeenCalledTimes(1); + expect(mockPreflightCheckForCreate).toHaveBeenCalledTimes(1); + expect(mockPreflightCheckForCreate).toHaveBeenCalledWith( + expect.objectContaining({ + objects: [ + { + type: MULTI_NAMESPACE_ISOLATED_TYPE, + id: obj2.id, + overwrite: false, + namespaces: ['default'], + }, + ], + }) + ); + }); + + it(`should use the ES create method if ID is undefined and overwrite=true`, async () => { + const objects = [obj1, obj2].map((obj) => ({ ...obj, id: undefined })); + await bulkCreateSuccess(client, repository, objects, { overwrite: true }); + expectClientCallArgsAction(objects, { method: 'create' }); + }); + + it(`should use the ES create method if ID is undefined and overwrite=false`, async () => { + const objects = [obj1, obj2].map((obj) => ({ ...obj, id: undefined })); + await bulkCreateSuccess(client, repository, objects); + expectClientCallArgsAction(objects, { method: 'create' }); + }); + + it(`should use the ES index method if ID is defined and overwrite=true`, async () => { + await bulkCreateSuccess(client, repository, [obj1, obj2], { overwrite: true }); + expectClientCallArgsAction([obj1, obj2], { method: 'index' }); + }); + + it(`should use the ES index method with version if ID and version are defined and overwrite=true`, async () => { + await bulkCreateSuccess( + client, + repository, + [ + { + ...obj1, + version: mockVersion, + }, + obj2, + ], + { overwrite: true } + ); + + const obj1WithSeq = { + ...obj1, + managed: obj1.managed, + if_seq_no: mockVersionProps._seq_no, + if_primary_term: mockVersionProps._primary_term, + }; + + expectClientCallArgsAction([obj1WithSeq, obj2], { method: 'index' }); + }); + + it(`should use the ES create method if ID is defined and overwrite=false`, async () => { + await bulkCreateSuccess(client, repository, [obj1, obj2]); + expectClientCallArgsAction([obj1, obj2], { method: 'create' }); + }); + + it(`should use the ES index method if ID is defined, overwrite=true and managed=true in a document`, async () => { + await bulkCreateSuccess(client, repository, [obj1, obj2], { + overwrite: true, + managed: true, + }); + expectClientCallArgsAction([obj1, obj2], { method: 'index' }); + }); + + it(`should use the ES create method if ID is defined, overwrite=false and managed=true in a document`, async () => { + await bulkCreateSuccess(client, repository, [obj1, obj2], { managed: true }); + expectClientCallArgsAction([obj1, obj2], { method: 'create' }); + }); + + it(`formats the ES request`, async () => { + await bulkCreateSuccess(client, repository, [obj1, obj2]); + const body = [...expectObjArgs(obj1), ...expectObjArgs(obj2)]; + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + }); + // this test only ensures that the client accepts the managed field in a document + it(`formats the ES request with managed=true in a document`, async () => { + const obj1WithManagedTrue = { ...obj1, managed: true }; + const obj2WithManagedTrue = { ...obj2, managed: true }; + await bulkCreateSuccess(client, repository, [obj1WithManagedTrue, obj2WithManagedTrue]); + const body = [...expectObjArgs(obj1WithManagedTrue), ...expectObjArgs(obj2WithManagedTrue)]; + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + }); + + describe('originId', () => { + it(`returns error if originId is set for non-multi-namespace type`, async () => { + const result = await repository.bulkCreate([ + { ...obj1, originId: 'some-originId' }, + { ...obj2, type: NAMESPACE_AGNOSTIC_TYPE, originId: 'some-originId' }, + ]); + expect(result.saved_objects).toEqual([ + expect.objectContaining({ id: obj1.id, type: obj1.type, error: expect.anything() }), + expect.objectContaining({ + id: obj2.id, + type: NAMESPACE_AGNOSTIC_TYPE, + error: expect.anything(), + }), + ]); + expect(client.bulk).not.toHaveBeenCalled(); + }); + + it(`defaults to no originId`, async () => { + const objects = [ + { ...obj1, type: MULTI_NAMESPACE_TYPE }, + { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }, + ]; + + await bulkCreateSuccess(client, repository, objects); + const expected = expect.not.objectContaining({ originId: expect.anything() }); + const body = [expect.any(Object), expected, expect.any(Object), expected]; + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + }); + + describe('with existing originId', () => { + beforeEach(() => { + mockPreflightCheckForCreate.mockImplementation(({ objects }) => { + const existingDocument = { + _source: { originId: 'existing-originId' }, + } as SavedObjectsRawDoc; + return Promise.resolve( + objects.map(({ type, id }) => ({ type, id, existingDocument })) + ); + }); + }); + + it(`accepts custom originId for multi-namespace type`, async () => { + // The preflight result has `existing-originId`, but that is discarded + const objects = [ + { ...obj1, type: MULTI_NAMESPACE_TYPE, originId: 'some-originId' }, + { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE, originId: 'some-originId' }, + ]; + await bulkCreateSuccess(client, repository, objects); + const expected = expect.objectContaining({ originId: 'some-originId' }); + const body = [expect.any(Object), expected, expect.any(Object), expected]; + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + }); + + it(`accepts undefined originId`, async () => { + // The preflight result has `existing-originId`, but that is discarded + const objects = [ + { ...obj1, type: MULTI_NAMESPACE_TYPE, originId: undefined }, + { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE, originId: undefined }, + ]; + await bulkCreateSuccess(client, repository, objects); + const expected = expect.not.objectContaining({ originId: expect.anything() }); + const body = [expect.any(Object), expected, expect.any(Object), expected]; + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + }); + + it(`preserves existing originId if originId option is not set`, async () => { + const objects = [ + { ...obj1, type: MULTI_NAMESPACE_TYPE }, + { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }, + ]; + await bulkCreateSuccess(client, repository, objects); + const expected = expect.objectContaining({ originId: 'existing-originId' }); + const body = [expect.any(Object), expected, expect.any(Object), expected]; + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + }); + }); + }); + + it(`adds namespace to request body for any types that are single-namespace`, async () => { + await bulkCreateSuccess(client, repository, [obj1, obj2], { namespace }); + const expected = expect.objectContaining({ namespace }); + const body = [expect.any(Object), expected, expect.any(Object), expected]; + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + }); + + // this only ensures we don't override any other options + it(`adds managed=false to request body if declared for any types that are single-namespace`, async () => { + await bulkCreateSuccess(client, repository, [obj1, obj2], { namespace, managed: false }); + const expected = expect.objectContaining({ namespace, managed: false }); + const body = [expect.any(Object), expected, expect.any(Object), expected]; + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + }); + // this only ensures we don't override any other options + it(`adds managed=true to request body if declared for any types that are single-namespace`, async () => { + await bulkCreateSuccess(client, repository, [obj1, obj2], { namespace, managed: true }); + const expected = expect.objectContaining({ namespace, managed: true }); + const body = [expect.any(Object), expected, expect.any(Object), expected]; + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + }); + + it(`normalizes options.namespace from 'default' to undefined`, async () => { + await bulkCreateSuccess(client, repository, [obj1, obj2], { namespace: 'default' }); + const expected = expect.not.objectContaining({ namespace: 'default' }); + const body = [expect.any(Object), expected, expect.any(Object), expected]; + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + }); + + it(`doesn't add namespace to request body for any types that are not single-namespace`, async () => { + const objects = [ + { ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }, + { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }, + ]; + await bulkCreateSuccess(client, repository, objects, { namespace }); + const expected = expect.not.objectContaining({ namespace: expect.anything() }); + const body = [expect.any(Object), expected, expect.any(Object), expected]; + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + }); + + it(`adds namespaces to request body for any types that are multi-namespace`, async () => { + const test = async (namespace?: string) => { + const objects = [obj1, obj2].map((x) => ({ ...x, type: MULTI_NAMESPACE_ISOLATED_TYPE })); + const [o1, o2] = objects; + mockPreflightCheckForCreate.mockResolvedValueOnce([ + { type: o1.type, id: o1.id! }, // first object does not have an existing document to overwrite + { + type: o2.type, + id: o2.id!, + existingDocument: { _id: o2.id!, _source: { namespaces: ['*'], type: o2.type } }, // second object does have an existing document to overwrite + }, + ]); + await bulkCreateSuccess(client, repository, objects, { namespace, overwrite: true }); + const expected1 = expect.objectContaining({ namespaces: [namespace ?? 'default'] }); + const expected2 = expect.objectContaining({ namespaces: ['*'] }); + const body = [expect.any(Object), expected1, expect.any(Object), expected2]; + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + client.bulk.mockClear(); + mockPreflightCheckForCreate.mockReset(); + }; + await test(undefined); + await test(namespace); + }); + + it(`adds initialNamespaces instead of namespace`, async () => { + const test = async (namespace?: string) => { + const ns2 = 'bar-namespace'; + const ns3 = 'baz-namespace'; + const objects = [ + { ...obj1, type: 'dashboard', initialNamespaces: [ns2] }, + { ...obj1, type: MULTI_NAMESPACE_ISOLATED_TYPE, initialNamespaces: [ns2] }, + { ...obj1, type: MULTI_NAMESPACE_TYPE, initialNamespaces: [ns2, ns3] }, + ]; + const [o1, o2, o3] = objects; + mockPreflightCheckForCreate.mockResolvedValueOnce([ + // first object does not get passed in to preflightCheckForCreate at all + { type: o2.type, id: o2.id! }, // second object does not have an existing document to overwrite + { + type: o3.type, + id: o3.id!, + existingDocument: { + _id: o3.id!, + _source: { type: o3.type, namespaces: [namespace ?? 'default', 'something-else'] }, // third object does have an existing document to overwrite + }, + }, + ]); + await bulkCreateSuccess(client, repository, objects, { namespace, overwrite: true }); + const body = [ + { index: expect.objectContaining({ _id: `${ns2}:dashboard:${o1.id}` }) }, + expect.objectContaining({ namespace: ns2 }), + { + index: expect.objectContaining({ + _id: `${MULTI_NAMESPACE_ISOLATED_TYPE}:${o2.id}`, + }), + }, + expect.objectContaining({ namespaces: [ns2] }), + { index: expect.objectContaining({ _id: `${MULTI_NAMESPACE_TYPE}:${o3.id}` }) }, + expect.objectContaining({ namespaces: [ns2, ns3] }), + ]; + expect(mockPreflightCheckForCreate).toHaveBeenCalledWith( + expect.objectContaining({ + objects: [ + // assert that the initialNamespaces fields were passed into preflightCheckForCreate instead of the current namespace + { type: o2.type, id: o2.id, overwrite: true, namespaces: o2.initialNamespaces }, + { type: o3.type, id: o3.id, overwrite: true, namespaces: o3.initialNamespaces }, + ], + }) + ); + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + client.bulk.mockClear(); + mockPreflightCheckForCreate.mockReset(); + }; + await test(undefined); + await test(namespace); + }); + + it(`normalizes initialNamespaces from 'default' to undefined`, async () => { + const test = async (namespace?: string) => { + const objects = [{ ...obj1, type: 'dashboard', initialNamespaces: ['default'] }]; + await bulkCreateSuccess(client, repository, objects, { namespace, overwrite: true }); + const body = [ + { index: expect.objectContaining({ _id: `dashboard:${obj1.id}` }) }, + expect.not.objectContaining({ namespace: 'default' }), + ]; + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + client.bulk.mockClear(); + }; + await test(undefined); + await test(namespace); + }); + + it(`doesn't add namespaces to request body for any types that are not multi-namespace`, async () => { + const test = async (namespace?: string) => { + const objects = [obj1, { ...obj2, type: NAMESPACE_AGNOSTIC_TYPE }]; + await bulkCreateSuccess(client, repository, objects, { namespace, overwrite: true }); + const expected = expect.not.objectContaining({ namespaces: expect.anything() }); + const body = [expect.any(Object), expected, expect.any(Object), expected]; + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + client.bulk.mockClear(); + }; + await test(undefined); + await test(namespace); + }); + + it(`defaults to a refresh setting of wait_for`, async () => { + await bulkCreateSuccess(client, repository, [obj1, obj2]); + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ refresh: 'wait_for' }), + expect.anything() + ); + }); + + it(`should use default index`, async () => { + await bulkCreateSuccess(client, repository, [obj1, obj2]); + expectClientCallArgsAction([obj1, obj2], { + method: 'create', + _index: '.kibana-test_8.0.0-testing', + }); + }); + + it(`should use custom index`, async () => { + await bulkCreateSuccess( + client, + repository, + [obj1, obj2].map((x) => ({ ...x, type: CUSTOM_INDEX_TYPE })) + ); + expectClientCallArgsAction([obj1, obj2], { + method: 'create', + _index: 'custom_8.0.0-testing', + }); + }); + + it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => { + const getId = (type: string, id: string = '') => `${namespace}:${type}:${id}`; // test that the raw document ID equals this (e.g., has a namespace prefix) + await bulkCreateSuccess(client, repository, [obj1, obj2], { namespace }); + expectClientCallArgsAction([obj1, obj2], { method: 'create', getId }); + }); + + it(`doesn't prepend namespace to the id when providing no namespace for single-namespace type`, async () => { + const getId = (type: string, id: string = '') => `${type}:${id}`; // test that the raw document ID equals this (e.g., does not have a namespace prefix) + await bulkCreateSuccess(client, repository, [obj1, obj2]); + expectClientCallArgsAction([obj1, obj2], { method: 'create', getId }); + }); + + it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => { + const getId = (type: string, id: string = '') => `${type}:${id}`; // test that the raw document ID equals this (e.g., does not have a namespace prefix) + const objects = [ + { ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }, + { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }, + ]; + await bulkCreateSuccess(client, repository, objects, { namespace }); + expectClientCallArgsAction(objects, { method: 'create', getId }); + }); + }); + + describe('errors', () => { + afterEach(() => { + mockGetBulkOperationError.mockReset(); + }); + + const obj3 = { + type: 'dashboard', + id: 'three', + attributes: { title: 'Test Three' }, + references: [{ name: 'ref_0', type: 'test', id: '2' }], + }; + + const bulkCreateError = async ( + obj: SavedObjectsBulkCreateObject, + isBulkError: boolean | undefined, + expectedErrorResult: ExpectedErrorResult + ) => { + let response; + if (isBulkError) { + // mock the bulk error for only the second object + mockGetBulkOperationError.mockReturnValueOnce(undefined); + mockGetBulkOperationError.mockReturnValueOnce(expectedErrorResult.error as Payload); + response = getMockBulkCreateResponse([obj1, obj, obj2]); + } else { + response = getMockBulkCreateResponse([obj1, obj2]); + } + client.bulk.mockResponseOnce(response); + + const objects = [obj1, obj, obj2]; + const result = await repository.bulkCreate(objects); + expect(client.bulk).toHaveBeenCalled(); + const objCall = isBulkError ? expectObjArgs(obj) : []; + const body = [...expectObjArgs(obj1), ...objCall, ...expectObjArgs(obj2)]; + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + expect(result).toEqual({ + saved_objects: [expectSuccess(obj1), expectedErrorResult, expectSuccess(obj2)], + }); + }; + + it(`throws when options.namespace is '*'`, async () => { + await expect( + repository.bulkCreate([obj3], { namespace: ALL_NAMESPACES_STRING }) + ).rejects.toThrowError(createBadRequestErrorPayload('"options.namespace" cannot be "*"')); + }); + + it(`returns error when initialNamespaces is used with a space-agnostic object`, async () => { + const obj = { ...obj3, type: NAMESPACE_AGNOSTIC_TYPE, initialNamespaces: [] }; + await bulkCreateError( + obj, + undefined, + expectErrorResult( + obj, + createBadRequestErrorPayload( + '"initialNamespaces" cannot be used on space-agnostic types' + ) + ) + ); + }); + + it(`returns error when initialNamespaces is empty`, async () => { + const obj = { ...obj3, type: MULTI_NAMESPACE_TYPE, initialNamespaces: [] }; + await bulkCreateError( + obj, + undefined, + expectErrorResult( + obj, + createBadRequestErrorPayload('"initialNamespaces" must be a non-empty array of strings') + ) + ); + }); + + it(`returns error when initialNamespaces is used with a space-isolated object and does not specify a single space`, async () => { + const doTest = async (objType: string, initialNamespaces: string[]) => { + const obj = { ...obj3, type: objType, initialNamespaces }; + await bulkCreateError( + obj, + undefined, + expectErrorResult( + obj, + createBadRequestErrorPayload( + '"initialNamespaces" can only specify a single space when used with space-isolated types' + ) + ) + ); + }; + await doTest('dashboard', ['spacex', 'spacey']); + await doTest('dashboard', ['*']); + await doTest(MULTI_NAMESPACE_ISOLATED_TYPE, ['spacex', 'spacey']); + await doTest(MULTI_NAMESPACE_ISOLATED_TYPE, ['*']); + }); + + it(`returns error when type is invalid`, async () => { + const obj = { ...obj3, type: 'unknownType' }; + await bulkCreateError(obj, undefined, expectErrorInvalidType(obj)); + }); + + it(`returns error when type is hidden`, async () => { + const obj = { ...obj3, type: HIDDEN_TYPE }; + await bulkCreateError(obj, undefined, expectErrorInvalidType(obj)); + }); + + it(`returns error when there is a conflict from preflightCheckForCreate`, async () => { + const objects = [ + // only the second, third, and fourth objects are passed to preflightCheckForCreate and result in errors + obj1, + { ...obj1, type: MULTI_NAMESPACE_TYPE }, + { ...obj2, type: MULTI_NAMESPACE_TYPE }, + { ...obj3, type: MULTI_NAMESPACE_TYPE }, + obj2, + ]; + const [o1, o2, o3, o4, o5] = objects; + mockPreflightCheckForCreate.mockResolvedValueOnce([ + // first and last objects do not get passed in to preflightCheckForCreate at all + { type: o2.type, id: o2.id!, error: { type: 'conflict' } }, + { + type: o3.type, + id: o3.id!, + error: { type: 'unresolvableConflict', metadata: { isNotOverwritable: true } }, + }, + { + type: o4.type, + id: o4.id!, + error: { type: 'aliasConflict', metadata: { spacesWithConflictingAliases: ['foo'] } }, + }, + ]); + const bulkResponse = getMockBulkCreateResponse([o1, o5]); + client.bulk.mockResponseOnce(bulkResponse); + + const options = { overwrite: true }; + const result = await repository.bulkCreate(objects, options); + expect(mockPreflightCheckForCreate).toHaveBeenCalled(); + expect(mockPreflightCheckForCreate).toHaveBeenCalledWith( + expect.objectContaining({ + objects: [ + { type: o2.type, id: o2.id, overwrite: true, namespaces: ['default'] }, + { type: o3.type, id: o3.id, overwrite: true, namespaces: ['default'] }, + { type: o4.type, id: o4.id, overwrite: true, namespaces: ['default'] }, + ], + }) + ); + expect(client.bulk).toHaveBeenCalled(); + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body: [...expectObjArgs(o1), ...expectObjArgs(o5)] }), + expect.anything() + ); + expect(result).toEqual({ + saved_objects: [ + expectSuccess(o1), + expectErrorConflict(o2), + expectErrorConflict(o3, { metadata: { isNotOverwritable: true } }), + expectErrorConflict(o4, { metadata: { spacesWithConflictingAliases: ['foo'] } }), + expectSuccess(o5), + ], + }); + }); + + it(`returns bulk error`, async () => { + const expectedErrorResult = { + type: obj3.type, + id: obj3.id, + error: { error: 'Oh no, a bulk error!' }, + }; + await bulkCreateError(obj3, true, expectedErrorResult); + }); + + it(`returns errors for any bulk objects with invalid schemas`, async () => { + const response = getMockBulkCreateResponse([obj3]); + client.bulk.mockResponseOnce(response); + + const result = await repository.bulkCreate([ + obj3, + // @ts-expect-error - Title should be a string and is intentionally malformed for testing + { ...obj3, id: 'three-again', attributes: { title: 123 } }, + ]); + expect(client.bulk).toHaveBeenCalledTimes(1); // only called once for the valid object + expect(result.saved_objects).toEqual([ + expect.objectContaining(obj3), + expect.objectContaining({ + error: new Error( + '[attributes.title]: expected value of type [string] but got [number]: Bad Request' + ), + id: 'three-again', + type: 'dashboard', + }), + ]); + }); + }); + + describe('migration', () => { + it(`migrates the docs and serializes the migrated docs`, async () => { + migrator.migrateDocument.mockImplementation(mockMigrateDocument); + const modifiedObj1 = { ...obj1, coreMigrationVersion: '8.0.0' }; + await bulkCreateSuccess(client, repository, [modifiedObj1, obj2]); + const docs = [modifiedObj1, obj2].map((x) => ({ ...x, ...mockTimestampFieldsWithCreated })); + expectMigrationArgs(docs[0], true, 1); + expectMigrationArgs(docs[1], true, 2); + + const migratedDocs = docs.map((x) => migrator.migrateDocument(x)); + expect(serializer.savedObjectToRaw).toHaveBeenNthCalledWith(1, migratedDocs[0]); + expect(serializer.savedObjectToRaw).toHaveBeenNthCalledWith(2, migratedDocs[1]); + }); + + it(`adds namespace to body when providing namespace for single-namespace type`, async () => { + await bulkCreateSuccess(client, repository, [obj1, obj2], { namespace }); + expectMigrationArgs({ namespace }, true, 1); + expectMigrationArgs({ namespace }, true, 2); + }); + + it(`doesn't add namespace to body when providing no namespace for single-namespace type`, async () => { + await bulkCreateSuccess(client, repository, [obj1, obj2]); + expectMigrationArgs({ namespace: expect.anything() }, false, 1); + expectMigrationArgs({ namespace: expect.anything() }, false, 2); + }); + + it(`doesn't add namespace to body when not using single-namespace type`, async () => { + const objects = [ + { ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }, + { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }, + ]; + await bulkCreateSuccess(client, repository, objects, { namespace }); + expectMigrationArgs({ namespace: expect.anything() }, false, 1); + expectMigrationArgs({ namespace: expect.anything() }, false, 2); + }); + + it(`adds namespaces to body when providing namespace for multi-namespace type`, async () => { + const objects = [obj1, obj2].map((obj) => ({ + ...obj, + type: MULTI_NAMESPACE_ISOLATED_TYPE, + })); + await bulkCreateSuccess(client, repository, objects, { namespace }); + expectMigrationArgs({ namespaces: [namespace] }, true, 1); + expectMigrationArgs({ namespaces: [namespace] }, true, 2); + }); + + it(`adds default namespaces to body when providing no namespace for multi-namespace type`, async () => { + const objects = [obj1, obj2].map((obj) => ({ + ...obj, + type: MULTI_NAMESPACE_ISOLATED_TYPE, + })); + await bulkCreateSuccess(client, repository, objects); + expectMigrationArgs({ namespaces: ['default'] }, true, 1); + expectMigrationArgs({ namespaces: ['default'] }, true, 2); + }); + + it(`doesn't add namespaces to body when not using multi-namespace type`, async () => { + const objects = [obj1, { ...obj2, type: NAMESPACE_AGNOSTIC_TYPE }]; + await bulkCreateSuccess(client, repository, objects); + expectMigrationArgs({ namespaces: expect.anything() }, false, 1); + expectMigrationArgs({ namespaces: expect.anything() }, false, 2); + }); + }); + + describe('returns', () => { + it(`formats the ES response`, async () => { + const result = await bulkCreateSuccess(client, repository, [obj1, obj2]); + expect(result).toEqual({ + saved_objects: [obj1, obj2].map((x) => expectCreateResult(x)), + }); + }); + + it.todo(`should return objects in the same order regardless of type`); + + it(`handles a mix of successful creates and errors`, async () => { + const obj = { + type: 'unknownType', + id: 'three', + attributes: {}, + }; + const objects = [obj1, obj, obj2]; + const response = getMockBulkCreateResponse([obj1, obj2]); + client.bulk.mockResponseOnce(response); + const result = await repository.bulkCreate(objects); + expect(client.bulk).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + saved_objects: [expectCreateResult(obj1), expectError(obj), expectCreateResult(obj2)], + }); + }); + + it(`a deserialized saved object`, async () => { + // Test for fix to https://github.com/elastic/kibana/issues/65088 where + // we returned raw ID's when an object without an id was created. + const namespace = 'myspace'; + // FIXME: this test is based on a gigantic hack to have the bulk operation return the source + // of the document when it actually does not, forcing to cast to any as BulkResponse + // does not contains _source + const response = getMockBulkCreateResponse([obj1, obj2], namespace) as any; + client.bulk.mockResponseOnce(response); + + // Bulk create one object with id unspecified, and one with id specified + const result = await repository.bulkCreate([{ ...obj1, id: undefined }, obj2], { + namespace, + }); + + // Assert that both raw docs from the ES response are deserialized + expect(serializer.rawToSavedObject).toHaveBeenNthCalledWith( + 1, + { + ...response.items[0].create, + _source: { + ...response.items[0].create._source, + namespaces: response.items[0].create._source.namespaces, + coreMigrationVersion: expect.any(String), + typeMigrationVersion: '1.1.1', + }, + _id: expect.stringMatching( + /^myspace:config:[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$/ + ), + }, + expect.any(Object) + ); + expect(serializer.rawToSavedObject).toHaveBeenNthCalledWith( + 2, + { + ...response.items[1].create, + _source: { + ...response.items[1].create._source, + namespaces: response.items[1].create._source.namespaces, + coreMigrationVersion: expect.any(String), + typeMigrationVersion: '1.1.1', + }, + }, + expect.any(Object) + ); + + // Assert that ID's are deserialized to remove the type and namespace + expect(result.saved_objects[0].id).toEqual( + expect.stringMatching(/^[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$/) + ); + expect(result.saved_objects[1].id).toEqual(obj2.id); + + // Assert that managed is not changed + expect(result.saved_objects[0].managed).toBeFalsy(); + expect(result.saved_objects[1].managed).toEqual(obj2.managed); + }); + + it(`sets managed=false if not already set`, async () => { + const obj1WithoutManaged = { + type: 'config', + id: '6.0.0-alpha1', + attributes: { title: 'Test One' }, + references: [{ name: 'ref_0', type: 'test', id: '1' }], + }; + const obj2WithoutManaged = { + type: 'index-pattern', + id: 'logstash-*', + attributes: { title: 'Test Two' }, + references: [{ name: 'ref_0', type: 'test', id: '2' }], + }; + const result = await bulkCreateSuccess(client, repository, [ + obj1WithoutManaged, + obj2WithoutManaged, + ]); + expect(result).toEqual({ + saved_objects: [obj1, obj2].map((x) => expectCreateResult(x)), + }); + }); + + it(`sets managed=false only on documents without managed already set`, async () => { + const objWithoutManaged = { + type: 'config', + id: '6.0.0-alpha1', + attributes: { title: 'Test One' }, + references: [{ name: 'ref_0', type: 'test', id: '1' }], + }; + const result = await bulkCreateSuccess(client, repository, [objWithoutManaged, obj2]); + expect(result).toEqual({ + saved_objects: [obj1, obj2].map((x) => expectCreateResult(x)), + }); + }); + + it(`sets managed=true if provided as an override`, async () => { + const obj1WithoutManaged = { + type: 'config', + id: '6.0.0-alpha1', + attributes: { title: 'Test One' }, + references: [{ name: 'ref_0', type: 'test', id: '1' }], + }; + const obj2WithoutManaged = { + type: 'index-pattern', + id: 'logstash-*', + attributes: { title: 'Test Two' }, + references: [{ name: 'ref_0', type: 'test', id: '2' }], + }; + const result = await bulkCreateSuccess( + client, + repository, + [obj1WithoutManaged, obj2WithoutManaged], + { managed: true } + ); + expect(result).toEqual({ + saved_objects: [ + { ...obj1WithoutManaged, managed: true }, + { ...obj2WithoutManaged, managed: true }, + ].map((x) => expectCreateResult(x)), + }); + }); + + it(`sets managed=false if provided as an override`, async () => { + const obj1WithoutManaged = { + type: 'config', + id: '6.0.0-alpha1', + attributes: { title: 'Test One' }, + references: [{ name: 'ref_0', type: 'test', id: '1' }], + }; + const obj2WithoutManaged = { + type: 'index-pattern', + id: 'logstash-*', + attributes: { title: 'Test Two' }, + references: [{ name: 'ref_0', type: 'test', id: '2' }], + }; + const result = await bulkCreateSuccess( + client, + repository, + [obj1WithoutManaged, obj2WithoutManaged], + { managed: false } + ); + expect(result).toEqual({ + saved_objects: [obj1, obj2].map((x) => expectCreateResult(x)), + }); + }); + }); + }); + describe('#bulkGet', () => { const obj1: SavedObject = { type: 'config', From cc314f872cd20c28a32e05727b030638bb151841 Mon Sep 17 00:00:00 2001 From: "Christiane (Tina) Heiligers" Date: Sat, 18 Nov 2023 10:51:11 -0700 Subject: [PATCH 07/16] refactors and update bulkUpdate return unit tests --- .../src/lib/apis/bulk_update.test.ts | 49 +++++++++++++------ .../src/lib/apis/bulk_update.ts | 5 +- .../test_helpers/repository.test.common.ts | 41 ++++++++++------ 3 files changed, 65 insertions(+), 30 deletions(-) diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.test.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.test.ts index a4c87fb1b1eac..02fe4a5c092f1 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.test.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.test.ts @@ -521,7 +521,7 @@ describe('SavedObjectsRepository', () => { }); }); - describe('returns', () => { + describe.only('returns', () => { it(`formats the ES response`, async () => { const response = await bulkUpdateSuccess(client, repository, registry, [obj1, obj2]); expect(response).toEqual({ @@ -543,14 +543,24 @@ describe('SavedObjectsRepository', () => { id: 'three', attributes: {}, }; - const objects = [obj1, obj, obj2]; - const mockResponse = getMockBulkUpdateResponse(registry, objects); - client.bulk.mockResponseOnce(mockResponse); + const objects = [obj1, obj2, obj]; + const mockedMgetResponse = getMockMgetResponse(registry, [obj1, obj2, obj]); + client.bulk.mockClear(); + client.mget.mockClear(); + client.mget.mockResponseOnce(mockedMgetResponse); + const mockBulkIndexResponse = getMockBulkUpdateResponse(registry, objects); + client.bulk.mockResponseOnce(mockBulkIndexResponse); const result = await repository.bulkUpdate(objects); - expect(client.bulk).toHaveBeenCalledTimes(1); + + expect(client.mget).toHaveBeenCalled(); + expect(client.bulk).toHaveBeenCalled(); + + const expectClientCallObjects = [obj1, obj2]; + expectClientCallArgsAction(expectClientCallObjects, { method: 'index' }); + expect(result).toEqual({ - saved_objects: [expectUpdateResult(obj1), expectError(obj), expectUpdateResult(obj2)], + saved_objects: [expectUpdateResult(obj1), expectUpdateResult(obj2), expectError(obj)], }); }); @@ -575,14 +585,25 @@ describe('SavedObjectsRepository', () => { id: 'three', attributes: {}, }; - const result = await bulkUpdateSuccess( - client, - repository, - registry, - [obj1, obj], - {}, - originId - ); + client.bulk.mockClear(); + client.mget.mockClear(); + const objects = [ + { ...obj1, originId }, + { ...obj, originId }, + ]; + const mockedMgetResponse = getMockMgetResponse(registry, objects); + + client.mget.mockResponseOnce(mockedMgetResponse); + + const mockBulkIndexResponse = getMockBulkUpdateResponse(registry, objects, {}, originId); + client.bulk.mockResponseOnce(mockBulkIndexResponse); + const result = await repository.bulkUpdate(objects); + + expect(client.mget).toHaveBeenCalled(); + expect(client.bulk).toHaveBeenCalled(); + + const expectClientCallObjects = objects; + expectClientCallArgsAction(expectClientCallObjects, { method: 'index' }); expect(result).toEqual({ saved_objects: [ expect.objectContaining({ originId }), diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.ts index 7e11191b40d8a..88b01991e438f 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.ts @@ -394,8 +394,9 @@ export const performBulkUpdate = async ( const { [type]: attributes, references, updated_at } = documentToSave; // use the original request params ?? probably need to return the actual updated doc that exists in es now. const { originId } = rawMigratedUpdatedDoc._source; + console.log('in actual API, do we have an originId?', originId); // @TINA TODO: ensure we return the correct response without changing the signature - return { + const intermediateResult = { id, type, ...(namespaces && { namespaces }), @@ -405,6 +406,8 @@ export const performBulkUpdate = async ( attributes, references, }; + console.log('In actual API, intermediateResult:', JSON.stringify(intermediateResult)); + return intermediateResult; }), }; diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/test_helpers/repository.test.common.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/test_helpers/repository.test.common.ts index 07f7245597a39..c92186c0c0960 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/test_helpers/repository.test.common.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/test_helpers/repository.test.common.ts @@ -464,21 +464,27 @@ export const getMockGetResponse = ( ...mockTimestampFields, } as SavedObjectsRawDocSource, } as estypes.GetResponse; + // console.log('getMockGetResponse result', JSON.stringify(result)); return result; }; export const getMockMgetResponse = ( registry: SavedObjectTypeRegistry, - objects: Array, + objects: Array< + TypeIdTuple & { found?: boolean; initialNamespaces?: string[]; originId?: string } + >, namespace?: string -) => - ({ +) => { + const result = { docs: objects.map((obj) => obj.found === false ? obj : getMockGetResponse(registry, obj, obj.initialNamespaces ?? namespace) ), - } as estypes.MgetResponse); + } as estypes.MgetResponse; + console.log('getMockMgetResponse result', JSON.stringify(result)); + return result; +}; expect.extend({ toBeDocumentWithoutError(received, type, id) { @@ -649,8 +655,8 @@ export const getMockBulkUpdateResponse = ( objects: TypeIdTuple[], options?: SavedObjectsBulkUpdateOptions, originId?: string -) => - ({ +) => { + const mockedBulkUpdateResponse = { items: objects.map(({ type, id }) => ({ index: { _id: `${ @@ -667,7 +673,10 @@ export const getMockBulkUpdateResponse = ( result: 'updated', }, })), - } as estypes.BulkResponse); + } as estypes.BulkResponse; + console.log('mockedBulkUpdateResponse', JSON.stringify(mockedBulkUpdateResponse)); + return mockedBulkUpdateResponse; +}; export const bulkUpdateSuccess = async ( client: ElasticsearchClientMock, @@ -678,10 +687,11 @@ export const bulkUpdateSuccess = async ( originId?: string, multiNamespaceSpace?: string // the space for multi namespace objects returned by mock mget (this is only needed for space ext testing) ) => { - // console.log( - // 'bulkUpdateSuccess: the objects for mocking bulkUpdateSuccess are:', - // JSON.stringify(objects) - // ); + console.log( + 'bulkUpdateSuccess: the objects for mocking bulkUpdateSuccess are:', + JSON.stringify(objects) + ); + console.log('do we have originId?', originId); let mockedMgetResponse; const validObjects = objects.filter(({ type }) => registry.getType(type) !== undefined); // console.log('bulkUpdateSuccess: the valid objects are:', JSON.stringify(validObjects)); @@ -689,6 +699,7 @@ export const bulkUpdateSuccess = async ( if (validObjects?.length) { if (multiNamespaceObjects.length > 0) { + console.log('with multiNamespaceObjects'); mockedMgetResponse = getMockMgetResponse( registry, validObjects, @@ -698,10 +709,10 @@ export const bulkUpdateSuccess = async ( // console.log('bulkUpdateSuccess: no multinamespace object types'); mockedMgetResponse = getMockMgetResponse(registry, validObjects); } - // console.log( - // 'bulkUpdateSuccess: mocking mget response only once with:', - // JSON.stringify(mockedMgetResponse) - // ); + console.log( + 'bulkUpdateSuccess: mocking mget response only once with:', + JSON.stringify(mockedMgetResponse) + ); client.mget.mockResponseOnce(mockedMgetResponse); } const response = getMockBulkUpdateResponse(registry, objects, options, originId); From 3c663b09909839f43dda9b8c6907234c8b6f5c70 Mon Sep 17 00:00:00 2001 From: "Christiane (Tina) Heiligers" Date: Sat, 18 Nov 2023 10:56:38 -0700 Subject: [PATCH 08/16] Cleans up returns unit tests --- .../src/lib/apis/bulk_update.test.ts | 2 +- .../src/lib/apis/bulk_update.ts | 6 +---- .../test_helpers/repository.test.common.ts | 25 +++---------------- 3 files changed, 5 insertions(+), 28 deletions(-) diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.test.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.test.ts index 02fe4a5c092f1..c81ffb13bad26 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.test.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.test.ts @@ -521,7 +521,7 @@ describe('SavedObjectsRepository', () => { }); }); - describe.only('returns', () => { + describe('returns', () => { it(`formats the ES response`, async () => { const response = await bulkUpdateSuccess(client, repository, registry, [obj1, obj2]); expect(response).toEqual({ diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.ts index 88b01991e438f..969e3cd9b89a1 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.ts @@ -394,9 +394,7 @@ export const performBulkUpdate = async ( const { [type]: attributes, references, updated_at } = documentToSave; // use the original request params ?? probably need to return the actual updated doc that exists in es now. const { originId } = rawMigratedUpdatedDoc._source; - console.log('in actual API, do we have an originId?', originId); - // @TINA TODO: ensure we return the correct response without changing the signature - const intermediateResult = { + return { id, type, ...(namespaces && { namespaces }), @@ -406,8 +404,6 @@ export const performBulkUpdate = async ( attributes, references, }; - console.log('In actual API, intermediateResult:', JSON.stringify(intermediateResult)); - return intermediateResult; }), }; diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/test_helpers/repository.test.common.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/test_helpers/repository.test.common.ts index c92186c0c0960..a922787215f7f 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/test_helpers/repository.test.common.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/test_helpers/repository.test.common.ts @@ -464,7 +464,6 @@ export const getMockGetResponse = ( ...mockTimestampFields, } as SavedObjectsRawDocSource, } as estypes.GetResponse; - // console.log('getMockGetResponse result', JSON.stringify(result)); return result; }; @@ -474,17 +473,14 @@ export const getMockMgetResponse = ( TypeIdTuple & { found?: boolean; initialNamespaces?: string[]; originId?: string } >, namespace?: string -) => { - const result = { +) => + ({ docs: objects.map((obj) => obj.found === false ? obj : getMockGetResponse(registry, obj, obj.initialNamespaces ?? namespace) ), - } as estypes.MgetResponse; - console.log('getMockMgetResponse result', JSON.stringify(result)); - return result; -}; + } as estypes.MgetResponse); expect.extend({ toBeDocumentWithoutError(received, type, id) { @@ -674,7 +670,6 @@ export const getMockBulkUpdateResponse = ( }, })), } as estypes.BulkResponse; - console.log('mockedBulkUpdateResponse', JSON.stringify(mockedBulkUpdateResponse)); return mockedBulkUpdateResponse; }; @@ -687,40 +682,26 @@ export const bulkUpdateSuccess = async ( originId?: string, multiNamespaceSpace?: string // the space for multi namespace objects returned by mock mget (this is only needed for space ext testing) ) => { - console.log( - 'bulkUpdateSuccess: the objects for mocking bulkUpdateSuccess are:', - JSON.stringify(objects) - ); - console.log('do we have originId?', originId); let mockedMgetResponse; const validObjects = objects.filter(({ type }) => registry.getType(type) !== undefined); - // console.log('bulkUpdateSuccess: the valid objects are:', JSON.stringify(validObjects)); const multiNamespaceObjects = validObjects.filter(({ type }) => registry.isMultiNamespace(type)); if (validObjects?.length) { if (multiNamespaceObjects.length > 0) { - console.log('with multiNamespaceObjects'); mockedMgetResponse = getMockMgetResponse( registry, validObjects, multiNamespaceSpace ?? options?.namespace ); } else { - // console.log('bulkUpdateSuccess: no multinamespace object types'); mockedMgetResponse = getMockMgetResponse(registry, validObjects); } - console.log( - 'bulkUpdateSuccess: mocking mget response only once with:', - JSON.stringify(mockedMgetResponse) - ); client.mget.mockResponseOnce(mockedMgetResponse); } const response = getMockBulkUpdateResponse(registry, objects, options, originId); - // console.log('bulkUpdateSuccess: mocked response for bulkUpdate', JSON.stringify(response)); client.bulk.mockResponseOnce(response); const result = await repository.bulkUpdate(objects, options); expect(client.mget).toHaveBeenCalledTimes(validObjects?.length ? 1 : 0); - // console.log('bulkUpdateSuccess: result', JSON.stringify(result)); return result; }; From aedd6be2d8a5c30c83528f47b125c59a9fc42bd9 Mon Sep 17 00:00:00 2001 From: "Christiane (Tina) Heiligers" Date: Sun, 19 Nov 2023 15:37:06 -0700 Subject: [PATCH 09/16] Adds migrations tests --- .../src/lib/apis/bulk_update.test.ts | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.test.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.test.ts index c81ffb13bad26..33cd1d49a76b0 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.test.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.test.ts @@ -78,6 +78,17 @@ describe('SavedObjectsRepository', () => { return expect.toBeDocumentWithoutError(type, id); }; + const expectMigrationArgs = (args: unknown, contains = true, n = 1) => { + const obj = contains ? expect.objectContaining(args) : expect.not.objectContaining(args); + expect(migrator.migrateDocument).toHaveBeenNthCalledWith( + n, + obj, + expect.objectContaining({ + allowDowngrade: expect.any(Boolean), + }) + ); + }; + beforeEach(() => { pointInTimeFinderMock.mockClear(); client = elasticsearchClientMock.createElasticsearchClient(); @@ -123,6 +134,8 @@ describe('SavedObjectsRepository', () => { const references = [{ name: 'ref_0', type: 'test', id: '1' }]; const originId = 'some-origin-id'; const namespace = 'foo-namespace'; + + // Setup migration mock for updating an object const mockMigrationVersion = { foo: '2.3.4' }; const mockMigrateDocumentForUpdate = (doc: SavedObjectUnsanitizedDoc) => { const response = { @@ -520,6 +533,52 @@ describe('SavedObjectsRepository', () => { await bulkUpdateError(obj, true, expectedErrorResult); }); }); + describe('migration', () => { + it('migrates the fetched documents from Mget', async () => { + const modifiedObj2 = { ...obj2, coreMigrationVersion: '8.0.0' }; + const objects = [modifiedObj2]; + migrator.migrateDocument.mockImplementationOnce((doc) => ({ ...doc, migrated: true })); + + await bulkUpdateSuccess(client, repository, registry, objects); + expect(migrator.migrateDocument).toHaveBeenCalledTimes(2); + expectMigrationArgs({ + id: modifiedObj2.id, + type: modifiedObj2.type, + }); + }); + + it('migrates namespace agnostic and multinamespace object documents', async () => { + const modifiedObj2 = { + ...obj2, + coreMigrationVersion: '8.0.0', + type: MULTI_NAMESPACE_ISOLATED_TYPE, + namespace: 'default', + }; + const modifiedObj1 = { ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }; + const objects = [modifiedObj2, modifiedObj1]; + migrator.migrateDocument.mockImplementationOnce((doc) => ({ ...doc, migrated: true })); + + await bulkUpdateSuccess(client, repository, registry, objects, { namespace }); + + expect(migrator.migrateDocument).toHaveBeenCalledTimes(4); + expectMigrationArgs( + { + id: modifiedObj2.id, + type: modifiedObj2.type, + }, + true, + 1 + ); + expectMigrationArgs( + { + id: modifiedObj1.id, + type: modifiedObj1.type, + }, + true, + 2 + ); + }); + }); describe('returns', () => { it(`formats the ES response`, async () => { From bf786b526228cefb202bd4c17c3e60b4733f8e1d Mon Sep 17 00:00:00 2001 From: "Christiane (Tina) Heiligers" Date: Mon, 20 Nov 2023 15:30:01 -0700 Subject: [PATCH 10/16] core server integration tests --- .../src/lib/apis/bulk_update.ts | 1 - .../service/lib/bulk_update.test.ts | 235 ++++++++++++++++++ 2 files changed, 235 insertions(+), 1 deletion(-) create mode 100644 src/core/server/integration_tests/saved_objects/service/lib/bulk_update.test.ts diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.ts index 969e3cd9b89a1..8deae14f865aa 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.ts @@ -299,7 +299,6 @@ export const performBulkUpdate = async ( const typeDefinition = registry.getType(type)!; if (docFound) { - // actualResult could be undefined const document = getSavedObjectFromSource( registry, type, diff --git a/src/core/server/integration_tests/saved_objects/service/lib/bulk_update.test.ts b/src/core/server/integration_tests/saved_objects/service/lib/bulk_update.test.ts new file mode 100644 index 0000000000000..2058b08c55b7a --- /dev/null +++ b/src/core/server/integration_tests/saved_objects/service/lib/bulk_update.test.ts @@ -0,0 +1,235 @@ +/* + * 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 Path from 'path'; +import fs from 'fs/promises'; +import { pick } from 'lodash'; +import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server'; +import type { SavedObjectsType, SavedObjectsModelVersionMap } from '@kbn/core-saved-objects-server'; +import { type TestElasticsearchUtils } from '@kbn/core-test-helpers-kbn-server'; +import '../../migrations/jest_matchers'; +import { + getKibanaMigratorTestKit, + startElasticsearch, +} from '../../migrations/kibana_migrator_test_kit'; +import { delay } from '../../migrations/test_utils'; +import { getBaseMigratorParams } from '../../migrations/fixtures/zdt_base.fixtures'; + +// export const logFilePath = Path.join(__dirname, 'update.test.log'); +export const logFilePath = Path.join(__dirname, 'bulk_update.test.log'); + +describe('SOR - bulk_update API', () => { + let esServer: TestElasticsearchUtils['es']; + + beforeAll(async () => { + await fs.unlink(logFilePath).catch(() => {}); + esServer = await startElasticsearch(); + }); + + const getType = (version: 'v1' | 'v2'): SavedObjectsType => { + const versionMap: SavedObjectsModelVersionMap = { + 1: { + changes: [], + schemas: { + forwardCompatibility: (attributes) => { + return pick(attributes, 'count'); + }, + }, + }, + }; + + if (version === 'v2') { + versionMap[2] = { + changes: [ + { + type: 'data_backfill', + backfillFn: (document) => { + return { attributes: { even: document.attributes.count % 2 === 0 } }; + }, + }, + ], + }; + } + + return { + name: 'my-test-type', + hidden: false, + namespaceType: 'agnostic', + mappings: { + dynamic: false, + properties: { + count: { type: 'integer' }, + ...(version === 'v2' ? { even: { type: 'boolean' } } : {}), + }, + }, + management: { + importableAndExportable: true, + }, + switchToModelVersionAt: '8.10.0', + modelVersions: versionMap, + }; + }; + + const getOtherType = (version: 'v1' | 'v2'): SavedObjectsType => { + const versionOtherMap: SavedObjectsModelVersionMap = { + 1: { + changes: [], + schemas: { + forwardCompatibility: (attributes) => { + return pick(attributes, 'sum'); + }, + }, + }, + }; + + if (version === 'v2') { + versionOtherMap[2] = { + changes: [ + { + type: 'data_backfill', + backfillFn: (document) => { + return { attributes: { isodd: document.attributes.sum % 2 !== 0 } }; + }, + }, + ], + }; + } + + return { + name: 'my-other-test-type', + hidden: false, + namespaceType: 'agnostic', + mappings: { + dynamic: false, + properties: { + sum: { type: 'integer' }, + ...(version === 'v2' ? { isodd: { type: 'boolean' } } : {}), + }, + }, + management: { + importableAndExportable: true, + }, + switchToModelVersionAt: '8.10.0', + modelVersions: versionOtherMap, + }; + }; + afterAll(async () => { + await esServer?.stop(); + await delay(10); + }); + + const setup = async () => { + const { runMigrations: runMigrationV1, savedObjectsRepository: repositoryV1 } = + await getKibanaMigratorTestKit({ + ...getBaseMigratorParams(), + types: [getType('v1'), getOtherType('v1')], + }); + await runMigrationV1(); + + const { + runMigrations: runMigrationV2, + savedObjectsRepository: repositoryV2, + client: esClient, + } = await getKibanaMigratorTestKit({ + ...getBaseMigratorParams(), + types: [getType('v2'), getOtherType('v2')], + }); + await runMigrationV2(); + + return { repositoryV1, repositoryV2, esClient }; + }; + + it('supports updates between older and newer versions', async () => { + const { repositoryV1, repositoryV2, esClient } = await setup(); + + await repositoryV1.create('my-test-type', { count: 12 }, { id: 'my-id' }); + await repositoryV1.create('my-other-test-type', { sum: 24 }, { id: 'my-other-id' }); + + let repoV2Docs = await repositoryV2.bulkGet([ + { type: 'my-test-type', id: 'my-id' }, + { type: 'my-other-test-type', id: 'my-other-id' }, + ]); + const [doc, otherDoc] = repoV2Docs.saved_objects; + + expect(doc.attributes).toEqual({ + count: 12, + even: true, + }); + expect(otherDoc.attributes).toEqual({ + sum: 24, + isodd: false, + }); + + await repositoryV2.bulkUpdate([ + { type: 'my-test-type', id: doc.id, attributes: { count: 11, even: false } }, + // @ts-expect-error cannot assign to partial + { type: 'my-other-test-type', id: otherDoc.id, attributes: { sum: 23, isodd: true } }, + ]); + + const repoV1Docs = await repositoryV1.bulkGet([ + { type: 'my-test-type', id: 'my-id' }, + { type: 'my-other-test-type', id: 'my-other-id' }, + ]); + const [doc1, otherDoc1] = repoV1Docs.saved_objects; + + expect(doc1.attributes).toEqual({ + count: 11, + }); + expect(otherDoc1.attributes).toEqual({ + sum: 23, + }); + + await repositoryV1.bulkUpdate([ + { type: 'my-test-type', id: doc1.id, attributes: { count: 14 } }, + // @ts-expect-error cannot assign to partial + { type: 'my-other-test-type', id: otherDoc1.id, attributes: { sum: 24 } }, + ]); + + repoV2Docs = await repositoryV2.bulkGet([ + { type: 'my-test-type', id: 'my-id' }, + { type: 'my-other-test-type', id: 'my-other-id' }, + ]); + const [doc2, otherDoc2] = repoV2Docs.saved_objects; + + expect(doc2.attributes).toEqual({ + count: 14, + even: true, + }); + expect(otherDoc2.attributes).toEqual({ + sum: 24, + isodd: false, + }); + + const rawDoc = await fetchDoc(esClient, 'my-test-type', 'my-id'); + expect(rawDoc._source).toEqual( + expect.objectContaining({ + typeMigrationVersion: '10.1.0', + 'my-test-type': { + count: 14, + }, + }) + ); + + const otherRawDoc = await fetchDoc(esClient, 'my-other-test-type', 'my-other-id'); + expect(otherRawDoc._source).toEqual( + expect.objectContaining({ + typeMigrationVersion: '10.1.0', + 'my-other-test-type': { + sum: 24, + }, + }) + ); + }); + + const fetchDoc = async (client: ElasticsearchClient, type: string, id: string) => { + return await client.get({ + index: '.kibana', + id: `${type}:${id}`, + }); + }; +}); From ebe12fec5382c937af3958915160a088510e35ac Mon Sep 17 00:00:00 2001 From: "Christiane (Tina) Heiligers" Date: Tue, 21 Nov 2023 12:55:38 -0700 Subject: [PATCH 11/16] cleanup --- .../src/lib/apis/bulk_update.test.ts | 28 +------------- .../src/lib/apis/bulk_update.ts | 37 ++++++------------- 2 files changed, 12 insertions(+), 53 deletions(-) diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.test.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.test.ts index 33cd1d49a76b0..60deaa64e3e63 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.test.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.test.ts @@ -22,10 +22,7 @@ import type { SavedObjectsBulkUpdateObject, SavedObjectsBulkUpdateOptions, } from '@kbn/core-saved-objects-api-server'; -import { - SavedObjectUnsanitizedDoc, - type SavedObjectReference, -} from '@kbn/core-saved-objects-server'; +import { type SavedObjectReference } from '@kbn/core-saved-objects-server'; import { ALL_NAMESPACES_STRING } from '@kbn/core-saved-objects-utils-server'; import { SavedObjectsRepository } from '../repository'; import { loggerMock } from '@kbn/logging-mocks'; @@ -135,28 +132,6 @@ describe('SavedObjectsRepository', () => { const originId = 'some-origin-id'; const namespace = 'foo-namespace'; - // Setup migration mock for updating an object - const mockMigrationVersion = { foo: '2.3.4' }; - const mockMigrateDocumentForUpdate = (doc: SavedObjectUnsanitizedDoc) => { - const response = { - ...doc, - attributes: { - ...doc.attributes, - ...(doc.attributes?.title && { title: `${doc.attributes.title}!!` }), - }, - migrationVersion: mockMigrationVersion, - managed: doc.managed ?? false, - references: doc.references || [ - { - name: 'ref_0', - type: 'test', - id: '1', - }, - ], - }; - return response; - }; - // bulk index calls have two objects for each source -- the action, and the source const expectClientCallArgsAction = ( objects: TypeIdTuple[], @@ -459,7 +434,6 @@ describe('SavedObjectsRepository', () => { const result = await repository.bulkUpdate([obj1, obj2, _obj], options); expect(client.mget).toHaveBeenCalled(); - // @TINA TODO: celan this up with a small parameterized function if (mgetOptions?.statusCode === 404) { expect(client.bulk).not.toHaveBeenCalled(); expect(result).toEqual({ diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.ts index 8deae14f865aa..5268337dca230 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.ts @@ -41,7 +41,6 @@ import { mergeForUpdate, } from './utils'; import { ApiExecutionContext } from './types'; - export interface PerformUpdateParams { objects: Array>; options: SavedObjectsBulkUpdateOptions; @@ -58,8 +57,6 @@ export const performBulkUpdate = async ( } = helpers; const { securityExtension } = extensions; - // console.log('the objects we are testing in unit test for errors:', JSON.stringify(objects)); - const namespace = commonHelper.getCurrentNamespace(options.namespace); const time = getCurrentTime(); @@ -77,10 +74,7 @@ export const performBulkUpdate = async ( migrationVersionCompatibility?: 'raw' | 'compatible'; } >; - // get all docs from ES -> once we have them, we're pretty much in a similar flow as - // bulkCreate when requestId !== undefined && overwrite === true - // maps to expectedResults in bulkCreate const expectedBulkGetResults = objects.map((object) => { const { type, @@ -92,9 +86,7 @@ export const performBulkUpdate = async ( migrationVersionCompatibility, } = object; let error: DecoratedError | undefined; - // console.log( - // `expectedBulkGetResults should return error as result for object that has "*" as namespace', type: ${type}, objectNamespace: ${objectNamespace}` - // ); + if (!allowedTypes.includes(type)) { error = SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } else { @@ -117,8 +109,6 @@ export const performBulkUpdate = async ( ...(Array.isArray(references) && { references }), }; - // const requiresNamespacesCheck = registry.isMultiNamespace(object.type); - return right({ type, id, @@ -129,10 +119,9 @@ export const performBulkUpdate = async ( migrationVersionCompatibility, }); }); - // maps to validObjects in bulkCreate + 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 { // Technically the returned array should only contain SavedObject results, but for errors this is not true (we cast to 'any' below) saved_objects: expectedBulkGetResults.map>( @@ -151,18 +140,15 @@ export const performBulkUpdate = async ( : namespace; const getNamespaceString = (objectNamespace?: string) => objectNamespace ?? namespaceString; - // bulkGetDocs maps to preflightCheckObjects in bulkCreate - // bulkCreate uses bulkGetObjectsAndAliases to mget them - // here we're only interested in the objects themselves + const bulkGetDocs = validObjects - .filter(({ value }) => value.esRequestIndex !== undefined) // line 136 in bulk_create + .filter(({ value }) => value.esRequestIndex !== undefined) .map(({ value: { type, id, objectNamespace } }) => ({ _id: serializer.generateRawId(getNamespaceId(objectNamespace), type, id), _index: commonHelper.getIndexForType(type), - // _source: true, _source: ['type', 'namespaces'], })); - // bulkGetResponse maps to preflightCheckResponse in bulkCreate + const bulkGetResponse = bulkGetDocs.length ? await client.mget( { body: { docs: bulkGetDocs } }, @@ -179,7 +165,7 @@ export const performBulkUpdate = async ( ) { throw SavedObjectsErrorHelpers.createGenericNotFoundEsUnavailableError(); } - // we need to still check auth for bulkUpdate a doc, regardless of using a different esClient API + const authObjects: AuthorizeUpdateObject[] = validObjects.map((element) => { let result; const { type, id, objectNamespace, esRequestIndex: index } = element.value; @@ -189,16 +175,16 @@ export const performBulkUpdate = async ( result = { type, id, - objectNamespace, // the namespace as defined per object in params.objects + objectNamespace, // @ts-expect-error MultiGetHit._source is optional - existingNamespaces: preflightResult?._source?.namespaces ?? [], // we only have _source.namespaces for multi-namespace objects. + existingNamespaces: preflightResult?._source?.namespaces ?? [], }; } else { result = { type, id, objectNamespace, - existingNamespaces: [], // we only have _source.namespaces for multi-namespace objects. + existingNamespaces: [], }; } return result; @@ -250,7 +236,6 @@ export const performBulkUpdate = async ( const docFound = indexFound && esRequestIndex !== undefined && isMgetDoc(actualResult) && actualResult.found; if (registry.isMultiNamespace(type)) { - // esRequestIndex will be undefined for invalid types. we assign an esReqeustIndex to all docs, regardless of namespace type and can't use it as an indicator for a multinamespace object type. if ( !docFound || !rawDocExistsInNamespace( @@ -366,7 +351,7 @@ export const performBulkUpdate = async ( ? await client.bulk({ refresh, body: bulkUpdateParams, - _source_includes: ['originId'], // originId can only be defined for multi-namespace object types + _source_includes: ['originId'], require_alias: true, }) : undefined; @@ -390,7 +375,7 @@ export const performBulkUpdate = async ( const { _seq_no: seqNo, _primary_term: primaryTerm } = rawResponse; // eslint-disable-next-line @typescript-eslint/naming-convention - const { [type]: attributes, references, updated_at } = documentToSave; // use the original request params ?? probably need to return the actual updated doc that exists in es now. + const { [type]: attributes, references, updated_at } = documentToSave; const { originId } = rawMigratedUpdatedDoc._source; return { From 658874f442d1aaa4df5b99a534c232a7cd631245 Mon Sep 17 00:00:00 2001 From: "Christiane (Tina) Heiligers" Date: Wed, 22 Nov 2023 16:00:51 -0700 Subject: [PATCH 12/16] Updates SO lib extensions unit tests --- .../src/lib/apis/bulk_update.ts | 3 +-- .../src/lib/repository.spaces_extension.test.ts | 16 ++++++++-------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.ts index 5268337dca230..5dd59011859df 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.ts @@ -309,7 +309,7 @@ export const performBulkUpdate = async ( updatedAttributes: await encryptionHelper.optionallyEncryptAttributes( type, id, - namespace, + objectNamespace || namespace, documentToSave[type] ), typeMappings: typeDefinition.mappings, @@ -325,7 +325,6 @@ export const performBulkUpdate = async ( updated_at: time, ...(Array.isArray(documentToSave.references) && { references: documentToSave.references }), }); - const updatedMigratedDocumentToSave = serializer.savedObjectToRaw( migratedUpdatedSavedObjectDoc as SavedObjectSanitizedDoc ); diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.spaces_extension.test.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.spaces_extension.test.ts index 29983177adc99..d5ac7bf43cd5e 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.spaces_extension.test.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.spaces_extension.test.ts @@ -696,26 +696,26 @@ describe('SavedObjectsRepository Spaces Extension', () => { expect.objectContaining({ body: expect.arrayContaining([ expect.objectContaining({ - update: expect.objectContaining({ + index: expect.objectContaining({ _id: `${ currentSpace.expectedNamespace ? `${currentSpace.expectedNamespace}:` : '' }${obj1.type}:${obj1.id}`, }), }), expect.objectContaining({ - doc: expect.objectContaining({ - config: obj1.attributes, - }), + // doc: expect.objectContaining({ + config: obj1.attributes, }), + // }), expect.objectContaining({ - update: expect.objectContaining({ + index: expect.objectContaining({ _id: `${obj2.type}:${obj2.id}`, }), }), expect.objectContaining({ - doc: expect.objectContaining({ - multiNamespaceType: obj2.attributes, - }), + // doc: expect.objectContaining({ + multiNamespaceType: obj2.attributes, + // }), }), ]), }), From 1a5f111c42338abeaa2bd00e966644327c8b1da8 Mon Sep 17 00:00:00 2001 From: "Christiane (Tina) Heiligers" Date: Wed, 22 Nov 2023 16:03:59 -0700 Subject: [PATCH 13/16] Updates spaces extension unit tests --- .../src/lib/repository.spaces_extension.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.spaces_extension.test.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.spaces_extension.test.ts index d5ac7bf43cd5e..82a7d2930f8d5 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.spaces_extension.test.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/repository.spaces_extension.test.ts @@ -703,19 +703,16 @@ describe('SavedObjectsRepository Spaces Extension', () => { }), }), expect.objectContaining({ - // doc: expect.objectContaining({ config: obj1.attributes, }), - // }), + expect.objectContaining({ index: expect.objectContaining({ _id: `${obj2.type}:${obj2.id}`, }), }), expect.objectContaining({ - // doc: expect.objectContaining({ multiNamespaceType: obj2.attributes, - // }), }), ]), }), From 9bd32b88cf63084e05950ecb42d40dfec9e81f03 Mon Sep 17 00:00:00 2001 From: pgayvallet Date: Fri, 24 Nov 2023 10:01:19 +0100 Subject: [PATCH 14/16] fix mget call, move a few things around --- .../src/lib/apis/bulk_update.ts | 177 ++++++++---------- .../src/lib/apis/update.ts | 2 +- 2 files changed, 81 insertions(+), 98 deletions(-) diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.ts index 5dd59011859df..97d7e654320e3 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.ts @@ -41,11 +41,38 @@ import { mergeForUpdate, } from './utils'; import { ApiExecutionContext } from './types'; + export interface PerformUpdateParams { objects: Array>; options: SavedObjectsBulkUpdateOptions; } +type DocumentToSave = Record; +type ExpectedBulkGetResult = Either< + { type: string; id: string; error: Payload }, + { + type: string; + id: string; + version?: string; + documentToSave: DocumentToSave; + objectNamespace?: string; + esRequestIndex: number; + migrationVersionCompatibility?: 'raw' | 'compatible'; + } +>; + +type ExpectedBulkUpdateResult = Either< + { type: string; id: string; error: Payload }, + { + type: string; + id: string; + namespaces?: string[]; + documentToSave: DocumentToSave; + esRequestIndex: number; + rawMigratedUpdatedDoc: SavedObjectsRawDoc; + } +>; + export const performBulkUpdate = async ( { objects, options }: PerformUpdateParams, { registry, helpers, allowedTypes, client, serializer, extensions = {} }: ApiExecutionContext @@ -61,20 +88,6 @@ export const performBulkUpdate = async ( const time = getCurrentTime(); let bulkGetRequestIndexCounter = 0; - type DocumentToSave = Record; - type ExpectedBulkGetResult = Either< - { type: string; id: string; error: Payload }, - { - type: string; - id: string; - version?: string; - documentToSave: DocumentToSave; - objectNamespace?: string; - esRequestIndex?: number; - migrationVersionCompatibility?: 'raw' | 'compatible'; - } - >; - const expectedBulkGetResults = objects.map((object) => { const { type, @@ -141,13 +154,11 @@ export const performBulkUpdate = async ( const getNamespaceString = (objectNamespace?: string) => objectNamespace ?? namespaceString; - const bulkGetDocs = validObjects - .filter(({ value }) => value.esRequestIndex !== undefined) - .map(({ value: { type, id, objectNamespace } }) => ({ - _id: serializer.generateRawId(getNamespaceId(objectNamespace), type, id), - _index: commonHelper.getIndexForType(type), - _source: ['type', 'namespaces'], - })); + const bulkGetDocs = validObjects.map(({ value: { type, id, objectNamespace } }) => ({ + _id: serializer.generateRawId(getNamespaceId(objectNamespace), type, id), + _index: commonHelper.getIndexForType(type), + _source: true, + })); const bulkGetResponse = bulkGetDocs.length ? await client.mget( @@ -167,27 +178,25 @@ export const performBulkUpdate = async ( } const authObjects: AuthorizeUpdateObject[] = validObjects.map((element) => { - let result; const { type, id, objectNamespace, esRequestIndex: index } = element.value; + const preflightResult = bulkGetResponse!.body.docs[index]; - const preflightResult = index !== undefined ? bulkGetResponse?.body?.docs[index] : undefined; if (registry.isMultiNamespace(type)) { - result = { + return { type, id, objectNamespace, // @ts-expect-error MultiGetHit._source is optional - existingNamespaces: preflightResult?._source?.namespaces ?? [], + existingNamespaces: preflightResult._source?.namespaces ?? [], }; } else { - result = { + return { type, id, objectNamespace, existingNamespaces: [], }; } - return result; }); const authorizationResult = await securityExtension?.authorizeBulkUpdate({ @@ -198,18 +207,6 @@ export const performBulkUpdate = async ( let bulkUpdateRequestIndexCounter = 0; const bulkUpdateParams: object[] = []; - type ExpectedBulkUpdateResult = Either< - { type: string; id: string; error: Payload }, - { - type: string; - id: string; - namespaces?: string[]; - documentToSave: DocumentToSave; - esRequestIndex: number; - rawMigratedUpdatedDoc: SavedObjectsRawDoc; - } - >; - const expectedBulkUpdateResults = await Promise.all( expectedBulkGetResults.map>(async (expectedBulkGetResult) => { if (isLeft(expectedBulkGetResult)) { @@ -227,81 +224,58 @@ export const performBulkUpdate = async ( } = expectedBulkGetResult.value; let namespaces: string[] | undefined; - let versionProperties; + const versionProperties = getExpectedVersionProperties(version); const indexFound = bulkGetResponse?.statusCode !== 404; - const actualResult = - indexFound && esRequestIndex !== undefined - ? bulkGetResponse?.body.docs[esRequestIndex] - : undefined; - const docFound = - indexFound && esRequestIndex !== undefined && isMgetDoc(actualResult) && actualResult.found; - if (registry.isMultiNamespace(type)) { - if ( - !docFound || + const actualResult = indexFound ? bulkGetResponse?.body.docs[esRequestIndex] : undefined; + const docFound = indexFound && isMgetDoc(actualResult) && actualResult.found; + const isMultiNS = registry.isMultiNamespace(type); + + if ( + !docFound || + (isMultiNS && !rawDocExistsInNamespace( registry, actualResult as SavedObjectsRawDoc, getNamespaceId(objectNamespace) - ) - ) { - return left({ - id, - type, - error: errorContent(SavedObjectsErrorHelpers.createGenericNotFoundError(type, id)), - }); - } + )) + ) { + return left({ + id, + type, + error: errorContent(SavedObjectsErrorHelpers.createGenericNotFoundError(type, id)), + }); + } + + if (isMultiNS) { // @ts-expect-error MultiGetHit is incorrectly missing _id, _source namespaces = actualResult!._source.namespaces ?? [ // @ts-expect-error MultiGetHit is incorrectly missing _id, _source SavedObjectsUtils.namespaceIdToString(actualResult!._source.namespace), ]; - versionProperties = getExpectedVersionProperties(version); - } else { - if (!docFound) { - return left({ - id, - type, - error: errorContent(SavedObjectsErrorHelpers.createGenericNotFoundError(type, id)), - }); - } - if (registry.isSingleNamespace(type)) { - // if `objectNamespace` is undefined, fall back to `options.namespace` - namespaces = [getNamespaceString(objectNamespace)]; - } - versionProperties = getExpectedVersionProperties(version); + } else if (registry.isSingleNamespace(type)) { + // if `objectNamespace` is undefined, fall back to `options.namespace` + namespaces = [getNamespaceString(objectNamespace)]; } - const expectedResult = { + const document = getSavedObjectFromSource( + registry, type, id, - namespaces, - esRequestIndex: bulkUpdateRequestIndexCounter++, - documentToSave: expectedBulkGetResult.value.documentToSave, - rawMigratedUpdatedDoc: {} as SavedObjectsRawDoc, - migrationVersionCompatibility, - }; - let migrated: SavedObject; - const typeDefinition = registry.getType(type)!; + actualResult as SavedObjectsRawDoc, + { migrationVersionCompatibility } + ); - if (docFound) { - const document = getSavedObjectFromSource( - registry, - type, - id, - actualResult as SavedObjectsRawDoc, - { migrationVersionCompatibility } + let migrated: SavedObject; + try { + migrated = migrationHelper.migrateStorageDocument(document) as SavedObject; + } catch (migrateStorageDocError) { + throw SavedObjectsErrorHelpers.decorateGeneralError( + migrateStorageDocError, + 'Failed to migrate document to the latest version.' ); - - try { - migrated = migrationHelper.migrateStorageDocument(document) as SavedObject; - } catch (migrateStorageDocError) { - throw SavedObjectsErrorHelpers.decorateGeneralError( - migrateStorageDocError, - 'Failed to migrate document to the latest version.' - ); - } } + const typeDefinition = registry.getType(type)!; const updatedAttributes = mergeForUpdate({ targetAttributes: { ...migrated!.attributes, @@ -328,7 +302,16 @@ export const performBulkUpdate = async ( const updatedMigratedDocumentToSave = serializer.savedObjectToRaw( migratedUpdatedSavedObjectDoc as SavedObjectSanitizedDoc ); - expectedResult.rawMigratedUpdatedDoc = updatedMigratedDocumentToSave; + + const expectedResult = { + type, + id, + namespaces, + esRequestIndex: bulkUpdateRequestIndexCounter++, + documentToSave: expectedBulkGetResult.value.documentToSave, + rawMigratedUpdatedDoc: updatedMigratedDocumentToSave, + migrationVersionCompatibility, + }; bulkUpdateParams.push( { diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/update.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/update.ts index fd9c587502d7b..61f9cb4cfdb27 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/update.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/update.ts @@ -106,7 +106,7 @@ export const executeUpdate = async ( preflightDocResult, }); - const existingNamespaces = preflightDocNSResult?.savedObjectNamespaces ?? []; + const existingNamespaces = preflightDocNSResult.savedObjectNamespaces ?? []; const authorizationResult = await securityExtension?.authorizeUpdate({ namespace, object: { type, id, existingNamespaces }, From 4defcff2024d3d3be0b6e3802a598121dfe7684a Mon Sep 17 00:00:00 2001 From: "Christiane (Tina) Heiligers" Date: Fri, 24 Nov 2023 14:46:19 -0700 Subject: [PATCH 15/16] clean up --- .../src/test_helpers/repository.test.common.ts | 3 +-- .../saved_objects/service/lib/bulk_update.test.ts | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/test_helpers/repository.test.common.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/test_helpers/repository.test.common.ts index a922787215f7f..d3a31a905de5c 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/test_helpers/repository.test.common.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/test_helpers/repository.test.common.ts @@ -652,7 +652,7 @@ export const getMockBulkUpdateResponse = ( options?: SavedObjectsBulkUpdateOptions, originId?: string ) => { - const mockedBulkUpdateResponse = { + return { items: objects.map(({ type, id }) => ({ index: { _id: `${ @@ -670,7 +670,6 @@ export const getMockBulkUpdateResponse = ( }, })), } as estypes.BulkResponse; - return mockedBulkUpdateResponse; }; export const bulkUpdateSuccess = async ( diff --git a/src/core/server/integration_tests/saved_objects/service/lib/bulk_update.test.ts b/src/core/server/integration_tests/saved_objects/service/lib/bulk_update.test.ts index 2058b08c55b7a..dc620f87ea55b 100644 --- a/src/core/server/integration_tests/saved_objects/service/lib/bulk_update.test.ts +++ b/src/core/server/integration_tests/saved_objects/service/lib/bulk_update.test.ts @@ -20,7 +20,6 @@ import { import { delay } from '../../migrations/test_utils'; import { getBaseMigratorParams } from '../../migrations/fixtures/zdt_base.fixtures'; -// export const logFilePath = Path.join(__dirname, 'update.test.log'); export const logFilePath = Path.join(__dirname, 'bulk_update.test.log'); describe('SOR - bulk_update API', () => { From 8c5338fc588c81be4e960fff98428011e88a8605 Mon Sep 17 00:00:00 2001 From: "Christiane (Tina) Heiligers" Date: Tue, 28 Nov 2023 10:43:56 -0700 Subject: [PATCH 16/16] move migrationVersionCompatibility to global options --- .../src/lib/apis/bulk_update.ts | 23 ++++--------------- .../src/apis/bulk_update.ts | 7 +++--- 2 files changed, 7 insertions(+), 23 deletions(-) diff --git a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.ts b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.ts index 97d7e654320e3..9c119ff86e7dd 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server-internal/src/lib/apis/bulk_update.ts @@ -83,21 +83,13 @@ export const performBulkUpdate = async ( migration: migrationHelper, } = helpers; const { securityExtension } = extensions; - + const { migrationVersionCompatibility } = options; const namespace = commonHelper.getCurrentNamespace(options.namespace); const time = getCurrentTime(); let bulkGetRequestIndexCounter = 0; const expectedBulkGetResults = objects.map((object) => { - const { - type, - id, - attributes, - references, - version, - namespace: objectNamespace, - migrationVersionCompatibility, - } = object; + const { type, id, attributes, references, version, namespace: objectNamespace } = object; let error: DecoratedError | undefined; if (!allowedTypes.includes(type)) { @@ -213,15 +205,8 @@ export const performBulkUpdate = async ( return expectedBulkGetResult; } - const { - esRequestIndex, - id, - type, - version, - documentToSave, - objectNamespace, - migrationVersionCompatibility, - } = expectedBulkGetResult.value; + const { esRequestIndex, id, type, version, documentToSave, objectNamespace } = + expectedBulkGetResult.value; let namespaces: string[] | undefined; const versionProperties = getExpectedVersionProperties(version); diff --git a/packages/core/saved-objects/core-saved-objects-api-server/src/apis/bulk_update.ts b/packages/core/saved-objects/core-saved-objects-api-server/src/apis/bulk_update.ts index b7c77b7ac3ab8..49bc8d769a1d6 100644 --- a/packages/core/saved-objects/core-saved-objects-api-server/src/apis/bulk_update.ts +++ b/packages/core/saved-objects/core-saved-objects-api-server/src/apis/bulk_update.ts @@ -15,10 +15,7 @@ import type { SavedObjectsUpdateOptions, SavedObjectsUpdateResponse } from './up * @public */ export interface SavedObjectsBulkUpdateObject - extends Pick< - SavedObjectsUpdateOptions, - 'version' | 'references' | 'migrationVersionCompatibility' - > { + extends Pick, 'version' | 'references'> { /** The ID of this Saved Object, guaranteed to be unique for all objects of the same `type` */ id: string; /** The type of this Saved Object. Each plugin can define it's own custom Saved Object types. */ @@ -42,6 +39,8 @@ export interface SavedObjectsBulkUpdateObject export interface SavedObjectsBulkUpdateOptions extends SavedObjectsBaseOptions { /** The Elasticsearch Refresh setting for this operation */ refresh?: MutatingOperationRefreshSetting; + /** {@link SavedObjectsRawDocParseOptions.migrationVersionCompatibility} */ + migrationVersionCompatibility?: 'compatible' | 'raw'; } /**